PageSourceSearch

https://manz.dev/cursos/bootcamp/

html manz.dev collected 2026-10-03 22:54:14 UTC 2,424,930 bytes, 59,042 lines download raw bytes

1<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="description" content="Aquí tienes un curso de Desarrollo web desde cero, técnico, enfocado en buenas prácticas y actualizado a 2026."><title>Bootcamp de Desarrollo Web</title><meta property="og:type" content="website"><meta property="og:title" content="Bootcamp de Desarrollo Web"><meta property="og:description" content="Aquí tienes un curso de Desarrollo web desde cero, técnico, enfocado en buenas prácticas y actualizado a 2026."><meta property="og:image" content="https://manz.dev/assets/social.jpg"><meta property="og:url" content="https://manz.dev"><meta property="og:site_name" content="Manz.dev"><meta property="og:locale" content="es_ES"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="Bootcamp de Desarrollo Web"><meta name="twitter:description" content="Aquí tienes un curso de Desarrollo web desde cero, técnico, enfocado en buenas prácticas y actualizado a 2026."><meta name="twitter:image" content="https://manz.dev/assets/social.jpg"><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"><link rel="mask-icon" href="/safari-pinned-tab.svg" color="#603cba"><meta name="msapplication-TileColor" content="#603cba"><meta name="theme-color" content="#603cba"><link rel="preload" href="/assets/fonts/jost-vf.woff2" as="font" type="font/woff2" crossorigin><link rel="preload" href="/assets/fonts/science-gothic-200.woff2" as="font" type="font/woff2" crossorigin><style>@font-face{font-family:Jost;font-display:swap;src:url(/assets/fonts/jost-vf.woff2)format("woff2"),url(/assets/fonts/jost-vf.woff)format("woff")}@font-face{font-family:Science Gothic;font-display:swap;font-weight:200;src:url(/assets/fonts/science-gothic-200.woff2)format("woff2"),url(/assets/fonts/science-gothic-200.woff)format("woff")}@font-face{font-family:Roboto Flex;font-display:swap;font-weight:100;src:url(/assets/fonts/roboto-flex-100.woff2)format("woff2"),url(/assets/fonts/roboto-flex-100.woff)format("woff")}@font-face{font-family:Roboto Flex;font-display:swap;font-weight:600;src:url(/assets/fonts/roboto-flex-600.woff2)format("woff2"),url(/assets/fonts/roboto-flex-600.woff)format("woff")}@font-face{font-family:Victor Mono;font-display:swap;src:url(/assets/fonts/victor-mono.woff2)format("woff2"),url(/assets/fonts/victor-mono.woff)format("woff")}@view-transition{navigation:auto}:root{--header-font:"Science Gothic", sans-serif;--normal-font:Jost, sans-serif;--coding-font:"Victor Mono", monospace}html{scroll-behavior:smooth;scrollbar-gutter:stable;background-color:#0c0c0c}body{--line-color:#5551;--color:#0c8c0c;color:#eee;z-index:-5;font-family:var(--normal-font);background-color:#0c0c0c;background-image:linear-gradient(90deg,#ffffff06 1px,#0000 1px),linear-gradient(#ffffff06 1px,#0000 1px);background-size:25px 25px;margin:0;position:relative}.row{align-items:center;gap:2rem;margin-block:1rem;display:flex;@media (width<800px){flex-direction:column}}.double{place-items:center;gap:2rem;margin-block:1rem;display:grid;@media (width>1024px){grid-template-columns:1fr 1fr;margin-block:2rem}}.column{flex-direction:column;justify-content:center;display:flex}.gap-1{gap:1rem}.w-full{width:100%}.p-2{padding:2rem}.px-2{padding-inline:2rem}.py-2{padding-block:2rem}.wrapper{box-sizing:border-box;align-items:center;width:100%;max-width:1024px;margin:auto}body>.wrapper{@media (width<=900px){width:auto}}:where(a){color:#665fee;text-underline-offset:3px;text-decoration:underline 1px #281d7c;&:hover{color:#0ff;text-decoration-color:#0ff}}blockquote{background:#1c102a;border-left:5px solid #5816a9;margin:0;& p{text-shadow:1px 1px 4px #0008;padding:1rem;& a{color:#9e48db;text-decoration-color:indigo}}}footer[data-astro-cid-nns7i3if]{background:#111;min-height:100px;padding:1.5rem;font-size:20px}a[data-astro-cid-nns7i3if]:has(.logo){text-decoration:none;display:block;position:relative;& .logo{opacity:.5;transition:opacity .25s ease-in-out,scale .25s ease-in-out;scale:.85;&:hover{opacity:1;scale:1}}& :is(.start,.end):before,& :is(.start,.end):after{color:#222;transition:translate .25s ease-in-out,color .25s ease-in-out;position:absolute}&:hover [data-astro-cid-nns7i3if]:is(.start,.end):before,&:hover [data-astro-cid-nns7i3if]:is(.start,.end):after{color:#999}
1& .start:before{content:"⌜";top:-15px;left:-10px}&:hover .start[data-astro-cid-nns7i3if]:before{translate:5px 5px}& .start:after{content:"⌞";bottom:-5px;left:-10px}&:hover .start[data-astro-cid-nns7i3if]:after{translate:5px -5px}& .end:before{content:"⌝";top:-15px;right:-10px}&:hover .end[data-astro-cid-nns7i3if]:before{translate:-5px 5px}& .end:after{content:"⌟";bottom:-5px;right:-10px}&:hover .end[data-astro-cid-nns7i3if]:after{translate:-5px -5px}}.center[data-astro-cid-nns7i3if]{justify-content:center;display:flex}
2</style><link rel="stylesheet" href="/_astro/TextBlock.Bn9oNLpQ.css"><link rel="stylesheet" href="/_astro/index.D4BNk47J.css"></head><body style="--color: #b317a0"><nav data-astro-cid-x3gvl4ly><div class="wrapper row" data-astro-cid-x3gvl4ly><a class="logo" href="/" data-astro-cid-x3gvl4ly><img class="avatar" src="/assets/manzdev.png" alt="manzdev avatar" data-astro-cid-x3gvl4ly><img src="/assets/logo-small.webp" alt="ai.manz.dev" data-astro-cid-x3gvl4ly></a><div class="links" data-astro-cid-x3gvl4ly><div class="option" tabindex="0" data-astro-cid-sxmepdjg>Creaciones<svg data-astro-cid-sxmepdjg><use href="/assets/icons/icons.svg#chevron" data-astro-cid-sxmepdjg></use></svg></div><div class="sub-menu" data-astro-cid-sxmepdjg><h3 data-astro-cid-x3gvl4ly>Si te interesa el código/diseño...</h3><a href="/docencia/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#teacher" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Docencia/Formación</div><div class="sub" data-astro-cid-33etrpfi>Mi actividad como profesor</div></a><a href="/cursos/bootcamp/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/logos.svg#manzdev" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Bootcamp</div><div class="sub" data-astro-cid-33etrpfi>Clases gratuitas de programación web</div></a><a href="https://ai.manz.dev/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#ai-chip" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Inteligencia Artificial</div><div class="sub" data-astro-cid-33etrpfi>Aprende más sobre IA</div></a><a href="/projects/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#tube" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Proyectos</div><div class="sub" data-astro-cid-33etrpfi>Proyectos y experimentos de ManzDev</div></a><a href="/creative/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#artist" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Code Art</div><div class="sub" data-astro-cid-33etrpfi>Zona de código creativo</div></a><a href="https://music.manz.dev/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#music" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Música generada con IA</div><div class="sub" data-astro-cid-33etrpfi>¡Tenemos nuestra propia banda sonora!</div></a></div><div class="option" tabindex="0" data-astro-cid-sxmepdjg>Directos<svg data-astro-cid-sxmepdjg><use href="/assets/icons/icons.svg#chevron" data-astro-cid-sxmepdjg></use></svg></div><div class="sub-menu" data-astro-cid-sxmepdjg><h3 data-astro-cid-x3gvl4ly>Directos y transmisiones...</h3><a href="/streams/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#community" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Streams</div><div class="sub" data-astro-cid-33etrpfi>Directos y comunidad ManzDev</div></a><a href="/streams/apoyo/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#donation" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>¿Cómo apoyar?</div><div class="sub" data-astro-cid-33etrpfi>Formas de apoyar a Manz</div></a><a href="/streams/setup/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#monitor" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Setup/Software</div><div class="sub" data-astro-cid-33etrpfi>¿Qué hardware/software usas?</div></a></div><div class="option" tabindex="0" data-astro-cid-sxmepdjg>Aprender<svg data-astro-cid-sxmepdjg><use href="/assets/icons/icons.svg#chevron" data-astro-cid-sxmepdjg></use></svg></div><div class="sub-menu" data-astro-cid-sxmepdjg><h3 data-astro-cid-x3gvl4ly>¡Quiero aprender contigo!</h3><a href="https://lenguajehtml.com/html/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/logos.svg#html" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>LenguajeHTML</div><div class="sub" data-astro-cid-33etrpfi>Estructura HTML y contenido</div></a><a href="https://lenguajecss.com/css/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/logos.svg#css" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>LenguajeCSS</div><div class="sub" data-astro-cid-33etrpfi>Apariencia, diseño y aspecto visual</div></a><a href="https://lenguajejs.com/javascript/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/logos.svg#javascript" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>LenguajeJS</div><div class="sub" data-astro-cid-33etrpfi>Funcionalidad, lógica y programación</div></a>
2<a href="https://terminaldelinux.com/terminal/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/logos.svg#terminal" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>TerminalDeLinux</div><div class="sub" data-astro-cid-33etrpfi>Uso de la línea de comandos</div></a><a href="/cheatsheets/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#reference" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Cheatsheets</div><div class="sub" data-astro-cid-33etrpfi>PDF descargables de referencia</div></a><a href="https://lenguajecss.com/baseline/" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/icons.svg#baseline" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>Baseline</div><div class="sub" data-astro-cid-33etrpfi>Compatibilidad en navegadores</div></a></div><div class="option" tabindex="0" data-astro-cid-sxmepdjg>Más...<svg data-astro-cid-sxmepdjg><use href="/assets/icons/icons.svg#chevron" data-astro-cid-sxmepdjg></use></svg></div><div class="sub-menu" data-astro-cid-sxmepdjg><h3 data-astro-cid-x3gvl4ly>¡Sígueme en mis otras redes!</h3><a href="https://twitch.tv/ManzDev" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/social.svg#twitch" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>@ManzDev</div><div class="sub" data-astro-cid-33etrpfi>Canal de Twitch</div></a><a href="https://youtube.com/manzdev" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/social.svg#youtube" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>@ManzDev</div><div class="sub" data-astro-cid-33etrpfi>Canal de Youtube</div></a><a href="https://twitter.com/Manz" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/social.svg#twitter" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>@Manz</div><div class="sub" data-astro-cid-33etrpfi>Perfil de Twitter/X</div></a><a href="https://linkedin.com/in/joseromanhdez" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/social.svg#linkedin" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>@JoseRomanHdez</div><div class="sub" data-astro-cid-33etrpfi>Perfil de Linkedin</div></a><a href="https://instagram.com/manz.dev" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/social.svg#instagram" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>@Manz.dev</div><div class="sub" data-astro-cid-33etrpfi>Perfil de Instagram</div></a><a href="https://www.tiktok.com/@manz.dev" tabindex="0" data-astro-cid-33etrpfi><svg data-astro-cid-33etrpfi><use href="/assets/icons/social.svg#tiktok" data-astro-cid-33etrpfi></use></svg><div class="hdr" data-astro-cid-33etrpfi>@Manz.dev</div><div class="sub" data-astro-cid-33etrpfi>Perfil de Tiktok</div></a></div></div></div></nav><header class="hero" data-astro-cid-fzpbxy5g><div class="vhs-wrap" data-astro-cid-claax724><canvas class="vhs-canvas" data-astro-cid-claax724></canvas></div>
2<script type="module" src="/_astro/VHS.astro_astro_type_script_index_0_lang.CY_A9YnZ.js"></script>
2<div class="synthwave-wrap" style="height:300px;" data-astro-cid-olqzasal><canvas class="synthwave-canvas" data-astro-cid-olqzasal></canvas></div>
2<script type="module">(function(){let e=document.querySelector(`.synthwave-canvas`);if(!e)return;let t=e.getContext(`2d`),n=e.parentElement,r,i=new Audio(`/assets/sounds/play.mp3`),a=new Audio(`/assets/sounds/stop.mp3`);i.volume=a.volume=.5;let o=0,s=0,c=!1,l=null,u=0,d=0,f=0,p=0,m=`#0a0a0f`,h=`#b82878`,g=.45,_=.02,v=[];function y(){v=Array.from({length:130},()=>({x:Math.random()*o,y:Math.random()*s*g*.92,r:Math.random()*1.3+.2,a:Math.random()*.55+.15,p:Math.random()*Math.PI*2,s:.006+Math.random()*.016}))}function b(){let r=n.getBoundingClientRect();o=r.width,s=r.height;let i=window.devicePixelRatio||1;e.width=o*i,e.height=s*i,t.setTransform(i,0,0,i,0,0),y(),c||x()}b(),window.addEventListener(`resize`,b);function x(){d=0,f=o/2-200,p=s*g,S()}function S(){let e=p,n=f,r=s*.39;t.fillStyle=m,t.fillRect(0,0,o,s);let i=t.createLinearGradient(0,0,0,e);i.addColorStop(0,`#030308`),i.addColorStop(1,`#080512`),t.fillStyle=i,t.fillRect(0,0,o,e);for(let e of v)e.p+=c?e.s:0,t.globalAlpha=e.a*(.35+.65*Math.sin(e.p)),t.fillStyle=`#fff`,t.beginPath(),t.arc(e.x,e.y,e.r,0,Math.PI*2),t.fill();t.globalAlpha=1;let a=t.createRadialGradient(n,e,r*.2,n,e,r*2.5);a.addColorStop(0,`rgba(244,114,182,0.14)`),a.addColorStop(.5,`rgba(99,102,241,0.06)`),a.addColorStop(1,`rgba(99,102,241,0)`),t.fillStyle=a,t.fillRect(0,0,o,e+30);let l=t.createLinearGradient(n,e-r,n,e);l.addColorStop(0,`#f472b6`),l.addColorStop(1,`#6366f1`),t.save(),t.beginPath(),t.arc(n,e,r,Math.PI,0),t.closePath(),t.clip(),t.fillStyle=l,t.fillRect(n-r,e-r,r*2,r),t.fillStyle=m;for(let i=0;i<9;i++){let a=(i+1)/10,o=e-r+a*r*2,s=1+(1-a)*5;t.fillRect(n-r-1,o,r*2+2,s)}t.restore();let u=t.createLinearGradient(0,e,0,s);u.addColorStop(0,`#0c0618`),u.addColorStop(1,`#060410`),t.fillStyle=u,t.fillRect(0,e,o,s-e),t.strokeStyle=h,t.lineWidth=1;for(let n=0;n<25;n++){let r=(n/25+d)%1,i=e+r*r*(s-e);t.globalAlpha=.22*(1-r*.6),t.beginPath(),t.moveTo(0,i),t.lineTo(o,i),t.stroke()}t.strokeStyle=h,t.globalAlpha=.12,t.lineWidth=1;for(let r=0;r<=16;r++)t.beginPath(),t.moveTo(n,e),t.lineTo(r/16*o,s),t.stroke();t.globalAlpha=.06,t.lineWidth=1;for(let r=0;r<8;r++){let i=o*.15*(r+1);t.beginPath(),t.moveTo(n,e),t.lineTo(-i,s),t.stroke(),t.beginPath(),t.moveTo(n,e),t.lineTo(o+i,s),t.stroke()}t.globalAlpha=1}function C(){let e=c?o/2:o/2-200,t=c?s*.5:s*g;f+=(e-f)*_,p+=(t-p)*_,c&&(d+=7e-4),S();let n=Math.abs(f-e)<.5&&Math.abs(p-t)<.5;(c||!n)&&(u=requestAnimationFrame(C))}x();function w(){let e=r&&!r.paused;!c&&!l&&!e||(l&&=(clearTimeout(l),null),c=!1,r&&=(r.removeEventListener(`ended`,T),r.pause(),null),a.currentTime=0,a.play().catch(()=>{}),cancelAnimationFrame(u),C())}function T(){w(),window.dispatchEvent(new Event(`synthwave:stop`)),window.dispatchEvent(new Event(`synthwave:ended`))}function E(){c||(l&&=(clearTimeout(l),null),c=!0,i.currentTime=0,i.play().catch(()=>{}),r&&=(r.removeEventListener(`ended`,T),r.pause(),null),r=new Audio(`/assets/sounds/neon-lights-manz.mp3`),r.volume=.5,r.addEventListener(`ended`,T),l=setTimeout(()=>{l=null,c&&r?.play().catch(()=>{})},750),cancelAnimationFrame(u),C())}window.addEventListener(`synthwave:play`,E),window.addEventListener(`synthwave:stop`,w)})();</script>
2<script type="module">var e=class extends HTMLElement{onPlay=()=>{this.removeCredits();let e=this.querySelector(`template`),t=document.createElement(`div`);t.className=`credits`,t.appendChild(e.content.cloneNode(!0)),t.addEventListener(`animationend`,()=>this.removeCredits()),this.appendChild(t)};onStop=()=>{this.removeCredits()};removeCredits(){this.querySelector(`.credits`)?.remove()}connectedCallback(){window.addEventListener(`synthwave:play`,this.onPlay),window.addEventListener(`synthwave:stop`,this.onStop)}disconnectedCallback(){window.removeEventListener(`synthwave:play`,this.onPlay),window.removeEventListener(`synthwave:stop`,this.onStop)}};customElements.define(`manzh-credits`,e);</script>
2<manzh-credits><template><div class="credits-content"><p class="credits-title">A MANZ.DEV PRODUCTION</p><p>Directed by<br><strong>Manz</strong></p><p>Starring<br><strong>Manz.dev</strong></p><p>Music<br><strong>Neon Lights</strong></p><p>VHS Effects<br><strong>&lt;canvas&gt;</strong></p><p>Executive Producer<br><strong>The Chat</strong></p><p>Built with<br><strong>0 lines of React</strong></p><p>TailwindCSS avoided by<br><strong>The Duck</strong></p><p>Responsive support<br><strong>¯\_(ツ)_/¯</strong></p><p>Floating-Point Precision<br><strong>0.30000000000000004</strong></p><p>Ban Hammer by<br><strong>Felixicaza</strong></p><p>Production Deployments<br><strong>On friday</strong></p><p>Node Modules size<br><strong>14.7TB</strong></p><p>Monthly DHardy bans<br><strong>234</strong></p><p>Legacy Code<br><strong>Protected by Ancient seniors</strong></p><p>It doesn't work today<br><strong>No one knows why</strong></p><p>It worked yesterday<br><strong>No one knows why</strong></p><p>It worked today<br><strong>No one knows why</strong></p><p>Versioning control<br><strong>final_v2_FINAL_really_final.tar.gz</strong></p><p>npm packages ignored<br><strong>47.382</strong></p><p>Math Coprocessor tests...<br><strong>114%/100% completed</strong></p><p>AI hallucinations reviewed by<br><strong>A tired human</strong></p><p>SSL Certificates<br><strong>expired yesterday</strong></p><p>UTF-8 Bugs<br><strong>¡Olé!</strong></p><p>Rubber Duck<br><strong>Employee of the Month</strong></p><p>Memory leaks count<br><strong>trying rembember...</strong></p><p>17 misconceptions corrected today by<br><strong>The Hypertext Guardian</strong></p><p>Merge conflicts<br><strong>resolved with violence</strong></p><p>Kitty 127.0.0.1 host<br><strong>lolcathost</strong></p><p style="translate: 80px 0">CSS Centering by<br><strong>A Backend developer</strong></p><p>Internet Explorer support<br><strong>Mmmmmm... nope</strong></p><p>Single source of truth<br><strong>Currently, there are 4</strong></p><p>Dark-to-Light Mode casualties<br><strong>42</strong></p><p>Lines written by Claude<br><strong>0 lines</strong></p><p><br><br>The Hypertext Guardian will return...<br><br>...probably because someone just said HTML is a programming language.<br><br></p><p class="credits-end">Thanks for watching!<br>Remember follow me on social networks!</p></div></template></manzh-credits><div class="wrapper" data-astro-cid-fzpbxy5g><div class="logo-container" data-astro-cid-fzpbxy5g><img class="manz" draggable="false" src="/assets/characters/manz.avif" alt="Manz" data-astro-cid-fzpbxy5g></div><div class="herotext column" data-astro-cid-fzpbxy5g><h1 data-astro-cid-3yfbwi5j>Bootcamp de Desarrollo Web</h1><p class="tag-generator" data-astro-cid-xge5h22k><em class="tagline" data-astro-cid-xge5h22k>It's a game-changer</em></p>
2<script type="module">var e=document.querySelector(`.tagline`),t=[`Ahora 500ms más rápido con Bun`,`Todos sus <code>&lt;div&gt;</code> están perfectamente centrados`,`Este sitio está patrocinado por jQuery64`,`El sitio más visto en GPT-vision 5000`,`Este sitio es seguro en Ukron-6B`,`Presentado en DobleVision™ (tras 6h programando)`,`Creado por Manz9000 con Bootstrap-8000`,`Better viewed on Internet Explorer 9`,`React was a mistake`,`Para su seguridad este sitio no cuenta con factor de magnetismo`,`No contamos con 5G`,`Si puedes leer esto estas muy cerca de la pantalla`,`Libre de pago de 0.20€ de cuota cada vez que pulsas F5`,`Orgullosamente hecho en PHP 6`,`Ese es mi secreto, siempre programo cansado`,`En 127.0.0.1 funcionaba...`,`No le vamos a dar tu computadora al cliente`,`Nunca te pediremos tus claves por SMS`,`As seen on TV`,`Basado en hechos reales`,`Sólo para uso externo`,`Guarde la documentación para futuras referencias`,`Cada vez que oiga «OJO» o «NICE», chupito`,`Ahora sin trazas de bootstrap`,`Conocido por provocar locura en ratones de laboratorio`,`¿Por qué no en React?`,`↑ ↑ ↓ ↓ ← → ← → B A`,`Si quieres sobresalir en una entrevista de trabajo, <code>z-index:9999!important</code>`,`Costo extra por cada <code>&lt;div&gt;</code> si son más de 20`,`Me gustaría ser el CSS de tu HTML, pero no consigo salir de vim`,`No seas como <a href="https://www.tiktok.com/@manz.dev/video/7282063163032440097">él</a>`,`Disponible para descarga en napster`,`Error 404: Frase ingeniosa no encontrada`,`Hecho con amor por un backend <code>.container { center: please; }</code>`,`No puc més`,`Si algún día tengo un perro, lo llamare Jason, para poder parsearlo...`,`Ahora con un 12% menos de deuda técnica`,`Validado por un patito de goma`,`Este sitio contiene cantidades peligrosas de CSS`,`Este sitio contiene referencias que entenderán solo tres personas`,`Lo siento Mario, la documentación está en otro castillo`,`Todo estaba alineado hasta que llegó Safari`,`Si encuentras un bug, es coleccionable`,`No hemos sacrificado ningún junior durante el desarrollo`],n=()=>{e.innerHTML=t[Math.ceil(Math.random()*t.length-1)]};n(),e.addEventListener(`click`,()=>n());</script>
2<p data-astro-cid-3yfbwi5j>Aquí tienes un curso de Desarrollo web desde cero, técnico, enfocado en buenas prácticas y actualizado a 2026.</p></div></div><div class="flash-overlay" data-astro-cid-fzpbxy5g></div></header>
2<script type="module" src="/_astro/SiteHeader.astro_astro_type_script_index_0_lang.CY6PaTGc.js"></script>
2<main class="wrapper p-2" data-astro-cid-3yfbwi5j><a href="#contenidos-del-curso" data-astro-cid-wcvghafu><h2 id="contenidos-del-curso" data-astro-cid-wcvghafu><span data-astro-cid-wcvghafu>Contenidos del curso</span></h2></a><div class="text-block"><p>Aquí tienes disponible nuestro <strong>Bootcamp de Desarrollo web 100% gratuito</strong> en formato de roadmap de curso. Más de 80 horas de contenido, desde lo más básico hasta cosas modernas y avanzadas. Probablemente, el bootcamp gratuito más completo que vas a encontrar, ideal para ENTENDER lo que estás haciendo. También tienes la web del <a href="https://bootcamp.manz.dev/">Bootcamp</a> o puedes verla desde <a href="https://dub.sh/bootcamp-2026">Youtube</a> si lo prefieres.</p></div><details class="phase" open data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>FASE 1: Fundamentos</summary><div class="phase-content" data-astro-cid-3yfbwi5j><div class="description" data-astro-cid-3yfbwi5j><p data-astro-cid-3yfbwi5j>En este primer bloque aprenderás las bases y fundamentos que debes saber para trabajar como developer. Desde nociones básicas y avanzadas de terminal, pasando por fundamentos de programación en Javascript y trabajar con herramientas de Inteligencia Artificial.</p></div><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 1 📕 Terminal Linux<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="60" data-end="435" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Presentación inicial</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>6 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-presentacion-inicial" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
2<script>
3  (() => {
4    if (window.__courseLazyPlayersInitialized) return;
5    window.__courseLazyPlayersInitialized = true;
6
7    function createPlayer(details) {
8      const videoId = details.dataset.videoId;
9      const start = details.dataset.start;
10      const end = details.dataset.end;
11
12      const container = document.createElement("div");
13      container.className = "player-container";
14
15      const spinner = document.createElement("div");
16      spinner.className = "spinner";
17      container.appendChild(spinner);
18
19      const controller = document.createElement("media-controller");
20      controller.setAttribute("defaultsubtitles", "");
21
22      const provider = document.createElement("youtube-media-provider");
23      provider.setAttribute("slot", "media");
24      provider.setAttribute("video-id", videoId);
25      provider.setAttribute("start", start);
26      provider.setAttribute("end", end);
27
28      const controlBar = document.createElement("media-control-bar");
29
30      const playBtn = document.createElement("media-play-button");
31      const timeDisplay = document.createElement("media-time-display");
32      timeDisplay.setAttribute("showduration", "");
33      const timeRange = document.createElement("media-time-range");
34      const playbackRate = document.createElement("media-playback-rate-button");
35      const muteBtn = document.createElement("media-mute-button");
36      const volumeRange = document.createElement("media-volume-range");
37
38      const captionsBtn = document.createElement("media-captions-button");
39      captionsBtn.addEventListener("click", (e) => {
40        e.stopPropagation();
41        const provider = container.querySelector("youtube-media-provider");
42        if (provider) provider.toggleCaptions();
43      });
44
45      const qualityBtn = document.createElement("media-chrome-button");
46      qualityBtn.textContent = "--";
47      qualityBtn.addEventListener("click", () => {
48        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
49      });
50
51      const fullscreenBtn = document.createElement("media-fullscreen-button");
52
53      controlBar.append(
54        playBtn, timeDisplay, timeRange,
55        playbackRate, muteBtn, volumeRange,
56        captionsBtn, qualityBtn, fullscreenBtn
57      );
58
59      const poster = document.createElement("media-poster-image");
60      poster.setAttribute("slot", "poster");
61      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
62
63      controller.append(provider, poster, controlBar);
64
65      captionsBtn.setAttribute("aria-checked", "true");
66      controller.setAttribute("mediasubtitlesshowing", "on");
67
68      container.appendChild(controller);
69      details.appendChild(container);
70
71      provider.addEventListener("waiting", () => container.classList.add("loading"));
72      provider.addEventListener("playing", () => container.classList.remove("loading"));
73      provider.addEventListener("play", () => container.classList.remove("loading"));
74
75      provider._textTracks.addEventListener("change", () => {
76        const enabled = provider._captionsEnabled;
77        captionsBtn.setAttribute("aria-checked", enabled);
78        if (enabled) {
79          controller.setAttribute("mediasubtitlesshowing", "on");
80        } else {
81          controller.removeAttribute("mediasubtitlesshowing");
82        }
83      });
84
85      provider.addEventListener("qualitychange", (e) => {
86        qualityBtn.textContent = e.detail.quality;
87      });
88    }
89
90    function initLazyPlayers() {
91      document.querySelectorAll("details[data-video-id]").forEach((details) => {
92        if (details.dataset.lazyInit) return;
93        details.dataset.lazyInit = "true";
94        details.addEventListener("toggle", () => {
95          if (details.open && !details.querySelector(".player-container")) {
96            createPlayer(details);
97          } else if (!details.open) {
98            const provider = details.querySelector("youtube-media-provider");
99            if (provider && typeof provider.pause === "function") {
100              provider.pause();
101            }
102          }
103        });
104      });
105    }
106
107    if (document.readyState === "loading") {
108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
109    } else {
110      initLazyPlayers();
111    }
112  })();
113</script>
113<script>
114  (() => {
115    if (window.__courseCompletionsInitialized) return;
116    window.__courseCompletionsInitialized = true;
117
118    const STORAGE_KEY = "course-completions";
119
120    function slugify(text) {
121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
122    }
123
124    function getCompletions() {
125      try {
126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
127      } catch {
128        return [];
129      }
130    }
131
132    function saveCompletions(completions) {
133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
134    }
135
136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
137    function canonicalFromStored(storedId) {
138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
139      if (!m) return slugify(storedId);
140      const [, slug, p, mv, title] = m;
141      return `${slug}-${p}-${mv}-${slugify(title)}`;
142    }
143
144    function canonicalPlusOne(storedId) {
145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
146      if (!m) return null;
147      const [, slug, p, mv, title] = m;
148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
149    }
150
151    function isCompleted(canonicalId, completions) {
152      if (completions.includes(canonicalId)) return true;
153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
155    }
156
157    function migrateCompletions() {
158      const completions = getCompletions();
159      let changed = false;
160      const migrated = completions.map((c) => {
161        const canon = canonicalFromStored(c);
162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
163        const plusOne = canonicalPlusOne(c);
164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
165          if (plusOne !== c) changed = true;
166          return plusOne;
167        }
168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
169          changed = true;
170          return canon;
171        }
172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
173        if (canon !== c) {
174          changed = true;
175          return canon;
176        }
177        return c;
178      });
179      // Elimina duplicados tras migración
180      const deduped = [...new Set(migrated)];
181      if (changed || deduped.length !== completions.length) {
182        saveCompletions(deduped);
183        return deduped;
184      }
185      return completions;
186    }
187
188    function updateButtonState(btn, completions) {
189      const id = btn.dataset.completionId;
190      const list = completions ?? getCompletions();
191      btn.classList.toggle("completed", isCompleted(id, list));
192    }
193
194    function checkModuleCompletion(completions) {
195      const list = completions ?? getCompletions();
196      document.querySelectorAll(".course-video").forEach((module) => {
197        const btns = module.querySelectorAll(".complete-btn");
198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
199        const check = module.querySelector(".module-check");
200        if (check) check.classList.toggle("visible", allCompleted);
201      });
202    }
203
204    function initCompletionButtons() {
205      const completions = migrateCompletions();
206
207      document.querySelectorAll(".complete-btn").forEach((btn) => {
208        if (btn.dataset.completionInit) return;
209        btn.dataset.completionInit = "true";
210        updateButtonState(btn, completions);
211
212        btn.addEventListener("click", (e) => {
213          e.preventDefault();
214          e.stopPropagation();
215
216          const id = btn.dataset.completionId;
217          let list = getCompletions();
218          const completed = isCompleted(id, list);
219
220          if (completed) {
221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
222            list = list.filter((c) =>
222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
223          } else {
224            list.push(id);
225          }
226
227          saveCompletions(list);
228          updateButtonState(btn, list);
229          checkModuleCompletion(list);
230        });
231      });
232
233      checkModuleCompletion(completions);
234    }
235
236    if (document.readyState === "loading") {
237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
238    } else {
239      initCompletionButtons();
240    }
241  })();
242</script>
242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="435" data-end="1305" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Introducción al Bootcamp</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-introduccion-al-bootcamp" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
242<script>
243  (() => {
244    if (window.__courseLazyPlayersInitialized) return;
245    window.__courseLazyPlayersInitialized = true;
246
247    function createPlayer(details) {
248      const videoId = details.dataset.videoId;
249      const start = details.dataset.start;
250      const end = details.dataset.end;
251
252      const container = document.createElement("div");
253      container.className = "player-container";
254
255      const spinner = document.createElement("div");
256      spinner.className = "spinner";
257      container.appendChild(spinner);
258
259      const controller = document.createElement("media-controller");
260      controller.setAttribute("defaultsubtitles", "");
261
262      const provider = document.createElement("youtube-media-provider");
263      provider.setAttribute("slot", "media");
264      provider.setAttribute("video-id", videoId);
265      provider.setAttribute("start", start);
266      provider.setAttribute("end", end);
267
268      const controlBar = document.createElement("media-control-bar");
269
270      const playBtn = document.createElement("media-play-button");
271      const timeDisplay = document.createElement("media-time-display");
272      timeDisplay.setAttribute("showduration", "");
273      const timeRange = document.createElement("media-time-range");
274      const playbackRate = document.createElement("media-playback-rate-button");
275      const muteBtn = document.createElement("media-mute-button");
276      const volumeRange = document.createElement("media-volume-range");
277
278      const captionsBtn = document.createElement("media-captions-button");
279      captionsBtn.addEventListener("click", (e) => {
280        e.stopPropagation();
281        const provider = container.querySelector("youtube-media-provider");
282        if (provider) provider.toggleCaptions();
283      });
284
285      const qualityBtn = document.createElement("media-chrome-button");
286      qualityBtn.textContent = "--";
287      qualityBtn.addEventListener("click", () => {
288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
289      });
290
291      const fullscreenBtn = document.createElement("media-fullscreen-button");
292
293      controlBar.append(
294        playBtn, timeDisplay, timeRange,
295        playbackRate, muteBtn, volumeRange,
296        captionsBtn, qualityBtn, fullscreenBtn
297      );
298
299      const poster = document.createElement("media-poster-image");
300      poster.setAttribute("slot", "poster");
301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
302
303      controller.append(provider, poster, controlBar);
304
305      captionsBtn.setAttribute("aria-checked", "true");
306      controller.setAttribute("mediasubtitlesshowing", "on");
307
308      container.appendChild(controller);
309      details.appendChild(container);
310
311      provider.addEventListener("waiting", () => container.classList.add("loading"));
312      provider.addEventListener("playing", () => container.classList.remove("loading"));
313      provider.addEventListener("play", () => container.classList.remove("loading"));
314
315      provider._textTracks.addEventListener("change", () => {
316        const enabled = provider._captionsEnabled;
317        captionsBtn.setAttribute("aria-checked", enabled);
318        if (enabled) {
319          controller.setAttribute("mediasubtitlesshowing", "on");
320        } else {
321          controller.removeAttribute("mediasubtitlesshowing");
322        }
323      });
324
325      provider.addEventListener("qualitychange", (e) => {
326        qualityBtn.textContent = e.detail.quality;
327      });
328    }
329
330    function initLazyPlayers() {
331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
332        if (details.dataset.lazyInit) return;
333        details.dataset.lazyInit = "true";
334        details.addEventListener("toggle", () => {
335          if (details.open && !details.querySelector(".player-container")) {
336            createPlayer(details);
337          } else if (!details.open) {
338            const provider = details.querySelector("youtube-media-provider");
339            if (provider && typeof provider.pause === "function") {
340              provider.pause();
341            }
342          }
343        });
344      });
345    }
346
347    if (document.readyState === "loading") {
348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
349    } else {
350      initLazyPlayers();
351    }
352  })();
353</script>
353<script>
354  (() => {
355    if (window.__courseCompletionsInitialized) return;
356    window.__courseCompletionsInitialized = true;
357
358    const STORAGE_KEY = "course-completions";
359
360    function slugify(text) {
361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
362    }
363
364    function getCompletions() {
365      try {
366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
367      } catch {
368        return [];
369      }
370    }
371
372    function saveCompletions(completions) {
373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
374    }
375
376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
377    function canonicalFromStored(storedId) {
378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
379      if (!m) return slugify(storedId);
380      const [, slug, p, mv, title] = m;
381      return `${slug}-${p}-${mv}-${slugify(title)}`;
382    }
383
384    function canonicalPlusOne(storedId) {
385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
386      if (!m) return null;
387      const [, slug, p, mv, title] = m;
388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
389    }
390
391    function isCompleted(canonicalId, completions) {
392      if (completions.includes(canonicalId)) return true;
393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
395    }
396
397    function migrateCompletions() {
398      const completions = getCompletions();
399      let changed = false;
400      const migrated = completions.map((c) => {
401        const canon = canonicalFromStored(c);
402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
403        const plusOne = canonicalPlusOne(c);
404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
405          if (plusOne !== c) changed = true;
406          return plusOne;
407        }
408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
409          changed = true;
410          return canon;
411        }
412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
413        if (canon !== c) {
414          changed = true;
415          return canon;
416        }
417        return c;
418      });
419      // Elimina duplicados tras migración
420      const deduped = [...new Set(migrated)];
421      if (changed || deduped.length !== completions.length) {
422        saveCompletions(deduped);
423        return deduped;
424      }
425      return completions;
426    }
427
428    function updateButtonState(btn, completions) {
429      const id = btn.dataset.completionId;
430      const list = completions ?? getCompletions();
431      btn.classList.toggle("completed", isCompleted(id, list));
432    }
433
434    function checkModuleCompletion(completions) {
435      const list = completions ?? getCompletions();
436      document.querySelectorAll(".course-video").forEach((module) => {
437        const btns = module.querySelectorAll(".complete-btn");
438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
439        const check = module.querySelector(".module-check");
440        if (check) check.classList.toggle("visible", allCompleted);
441      });
442    }
443
444    function initCompletionButtons() {
445      const completions = migrateCompletions();
446
447      document.querySelectorAll(".complete-btn").forEach((btn) => {
448        if (btn.dataset.completionInit) return;
449        btn.dataset.completionInit = "true";
450        updateButtonState(btn, completions);
451
452        btn.addEventListener("click", (e) => {
453          e.preventDefault();
454          e.stopPropagation();
455
456          const id = btn.dataset.completionId;
457          let list = getCompletions();
458          const completed = isCompleted(id, list);
459
460          if (completed) {
461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
462            list = list.filter((c) =>
462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
463          } else {
464            list.push(id);
465          }
466
467          saveCompletions(list);
468          updateButtonState(btn, list);
469          checkModuleCompletion(list);
470        });
471      });
472
473      checkModuleCompletion(completions);
474    }
475
476    if (document.readyState === "loading") {
477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
478    } else {
479      initCompletionButtons();
480    }
481  })();
482</script>
482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="1305" data-end="2165" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Inicio del Bootcamp</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-inicio-del-bootcamp" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
482<script>
483  (() => {
484    if (window.__courseLazyPlayersInitialized) return;
485    window.__courseLazyPlayersInitialized = true;
486
487    function createPlayer(details) {
488      const videoId = details.dataset.videoId;
489      const start = details.dataset.start;
490      const end = details.dataset.end;
491
492      const container = document.createElement("div");
493      container.className = "player-container";
494
495      const spinner = document.createElement("div");
496      spinner.className = "spinner";
497      container.appendChild(spinner);
498
499      const controller = document.createElement("media-controller");
500      controller.setAttribute("defaultsubtitles", "");
501
502      const provider = document.createElement("youtube-media-provider");
503      provider.setAttribute("slot", "media");
504      provider.setAttribute("video-id", videoId);
505      provider.setAttribute("start", start);
506      provider.setAttribute("end", end);
507
508      const controlBar = document.createElement("media-control-bar");
509
510      const playBtn = document.createElement("media-play-button");
511      const timeDisplay = document.createElement("media-time-display");
512      timeDisplay.setAttribute("showduration", "");
513      const timeRange = document.createElement("media-time-range");
514      const playbackRate = document.createElement("media-playback-rate-button");
515      const muteBtn = document.createElement("media-mute-button");
516      const volumeRange = document.createElement("media-volume-range");
517
518      const captionsBtn = document.createElement("media-captions-button");
519      captionsBtn.addEventListener("click", (e) => {
520        e.stopPropagation();
521        const provider = container.querySelector("youtube-media-provider");
522        if (provider) provider.toggleCaptions();
523      });
524
525      const qualityBtn = document.createElement("media-chrome-button");
526      qualityBtn.textContent = "--";
527      qualityBtn.addEventListener("click", () => {
528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
529      });
530
531      const fullscreenBtn = document.createElement("media-fullscreen-button");
532
533      controlBar.append(
534        playBtn, timeDisplay, timeRange,
535        playbackRate, muteBtn, volumeRange,
536        captionsBtn, qualityBtn, fullscreenBtn
537      );
538
539      const poster = document.createElement("media-poster-image");
540      poster.setAttribute("slot", "poster");
541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
542
543      controller.append(provider, poster, controlBar);
544
545      captionsBtn.setAttribute("aria-checked", "true");
546      controller.setAttribute("mediasubtitlesshowing", "on");
547
548      container.appendChild(controller);
549      details.appendChild(container);
550
551      provider.addEventListener("waiting", () => container.classList.add("loading"));
552      provider.addEventListener("playing", () => container.classList.remove("loading"));
553      provider.addEventListener("play", () => container.classList.remove("loading"));
554
555      provider._textTracks.addEventListener("change", () => {
556        const enabled = provider._captionsEnabled;
557        captionsBtn.setAttribute("aria-checked", enabled);
558        if (enabled) {
559          controller.setAttribute("mediasubtitlesshowing", "on");
560        } else {
561          controller.removeAttribute("mediasubtitlesshowing");
562        }
563      });
564
565      provider.addEventListener("qualitychange", (e) => {
566        qualityBtn.textContent = e.detail.quality;
567      });
568    }
569
570    function initLazyPlayers() {
571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
572        if (details.dataset.lazyInit) return;
573        details.dataset.lazyInit = "true";
574        details.addEventListener("toggle", () => {
575          if (details.open && !details.querySelector(".player-container")) {
576            createPlayer(details);
577          } else if (!details.open) {
578            const provider = details.querySelector("youtube-media-provider");
579            if (provider && typeof provider.pause === "function") {
580              provider.pause();
581            }
582          }
583        });
584      });
585    }
586
587    if (document.readyState === "loading") {
588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
589    } else {
590      initLazyPlayers();
591    }
592  })();
593</script>
593<script>
594  (() => {
595    if (window.__courseCompletionsInitialized) return;
596    window.__courseCompletionsInitialized = true;
597
598    const STORAGE_KEY = "course-completions";
599
600    function slugify(text) {
601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
602    }
603
604    function getCompletions() {
605      try {
606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
607      } catch {
608        return [];
609      }
610    }
611
612    function saveCompletions(completions) {
613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
614    }
615
616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
617    function canonicalFromStored(storedId) {
618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
619      if (!m) return slugify(storedId);
620      const [, slug, p, mv, title] = m;
621      return `${slug}-${p}-${mv}-${slugify(title)}`;
622    }
623
624    function canonicalPlusOne(storedId) {
625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
626      if (!m) return null;
627      const [, slug, p, mv, title] = m;
628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
629    }
630
631    function isCompleted(canonicalId, completions) {
632      if (completions.includes(canonicalId)) return true;
633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
635    }
636
637    function migrateCompletions() {
638      const completions = getCompletions();
639      let changed = false;
640      const migrated = completions.map((c) => {
641        const canon = canonicalFromStored(c);
642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
643        const plusOne = canonicalPlusOne(c);
644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
645          if (plusOne !== c) changed = true;
646          return plusOne;
647        }
648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
649          changed = true;
650          return canon;
651        }
652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
653        if (canon !== c) {
654          changed = true;
655          return canon;
656        }
657        return c;
658      });
659      // Elimina duplicados tras migración
660      const deduped = [...new Set(migrated)];
661      if (changed || deduped.length !== completions.length) {
662        saveCompletions(deduped);
663        return deduped;
664      }
665      return completions;
666    }
667
668    function updateButtonState(btn, completions) {
669      const id = btn.dataset.completionId;
670      const list = completions ?? getCompletions();
671      btn.classList.toggle("completed", isCompleted(id, list));
672    }
673
674    function checkModuleCompletion(completions) {
675      const list = completions ?? getCompletions();
676      document.querySelectorAll(".course-video").forEach((module) => {
677        const btns = module.querySelectorAll(".complete-btn");
678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
679        const check = module.querySelector(".module-check");
680        if (check) check.classList.toggle("visible", allCompleted);
681      });
682    }
683
684    function initCompletionButtons() {
685      const completions = migrateCompletions();
686
687      document.querySelectorAll(".complete-btn").forEach((btn) => {
688        if (btn.dataset.completionInit) return;
689        btn.dataset.completionInit = "true";
690        updateButtonState(btn, completions);
691
692        btn.addEventListener("click", (e) => {
693          e.preventDefault();
694          e.stopPropagation();
695
696          const id = btn.dataset.completionId;
697          let list = getCompletions();
698          const completed = isCompleted(id, list);
699
700          if (completed) {
701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
702            list = list.filter((c) =>
702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
703          } else {
704            list.push(id);
705          }
706
707          saveCompletions(list);
708          updateButtonState(btn, list);
709          checkModuleCompletion(list);
710        });
711      });
712
713      checkModuleCompletion(completions);
714    }
715
716    if (document.readyState === "loading") {
717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
718    } else {
719      initCompletionButtons();
720    }
721  })();
722</script>
722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="2165" data-end="3930" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Preparación de entorno y WSL</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>29 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-preparacion-de-entorno-y-wsl" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
722<script>
723  (() => {
724    if (window.__courseLazyPlayersInitialized) return;
725    window.__courseLazyPlayersInitialized = true;
726
727    function createPlayer(details) {
728      const videoId = details.dataset.videoId;
729      const start = details.dataset.start;
730      const end = details.dataset.end;
731
732      const container = document.createElement("div");
733      container.className = "player-container";
734
735      const spinner = document.createElement("div");
736      spinner.className = "spinner";
737      container.appendChild(spinner);
738
739      const controller = document.createElement("media-controller");
740      controller.setAttribute("defaultsubtitles", "");
741
742      const provider = document.createElement("youtube-media-provider");
743      provider.setAttribute("slot", "media");
744      provider.setAttribute("video-id", videoId);
745      provider.setAttribute("start", start);
746      provider.setAttribute("end", end);
747
748      const controlBar = document.createElement("media-control-bar");
749
750      const playBtn = document.createElement("media-play-button");
751      const timeDisplay = document.createElement("media-time-display");
752      timeDisplay.setAttribute("showduration", "");
753      const timeRange = document.createElement("media-time-range");
754      const playbackRate = document.createElement("media-playback-rate-button");
755      const muteBtn = document.createElement("media-mute-button");
756      const volumeRange = document.createElement("media-volume-range");
757
758      const captionsBtn = document.createElement("media-captions-button");
759      captionsBtn.addEventListener("click", (e) => {
760        e.stopPropagation();
761        const provider = container.querySelector("youtube-media-provider");
762        if (provider) provider.toggleCaptions();
763      });
764
765      const qualityBtn = document.createElement("media-chrome-button");
766      qualityBtn.textContent = "--";
767      qualityBtn.addEventListener("click", () => {
768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
769      });
770
771      const fullscreenBtn = document.createElement("media-fullscreen-button");
772
773      controlBar.append(
774        playBtn, timeDisplay, timeRange,
775        playbackRate, muteBtn, volumeRange,
776        captionsBtn, qualityBtn, fullscreenBtn
777      );
778
779      const poster = document.createElement("media-poster-image");
780      poster.setAttribute("slot", "poster");
781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
782
783      controller.append(provider, poster, controlBar);
784
785      captionsBtn.setAttribute("aria-checked", "true");
786      controller.setAttribute("mediasubtitlesshowing", "on");
787
788      container.appendChild(controller);
789      details.appendChild(container);
790
791      provider.addEventListener("waiting", () => container.classList.add("loading"));
792      provider.addEventListener("playing", () => container.classList.remove("loading"));
793      provider.addEventListener("play", () => container.classList.remove("loading"));
794
795      provider._textTracks.addEventListener("change", () => {
796        const enabled = provider._captionsEnabled;
797        captionsBtn.setAttribute("aria-checked", enabled);
798        if (enabled) {
799          controller.setAttribute("mediasubtitlesshowing", "on");
800        } else {
801          controller.removeAttribute("mediasubtitlesshowing");
802        }
803      });
804
805      provider.addEventListener("qualitychange", (e) => {
806        qualityBtn.textContent = e.detail.quality;
807      });
808    }
809
810    function initLazyPlayers() {
811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
812        if (details.dataset.lazyInit) return;
813        details.dataset.lazyInit = "true";
814        details.addEventListener("toggle", () => {
815          if (details.open && !details.querySelector(".player-container")) {
816            createPlayer(details);
817          } else if (!details.open) {
818            const provider = details.querySelector("youtube-media-provider");
819            if (provider && typeof provider.pause === "function") {
820              provider.pause();
821            }
822          }
823        });
824      });
825    }
826
827    if (document.readyState === "loading") {
828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
829    } else {
830      initLazyPlayers();
831    }
832  })();
833</script>
833<script>
834  (() => {
835    if (window.__courseCompletionsInitialized) return;
836    window.__courseCompletionsInitialized = true;
837
838    const STORAGE_KEY = "course-completions";
839
840    function slugify(text) {
841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
842    }
843
844    function getCompletions() {
845      try {
846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
847      } catch {
848        return [];
849      }
850    }
851
852    function saveCompletions(completions) {
853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
854    }
855
856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
857    function canonicalFromStored(storedId) {
858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
859      if (!m) return slugify(storedId);
860      const [, slug, p, mv, title] = m;
861      return `${slug}-${p}-${mv}-${slugify(title)}`;
862    }
863
864    function canonicalPlusOne(storedId) {
865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
866      if (!m) return null;
867      const [, slug, p, mv, title] = m;
868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
869    }
870
871    function isCompleted(canonicalId, completions) {
872      if (completions.includes(canonicalId)) return true;
873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
875    }
876
877    function migrateCompletions() {
878      const completions = getCompletions();
879      let changed = false;
880      const migrated = completions.map((c) => {
881        const canon = canonicalFromStored(c);
882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
883        const plusOne = canonicalPlusOne(c);
884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
885          if (plusOne !== c) changed = true;
886          return plusOne;
887        }
888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
889          changed = true;
890          return canon;
891        }
892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
893        if (canon !== c) {
894          changed = true;
895          return canon;
896        }
897        return c;
898      });
899      // Elimina duplicados tras migración
900      const deduped = [...new Set(migrated)];
901      if (changed || deduped.length !== completions.length) {
902        saveCompletions(deduped);
903        return deduped;
904      }
905      return completions;
906    }
907
908    function updateButtonState(btn, completions) {
909      const id = btn.dataset.completionId;
910      const list = completions ?? getCompletions();
911      btn.classList.toggle("completed", isCompleted(id, list));
912    }
913
914    function checkModuleCompletion(completions) {
915      const list = completions ?? getCompletions();
916      document.querySelectorAll(".course-video").forEach((module) => {
917        const btns = module.querySelectorAll(".complete-btn");
918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
919        const check = module.querySelector(".module-check");
920        if (check) check.classList.toggle("visible", allCompleted);
921      });
922    }
923
924    function initCompletionButtons() {
925      const completions = migrateCompletions();
926
927      document.querySelectorAll(".complete-btn").forEach((btn) => {
928        if (btn.dataset.completionInit) return;
929        btn.dataset.completionInit = "true";
930        updateButtonState(btn, completions);
931
932        btn.addEventListener("click", (e) => {
933          e.preventDefault();
934          e.stopPropagation();
935
936          const id = btn.dataset.completionId;
937          let list = getCompletions();
938          const completed = isCompleted(id, list);
939
940          if (completed) {
941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
942            list = list.filter((c) =>
942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
943          } else {
944            list.push(id);
945          }
946
947          saveCompletions(list);
948          updateButtonState(btn, list);
949          checkModuleCompletion(list);
950        });
951      });
952
953      checkModuleCompletion(completions);
954    }
955
956    if (document.readyState === "loading") {
957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
958    } else {
959      initCompletionButtons();
960    }
961  })();
962</script>
962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="3930" data-end="5010" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Seguridad</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-seguridad" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
962<script>
963  (() => {
964    if (window.__courseLazyPlayersInitialized) return;
965    window.__courseLazyPlayersInitialized = true;
966
967    function createPlayer(details) {
968      const videoId = details.dataset.videoId;
969      const start = details.dataset.start;
970      const end = details.dataset.end;
971
972      const container = document.createElement("div");
973      container.className = "player-container";
974
975      const spinner = document.createElement("div");
976      spinner.className = "spinner";
977      container.appendChild(spinner);
978
979      const controller = document.createElement("media-controller");
980      controller.setAttribute("defaultsubtitles", "");
981
982      const provider = document.createElement("youtube-media-provider");
983      provider.setAttribute("slot", "media");
984      provider.setAttribute("video-id", videoId);
985      provider.setAttribute("start", start);
986      provider.setAttribute("end", end);
987
988      const controlBar = document.createElement("media-control-bar");
989
990      const playBtn = document.createElement("media-play-button");
991      const timeDisplay = document.createElement("media-time-display");
992      timeDisplay.setAttribute("showduration", "");
993      const timeRange = document.createElement("media-time-range");
994      const playbackRate = document.createElement("media-playback-rate-button");
995      const muteBtn = document.createElement("media-mute-button");
996      const volumeRange = document.createElement("media-volume-range");
997
998      const captionsBtn = document.createElement("media-captions-button");
999      captionsBtn.addEventListener("click", (e) => {
1000        e.stopPropagation();
1001        const provider = container.querySelector("youtube-media-provider");
1002        if (provider) provider.toggleCaptions();
1003      });
1004
1005      const qualityBtn = document.createElement("media-chrome-button");
1006      qualityBtn.textContent = "--";
1007      qualityBtn.addEventListener("click", () => {
1008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
1009      });
1010
1011      const fullscreenBtn = document.createElement("media-fullscreen-button");
1012
1013      controlBar.append(
1014        playBtn, timeDisplay, timeRange,
1015        playbackRate, muteBtn, volumeRange,
1016        captionsBtn, qualityBtn, fullscreenBtn
1017      );
1018
1019      const poster = document.createElement("media-poster-image");
1020      poster.setAttribute("slot", "poster");
1021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
1022
1023      controller.append(provider, poster, controlBar);
1024
1025      captionsBtn.setAttribute("aria-checked", "true");
1026      controller.setAttribute("mediasubtitlesshowing", "on");
1027
1028      container.appendChild(controller);
1029      details.appendChild(container);
1030
1031      provider.addEventListener("waiting", () => container.classList.add("loading"));
1032      provider.addEventListener("playing", () => container.classList.remove("loading"));
1033      provider.addEventListener("play", () => container.classList.remove("loading"));
1034
1035      provider._textTracks.addEventListener("change", () => {
1036        const enabled = provider._captionsEnabled;
1037        captionsBtn.setAttribute("aria-checked", enabled);
1038        if (enabled) {
1039          controller.setAttribute("mediasubtitlesshowing", "on");
1040        } else {
1041          controller.removeAttribute("mediasubtitlesshowing");
1042        }
1043      });
1044
1045      provider.addEventListener("qualitychange", (e) => {
1046        qualityBtn.textContent = e.detail.quality;
1047      });
1048    }
1049
1050    function initLazyPlayers() {
1051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
1052        if (details.dataset.lazyInit) return;
1053        details.dataset.lazyInit = "true";
1054        details.addEventListener("toggle", () => {
1055          if (details.open && !details.querySelector(".player-container")) {
1056            createPlayer(details);
1057          } else if (!details.open) {
1058            const provider = details.querySelector("youtube-media-provider");
1059            if (provider && typeof provider.pause === "function") {
1060              provider.pause();
1061            }
1062          }
1063        });
1064      });
1065    }
1066
1067    if (document.readyState === "loading") {
1068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
1069    } else {
1070      initLazyPlayers();
1071    }
1072  })();
1073</script>
1073<script>
1074  (() => {
1075    if (window.__courseCompletionsInitialized) return;
1076    window.__courseCompletionsInitialized = true;
1077
1078    const STORAGE_KEY = "course-completions";
1079
1080    function slugify(text) {
1081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1082    }
1083
1084    function getCompletions() {
1085      try {
1086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
1087      } catch {
1088        return [];
1089      }
1090    }
1091
1092    function saveCompletions(completions) {
1093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
1094    }
1095
1096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
1097    function canonicalFromStored(storedId) {
1098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1099      if (!m) return slugify(storedId);
1100      const [, slug, p, mv, title] = m;
1101      return `${slug}-${p}-${mv}-${slugify(title)}`;
1102    }
1103
1104    function canonicalPlusOne(storedId) {
1105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1106      if (!m) return null;
1107      const [, slug, p, mv, title] = m;
1108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
1109    }
1110
1111    function isCompleted(canonicalId, completions) {
1112      if (completions.includes(canonicalId)) return true;
1113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
1114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
1115    }
1116
1117    function migrateCompletions() {
1118      const completions = getCompletions();
1119      let changed = false;
1120      const migrated = completions.map((c) => {
1121        const canon = canonicalFromStored(c);
1122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
1123        const plusOne = canonicalPlusOne(c);
1124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
1125          if (plusOne !== c) changed = true;
1126          return plusOne;
1127        }
1128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
1129          changed = true;
1130          return canon;
1131        }
1132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
1133        if (canon !== c) {
1134          changed = true;
1135          return canon;
1136        }
1137        return c;
1138      });
1139      // Elimina duplicados tras migración
1140      const deduped = [...new Set(migrated)];
1141      if (changed || deduped.length !== completions.length) {
1142        saveCompletions(deduped);
1143        return deduped;
1144      }
1145      return completions;
1146    }
1147
1148    function updateButtonState(btn, completions) {
1149      const id = btn.dataset.completionId;
1150      const list = completions ?? getCompletions();
1151      btn.classList.toggle("completed", isCompleted(id, list));
1152    }
1153
1154    function checkModuleCompletion(completions) {
1155      const list = completions ?? getCompletions();
1156      document.querySelectorAll(".course-video").forEach((module) => {
1157        const btns = module.querySelectorAll(".complete-btn");
1158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
1159        const check = module.querySelector(".module-check");
1160        if (check) check.classList.toggle("visible", allCompleted);
1161      });
1162    }
1163
1164    function initCompletionButtons() {
1165      const completions = migrateCompletions();
1166
1167      document.querySelectorAll(".complete-btn").forEach((btn) => {
1168        if (btn.dataset.completionInit) return;
1169        btn.dataset.completionInit = "true";
1170        updateButtonState(btn, completions);
1171
1172        btn.addEventListener("click", (e) => {
1173          e.preventDefault();
1174          e.stopPropagation();
1175
1176          const id = btn.dataset.completionId;
1177          let list = getCompletions();
1178          const completed = isCompleted(id, list);
1179
1180          if (completed) {
1181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
1182            list = list.filter((c) =>
1182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
1183          } else {
1184            list.push(id);
1185          }
1186
1187          saveCompletions(list);
1188          updateButtonState(btn, list);
1189          checkModuleCompletion(list);
1190        });
1191      });
1192
1193      checkModuleCompletion(completions);
1194    }
1195
1196    if (document.readyState === "loading") {
1197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
1198    } else {
1199      initCompletionButtons();
1200    }
1201  })();
1202</script>
1202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="5010" data-end="5675" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Personalizar la terminal (fish)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-personalizar-la-terminal-fish" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
1202<script>
1203  (() => {
1204    if (window.__courseLazyPlayersInitialized) return;
1205    window.__courseLazyPlayersInitialized = true;
1206
1207    function createPlayer(details) {
1208      const videoId = details.dataset.videoId;
1209      const start = details.dataset.start;
1210      const end = details.dataset.end;
1211
1212      const container = document.createElement("div");
1213      container.className = "player-container";
1214
1215      const spinner = document.createElement("div");
1216      spinner.className = "spinner";
1217      container.appendChild(spinner);
1218
1219      const controller = document.createElement("media-controller");
1220      controller.setAttribute("defaultsubtitles", "");
1221
1222      const provider = document.createElement("youtube-media-provider");
1223      provider.setAttribute("slot", "media");
1224      provider.setAttribute("video-id", videoId);
1225      provider.setAttribute("start", start);
1226      provider.setAttribute("end", end);
1227
1228      const controlBar = document.createElement("media-control-bar");
1229
1230      const playBtn = document.createElement("media-play-button");
1231      const timeDisplay = document.createElement("media-time-display");
1232      timeDisplay.setAttribute("showduration", "");
1233      const timeRange = document.createElement("media-time-range");
1234      const playbackRate = document.createElement("media-playback-rate-button");
1235      const muteBtn = document.createElement("media-mute-button");
1236      const volumeRange = document.createElement("media-volume-range");
1237
1238      const captionsBtn = document.createElement("media-captions-button");
1239      captionsBtn.addEventListener("click", (e) => {
1240        e.stopPropagation();
1241        const provider = container.querySelector("youtube-media-provider");
1242        if (provider) provider.toggleCaptions();
1243      });
1244
1245      const qualityBtn = document.createElement("media-chrome-button");
1246      qualityBtn.textContent = "--";
1247      qualityBtn.addEventListener("click", () => {
1248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
1249      });
1250
1251      const fullscreenBtn = document.createElement("media-fullscreen-button");
1252
1253      controlBar.append(
1254        playBtn, timeDisplay, timeRange,
1255        playbackRate, muteBtn, volumeRange,
1256        captionsBtn, qualityBtn, fullscreenBtn
1257      );
1258
1259      const poster = document.createElement("media-poster-image");
1260      poster.setAttribute("slot", "poster");
1261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
1262
1263      controller.append(provider, poster, controlBar);
1264
1265      captionsBtn.setAttribute("aria-checked", "true");
1266      controller.setAttribute("mediasubtitlesshowing", "on");
1267
1268      container.appendChild(controller);
1269      details.appendChild(container);
1270
1271      provider.addEventListener("waiting", () => container.classList.add("loading"));
1272      provider.addEventListener("playing", () => container.classList.remove("loading"));
1273      provider.addEventListener("play", () => container.classList.remove("loading"));
1274
1275      provider._textTracks.addEventListener("change", () => {
1276        const enabled = provider._captionsEnabled;
1277        captionsBtn.setAttribute("aria-checked", enabled);
1278        if (enabled) {
1279          controller.setAttribute("mediasubtitlesshowing", "on");
1280        } else {
1281          controller.removeAttribute("mediasubtitlesshowing");
1282        }
1283      });
1284
1285      provider.addEventListener("qualitychange", (e) => {
1286        qualityBtn.textContent = e.detail.quality;
1287      });
1288    }
1289
1290    function initLazyPlayers() {
1291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
1292        if (details.dataset.lazyInit) return;
1293        details.dataset.lazyInit = "true";
1294        details.addEventListener("toggle", () => {
1295          if (details.open && !details.querySelector(".player-container")) {
1296            createPlayer(details);
1297          } else if (!details.open) {
1298            const provider = details.querySelector("youtube-media-provider");
1299            if (provider && typeof provider.pause === "function") {
1300              provider.pause();
1301            }
1302          }
1303        });
1304      });
1305    }
1306
1307    if (document.readyState === "loading") {
1308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
1309    } else {
1310      initLazyPlayers();
1311    }
1312  })();
1313</script>
1313<script>
1314  (() => {
1315    if (window.__courseCompletionsInitialized) return;
1316    window.__courseCompletionsInitialized = true;
1317
1318    const STORAGE_KEY = "course-completions";
1319
1320    function slugify(text) {
1321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1322    }
1323
1324    function getCompletions() {
1325      try {
1326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
1327      } catch {
1328        return [];
1329      }
1330    }
1331
1332    function saveCompletions(completions) {
1333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
1334    }
1335
1336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
1337    function canonicalFromStored(storedId) {
1338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1339      if (!m) return slugify(storedId);
1340      const [, slug, p, mv, title] = m;
1341      return `${slug}-${p}-${mv}-${slugify(title)}`;
1342    }
1343
1344    function canonicalPlusOne(storedId) {
1345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1346      if (!m) return null;
1347      const [, slug, p, mv, title] = m;
1348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
1349    }
1350
1351    function isCompleted(canonicalId, completions) {
1352      if (completions.includes(canonicalId)) return true;
1353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
1354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
1355    }
1356
1357    function migrateCompletions() {
1358      const completions = getCompletions();
1359      let changed = false;
1360      const migrated = completions.map((c) => {
1361        const canon = canonicalFromStored(c);
1362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
1363        const plusOne = canonicalPlusOne(c);
1364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
1365          if (plusOne !== c) changed = true;
1366          return plusOne;
1367        }
1368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
1369          changed = true;
1370          return canon;
1371        }
1372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
1373        if (canon !== c) {
1374          changed = true;
1375          return canon;
1376        }
1377        return c;
1378      });
1379      // Elimina duplicados tras migración
1380      const deduped = [...new Set(migrated)];
1381      if (changed || deduped.length !== completions.length) {
1382        saveCompletions(deduped);
1383        return deduped;
1384      }
1385      return completions;
1386    }
1387
1388    function updateButtonState(btn, completions) {
1389      const id = btn.dataset.completionId;
1390      const list = completions ?? getCompletions();
1391      btn.classList.toggle("completed", isCompleted(id, list));
1392    }
1393
1394    function checkModuleCompletion(completions) {
1395      const list = completions ?? getCompletions();
1396      document.querySelectorAll(".course-video").forEach((module) => {
1397        const btns = module.querySelectorAll(".complete-btn");
1398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
1399        const check = module.querySelector(".module-check");
1400        if (check) check.classList.toggle("visible", allCompleted);
1401      });
1402    }
1403
1404    function initCompletionButtons() {
1405      const completions = migrateCompletions();
1406
1407      document.querySelectorAll(".complete-btn").forEach((btn) => {
1408        if (btn.dataset.completionInit) return;
1409        btn.dataset.completionInit = "true";
1410        updateButtonState(btn, completions);
1411
1412        btn.addEventListener("click", (e) => {
1413          e.preventDefault();
1414          e.stopPropagation();
1415
1416          const id = btn.dataset.completionId;
1417          let list = getCompletions();
1418          const completed = isCompleted(id, list);
1419
1420          if (completed) {
1421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
1422            list = list.filter((c) =>
1422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
1423          } else {
1424            list.push(id);
1425          }
1426
1427          saveCompletions(list);
1428          updateButtonState(btn, list);
1429          checkModuleCompletion(list);
1430        });
1431      });
1432
1433      checkModuleCompletion(completions);
1434    }
1435
1436    if (document.readyState === "loading") {
1437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
1438    } else {
1439      initCompletionButtons();
1440    }
1441  })();
1442</script>
1442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="6330" data-end="7755" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Moverse por carpetas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-moverse-por-carpetas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
1442<script>
1443  (() => {
1444    if (window.__courseLazyPlayersInitialized) return;
1445    window.__courseLazyPlayersInitialized = true;
1446
1447    function createPlayer(details) {
1448      const videoId = details.dataset.videoId;
1449      const start = details.dataset.start;
1450      const end = details.dataset.end;
1451
1452      const container = document.createElement("div");
1453      container.className = "player-container";
1454
1455      const spinner = document.createElement("div");
1456      spinner.className = "spinner";
1457      container.appendChild(spinner);
1458
1459      const controller = document.createElement("media-controller");
1460      controller.setAttribute("defaultsubtitles", "");
1461
1462      const provider = document.createElement("youtube-media-provider");
1463      provider.setAttribute("slot", "media");
1464      provider.setAttribute("video-id", videoId);
1465      provider.setAttribute("start", start);
1466      provider.setAttribute("end", end);
1467
1468      const controlBar = document.createElement("media-control-bar");
1469
1470      const playBtn = document.createElement("media-play-button");
1471      const timeDisplay = document.createElement("media-time-display");
1472      timeDisplay.setAttribute("showduration", "");
1473      const timeRange = document.createElement("media-time-range");
1474      const playbackRate = document.createElement("media-playback-rate-button");
1475      const muteBtn = document.createElement("media-mute-button");
1476      const volumeRange = document.createElement("media-volume-range");
1477
1478      const captionsBtn = document.createElement("media-captions-button");
1479      captionsBtn.addEventListener("click", (e) => {
1480        e.stopPropagation();
1481        const provider = container.querySelector("youtube-media-provider");
1482        if (provider) provider.toggleCaptions();
1483      });
1484
1485      const qualityBtn = document.createElement("media-chrome-button");
1486      qualityBtn.textContent = "--";
1487      qualityBtn.addEventListener("click", () => {
1488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
1489      });
1490
1491      const fullscreenBtn = document.createElement("media-fullscreen-button");
1492
1493      controlBar.append(
1494        playBtn, timeDisplay, timeRange,
1495        playbackRate, muteBtn, volumeRange,
1496        captionsBtn, qualityBtn, fullscreenBtn
1497      );
1498
1499      const poster = document.createElement("media-poster-image");
1500      poster.setAttribute("slot", "poster");
1501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
1502
1503      controller.append(provider, poster, controlBar);
1504
1505      captionsBtn.setAttribute("aria-checked", "true");
1506      controller.setAttribute("mediasubtitlesshowing", "on");
1507
1508      container.appendChild(controller);
1509      details.appendChild(container);
1510
1511      provider.addEventListener("waiting", () => container.classList.add("loading"));
1512      provider.addEventListener("playing", () => container.classList.remove("loading"));
1513      provider.addEventListener("play", () => container.classList.remove("loading"));
1514
1515      provider._textTracks.addEventListener("change", () => {
1516        const enabled = provider._captionsEnabled;
1517        captionsBtn.setAttribute("aria-checked", enabled);
1518        if (enabled) {
1519          controller.setAttribute("mediasubtitlesshowing", "on");
1520        } else {
1521          controller.removeAttribute("mediasubtitlesshowing");
1522        }
1523      });
1524
1525      provider.addEventListener("qualitychange", (e) => {
1526        qualityBtn.textContent = e.detail.quality;
1527      });
1528    }
1529
1530    function initLazyPlayers() {
1531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
1532        if (details.dataset.lazyInit) return;
1533        details.dataset.lazyInit = "true";
1534        details.addEventListener("toggle", () => {
1535          if (details.open && !details.querySelector(".player-container")) {
1536            createPlayer(details);
1537          } else if (!details.open) {
1538            const provider = details.querySelector("youtube-media-provider");
1539            if (provider && typeof provider.pause === "function") {
1540              provider.pause();
1541            }
1542          }
1543        });
1544      });
1545    }
1546
1547    if (document.readyState === "loading") {
1548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
1549    } else {
1550      initLazyPlayers();
1551    }
1552  })();
1553</script>
1553<script>
1554  (() => {
1555    if (window.__courseCompletionsInitialized) return;
1556    window.__courseCompletionsInitialized = true;
1557
1558    const STORAGE_KEY = "course-completions";
1559
1560    function slugify(text) {
1561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1562    }
1563
1564    function getCompletions() {
1565      try {
1566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
1567      } catch {
1568        return [];
1569      }
1570    }
1571
1572    function saveCompletions(completions) {
1573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
1574    }
1575
1576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
1577    function canonicalFromStored(storedId) {
1578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1579      if (!m) return slugify(storedId);
1580      const [, slug, p, mv, title] = m;
1581      return `${slug}-${p}-${mv}-${slugify(title)}`;
1582    }
1583
1584    function canonicalPlusOne(storedId) {
1585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1586      if (!m) return null;
1587      const [, slug, p, mv, title] = m;
1588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
1589    }
1590
1591    function isCompleted(canonicalId, completions) {
1592      if (completions.includes(canonicalId)) return true;
1593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
1594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
1595    }
1596
1597    function migrateCompletions() {
1598      const completions = getCompletions();
1599      let changed = false;
1600      const migrated = completions.map((c) => {
1601        const canon = canonicalFromStored(c);
1602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
1603        const plusOne = canonicalPlusOne(c);
1604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
1605          if (plusOne !== c) changed = true;
1606          return plusOne;
1607        }
1608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
1609          changed = true;
1610          return canon;
1611        }
1612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
1613        if (canon !== c) {
1614          changed = true;
1615          return canon;
1616        }
1617        return c;
1618      });
1619      // Elimina duplicados tras migración
1620      const deduped = [...new Set(migrated)];
1621      if (changed || deduped.length !== completions.length) {
1622        saveCompletions(deduped);
1623        return deduped;
1624      }
1625      return completions;
1626    }
1627
1628    function updateButtonState(btn, completions) {
1629      const id = btn.dataset.completionId;
1630      const list = completions ?? getCompletions();
1631      btn.classList.toggle("completed", isCompleted(id, list));
1632    }
1633
1634    function checkModuleCompletion(completions) {
1635      const list = completions ?? getCompletions();
1636      document.querySelectorAll(".course-video").forEach((module) => {
1637        const btns = module.querySelectorAll(".complete-btn");
1638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
1639        const check = module.querySelector(".module-check");
1640        if (check) check.classList.toggle("visible", allCompleted);
1641      });
1642    }
1643
1644    function initCompletionButtons() {
1645      const completions = migrateCompletions();
1646
1647      document.querySelectorAll(".complete-btn").forEach((btn) => {
1648        if (btn.dataset.completionInit) return;
1649        btn.dataset.completionInit = "true";
1650        updateButtonState(btn, completions);
1651
1652        btn.addEventListener("click", (e) => {
1653          e.preventDefault();
1654          e.stopPropagation();
1655
1656          const id = btn.dataset.completionId;
1657          let list = getCompletions();
1658          const completed = isCompleted(id, list);
1659
1660          if (completed) {
1661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
1662            list = list.filter((c) =>
1662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
1663          } else {
1664            list.push(id);
1665          }
1666
1667          saveCompletions(list);
1668          updateButtonState(btn, list);
1669          checkModuleCompletion(list);
1670        });
1671      });
1672
1673      checkModuleCompletion(completions);
1674    }
1675
1676    if (document.readyState === "loading") {
1677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
1678    } else {
1679      initCompletionButtons();
1680    }
1681  })();
1682</script>
1682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="7755" data-end="9255" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Ver, copiar y mover archivos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>25 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-ver-copiar-y-mover-archivos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
1682<script>
1683  (() => {
1684    if (window.__courseLazyPlayersInitialized) return;
1685    window.__courseLazyPlayersInitialized = true;
1686
1687    function createPlayer(details) {
1688      const videoId = details.dataset.videoId;
1689      const start = details.dataset.start;
1690      const end = details.dataset.end;
1691
1692      const container = document.createElement("div");
1693      container.className = "player-container";
1694
1695      const spinner = document.createElement("div");
1696      spinner.className = "spinner";
1697      container.appendChild(spinner);
1698
1699      const controller = document.createElement("media-controller");
1700      controller.setAttribute("defaultsubtitles", "");
1701
1702      const provider = document.createElement("youtube-media-provider");
1703      provider.setAttribute("slot", "media");
1704      provider.setAttribute("video-id", videoId);
1705      provider.setAttribute("start", start);
1706      provider.setAttribute("end", end);
1707
1708      const controlBar = document.createElement("media-control-bar");
1709
1710      const playBtn = document.createElement("media-play-button");
1711      const timeDisplay = document.createElement("media-time-display");
1712      timeDisplay.setAttribute("showduration", "");
1713      const timeRange = document.createElement("media-time-range");
1714      const playbackRate = document.createElement("media-playback-rate-button");
1715      const muteBtn = document.createElement("media-mute-button");
1716      const volumeRange = document.createElement("media-volume-range");
1717
1718      const captionsBtn = document.createElement("media-captions-button");
1719      captionsBtn.addEventListener("click", (e) => {
1720        e.stopPropagation();
1721        const provider = container.querySelector("youtube-media-provider");
1722        if (provider) provider.toggleCaptions();
1723      });
1724
1725      const qualityBtn = document.createElement("media-chrome-button");
1726      qualityBtn.textContent = "--";
1727      qualityBtn.addEventListener("click", () => {
1728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
1729      });
1730
1731      const fullscreenBtn = document.createElement("media-fullscreen-button");
1732
1733      controlBar.append(
1734        playBtn, timeDisplay, timeRange,
1735        playbackRate, muteBtn, volumeRange,
1736        captionsBtn, qualityBtn, fullscreenBtn
1737      );
1738
1739      const poster = document.createElement("media-poster-image");
1740      poster.setAttribute("slot", "poster");
1741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
1742
1743      controller.append(provider, poster, controlBar);
1744
1745      captionsBtn.setAttribute("aria-checked", "true");
1746      controller.setAttribute("mediasubtitlesshowing", "on");
1747
1748      container.appendChild(controller);
1749      details.appendChild(container);
1750
1751      provider.addEventListener("waiting", () => container.classList.add("loading"));
1752      provider.addEventListener("playing", () => container.classList.remove("loading"));
1753      provider.addEventListener("play", () => container.classList.remove("loading"));
1754
1755      provider._textTracks.addEventListener("change", () => {
1756        const enabled = provider._captionsEnabled;
1757        captionsBtn.setAttribute("aria-checked", enabled);
1758        if (enabled) {
1759          controller.setAttribute("mediasubtitlesshowing", "on");
1760        } else {
1761          controller.removeAttribute("mediasubtitlesshowing");
1762        }
1763      });
1764
1765      provider.addEventListener("qualitychange", (e) => {
1766        qualityBtn.textContent = e.detail.quality;
1767      });
1768    }
1769
1770    function initLazyPlayers() {
1771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
1772        if (details.dataset.lazyInit) return;
1773        details.dataset.lazyInit = "true";
1774        details.addEventListener("toggle", () => {
1775          if (details.open && !details.querySelector(".player-container")) {
1776            createPlayer(details);
1777          } else if (!details.open) {
1778            const provider = details.querySelector("youtube-media-provider");
1779            if (provider && typeof provider.pause === "function") {
1780              provider.pause();
1781            }
1782          }
1783        });
1784      });
1785    }
1786
1787    if (document.readyState === "loading") {
1788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
1789    } else {
1790      initLazyPlayers();
1791    }
1792  })();
1793</script>
1793<script>
1794  (() => {
1795    if (window.__courseCompletionsInitialized) return;
1796    window.__courseCompletionsInitialized = true;
1797
1798    const STORAGE_KEY = "course-completions";
1799
1800    function slugify(text) {
1801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1802    }
1803
1804    function getCompletions() {
1805      try {
1806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
1807      } catch {
1808        return [];
1809      }
1810    }
1811
1812    function saveCompletions(completions) {
1813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
1814    }
1815
1816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
1817    function canonicalFromStored(storedId) {
1818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1819      if (!m) return slugify(storedId);
1820      const [, slug, p, mv, title] = m;
1821      return `${slug}-${p}-${mv}-${slugify(title)}`;
1822    }
1823
1824    function canonicalPlusOne(storedId) {
1825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
1826      if (!m) return null;
1827      const [, slug, p, mv, title] = m;
1828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
1829    }
1830
1831    function isCompleted(canonicalId, completions) {
1832      if (completions.includes(canonicalId)) return true;
1833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
1834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
1835    }
1836
1837    function migrateCompletions() {
1838      const completions = getCompletions();
1839      let changed = false;
1840      const migrated = completions.map((c) => {
1841        const canon = canonicalFromStored(c);
1842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
1843        const plusOne = canonicalPlusOne(c);
1844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
1845          if (plusOne !== c) changed = true;
1846          return plusOne;
1847        }
1848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
1849          changed = true;
1850          return canon;
1851        }
1852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
1853        if (canon !== c) {
1854          changed = true;
1855          return canon;
1856        }
1857        return c;
1858      });
1859      // Elimina duplicados tras migración
1860      const deduped = [...new Set(migrated)];
1861      if (changed || deduped.length !== completions.length) {
1862        saveCompletions(deduped);
1863        return deduped;
1864      }
1865      return completions;
1866    }
1867
1868    function updateButtonState(btn, completions) {
1869      const id = btn.dataset.completionId;
1870      const list = completions ?? getCompletions();
1871      btn.classList.toggle("completed", isCompleted(id, list));
1872    }
1873
1874    function checkModuleCompletion(completions) {
1875      const list = completions ?? getCompletions();
1876      document.querySelectorAll(".course-video").forEach((module) => {
1877        const btns = module.querySelectorAll(".complete-btn");
1878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
1879        const check = module.querySelector(".module-check");
1880        if (check) check.classList.toggle("visible", allCompleted);
1881      });
1882    }
1883
1884    function initCompletionButtons() {
1885      const completions = migrateCompletions();
1886
1887      document.querySelectorAll(".complete-btn").forEach((btn) => {
1888        if (btn.dataset.completionInit) return;
1889        btn.dataset.completionInit = "true";
1890        updateButtonState(btn, completions);
1891
1892        btn.addEventListener("click", (e) => {
1893          e.preventDefault();
1894          e.stopPropagation();
1895
1896          const id = btn.dataset.completionId;
1897          let list = getCompletions();
1898          const completed = isCompleted(id, list);
1899
1900          if (completed) {
1901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
1902            list = list.filter((c) =>
1902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
1903          } else {
1904            list.push(id);
1905          }
1906
1907          saveCompletions(list);
1908          updateButtonState(btn, list);
1909          checkModuleCompletion(list);
1910        });
1911      });
1912
1913      checkModuleCompletion(completions);
1914    }
1915
1916    if (document.readyState === "loading") {
1917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
1918    } else {
1919      initCompletionButtons();
1920    }
1921  })();
1922</script>
1922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="9255" data-end="9742" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Ver contenido de archivos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-ver-contenido-de-archivos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
1922<script>
1923  (() => {
1924    if (window.__courseLazyPlayersInitialized) return;
1925    window.__courseLazyPlayersInitialized = true;
1926
1927    function createPlayer(details) {
1928      const videoId = details.dataset.videoId;
1929      const start = details.dataset.start;
1930      const end = details.dataset.end;
1931
1932      const container = document.createElement("div");
1933      container.className = "player-container";
1934
1935      const spinner = document.createElement("div");
1936      spinner.className = "spinner";
1937      container.appendChild(spinner);
1938
1939      const controller = document.createElement("media-controller");
1940      controller.setAttribute("defaultsubtitles", "");
1941
1942      const provider = document.createElement("youtube-media-provider");
1943      provider.setAttribute("slot", "media");
1944      provider.setAttribute("video-id", videoId);
1945      provider.setAttribute("start", start);
1946      provider.setAttribute("end", end);
1947
1948      const controlBar = document.createElement("media-control-bar");
1949
1950      const playBtn = document.createElement("media-play-button");
1951      const timeDisplay = document.createElement("media-time-display");
1952      timeDisplay.setAttribute("showduration", "");
1953      const timeRange = document.createElement("media-time-range");
1954      const playbackRate = document.createElement("media-playback-rate-button");
1955      const muteBtn = document.createElement("media-mute-button");
1956      const volumeRange = document.createElement("media-volume-range");
1957
1958      const captionsBtn = document.createElement("media-captions-button");
1959      captionsBtn.addEventListener("click", (e) => {
1960        e.stopPropagation();
1961        const provider = container.querySelector("youtube-media-provider");
1962        if (provider) provider.toggleCaptions();
1963      });
1964
1965      const qualityBtn = document.createElement("media-chrome-button");
1966      qualityBtn.textContent = "--";
1967      qualityBtn.addEventListener("click", () => {
1968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
1969      });
1970
1971      const fullscreenBtn = document.createElement("media-fullscreen-button");
1972
1973      controlBar.append(
1974        playBtn, timeDisplay, timeRange,
1975        playbackRate, muteBtn, volumeRange,
1976        captionsBtn, qualityBtn, fullscreenBtn
1977      );
1978
1979      const poster = document.createElement("media-poster-image");
1980      poster.setAttribute("slot", "poster");
1981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
1982
1983      controller.append(provider, poster, controlBar);
1984
1985      captionsBtn.setAttribute("aria-checked", "true");
1986      controller.setAttribute("mediasubtitlesshowing", "on");
1987
1988      container.appendChild(controller);
1989      details.appendChild(container);
1990
1991      provider.addEventListener("waiting", () => container.classList.add("loading"));
1992      provider.addEventListener("playing", () => container.classList.remove("loading"));
1993      provider.addEventListener("play", () => container.classList.remove("loading"));
1994
1995      provider._textTracks.addEventListener("change", () => {
1996        const enabled = provider._captionsEnabled;
1997        captionsBtn.setAttribute("aria-checked", enabled);
1998        if (enabled) {
1999          controller.setAttribute("mediasubtitlesshowing", "on");
2000        } else {
2001          controller.removeAttribute("mediasubtitlesshowing");
2002        }
2003      });
2004
2005      provider.addEventListener("qualitychange", (e) => {
2006        qualityBtn.textContent = e.detail.quality;
2007      });
2008    }
2009
2010    function initLazyPlayers() {
2011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
2012        if (details.dataset.lazyInit) return;
2013        details.dataset.lazyInit = "true";
2014        details.addEventListener("toggle", () => {
2015          if (details.open && !details.querySelector(".player-container")) {
2016            createPlayer(details);
2017          } else if (!details.open) {
2018            const provider = details.querySelector("youtube-media-provider");
2019            if (provider && typeof provider.pause === "function") {
2020              provider.pause();
2021            }
2022          }
2023        });
2024      });
2025    }
2026
2027    if (document.readyState === "loading") {
2028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
2029    } else {
2030      initLazyPlayers();
2031    }
2032  })();
2033</script>
2033<script>
2034  (() => {
2035    if (window.__courseCompletionsInitialized) return;
2036    window.__courseCompletionsInitialized = true;
2037
2038    const STORAGE_KEY = "course-completions";
2039
2040    function slugify(text) {
2041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2042    }
2043
2044    function getCompletions() {
2045      try {
2046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
2047      } catch {
2048        return [];
2049      }
2050    }
2051
2052    function saveCompletions(completions) {
2053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
2054    }
2055
2056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
2057    function canonicalFromStored(storedId) {
2058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2059      if (!m) return slugify(storedId);
2060      const [, slug, p, mv, title] = m;
2061      return `${slug}-${p}-${mv}-${slugify(title)}`;
2062    }
2063
2064    function canonicalPlusOne(storedId) {
2065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2066      if (!m) return null;
2067      const [, slug, p, mv, title] = m;
2068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
2069    }
2070
2071    function isCompleted(canonicalId, completions) {
2072      if (completions.includes(canonicalId)) return true;
2073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
2074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
2075    }
2076
2077    function migrateCompletions() {
2078      const completions = getCompletions();
2079      let changed = false;
2080      const migrated = completions.map((c) => {
2081        const canon = canonicalFromStored(c);
2082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
2083        const plusOne = canonicalPlusOne(c);
2084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
2085          if (plusOne !== c) changed = true;
2086          return plusOne;
2087        }
2088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
2089          changed = true;
2090          return canon;
2091        }
2092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
2093        if (canon !== c) {
2094          changed = true;
2095          return canon;
2096        }
2097        return c;
2098      });
2099      // Elimina duplicados tras migración
2100      const deduped = [...new Set(migrated)];
2101      if (changed || deduped.length !== completions.length) {
2102        saveCompletions(deduped);
2103        return deduped;
2104      }
2105      return completions;
2106    }
2107
2108    function updateButtonState(btn, completions) {
2109      const id = btn.dataset.completionId;
2110      const list = completions ?? getCompletions();
2111      btn.classList.toggle("completed", isCompleted(id, list));
2112    }
2113
2114    function checkModuleCompletion(completions) {
2115      const list = completions ?? getCompletions();
2116      document.querySelectorAll(".course-video").forEach((module) => {
2117        const btns = module.querySelectorAll(".complete-btn");
2118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
2119        const check = module.querySelector(".module-check");
2120        if (check) check.classList.toggle("visible", allCompleted);
2121      });
2122    }
2123
2124    function initCompletionButtons() {
2125      const completions = migrateCompletions();
2126
2127      document.querySelectorAll(".complete-btn").forEach((btn) => {
2128        if (btn.dataset.completionInit) return;
2129        btn.dataset.completionInit = "true";
2130        updateButtonState(btn, completions);
2131
2132        btn.addEventListener("click", (e) => {
2133          e.preventDefault();
2134          e.stopPropagation();
2135
2136          const id = btn.dataset.completionId;
2137          let list = getCompletions();
2138          const completed = isCompleted(id, list);
2139
2140          if (completed) {
2141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
2142            list = list.filter((c) =>
2142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
2143          } else {
2144            list.push(id);
2145          }
2146
2147          saveCompletions(list);
2148          updateButtonState(btn, list);
2149          checkModuleCompletion(list);
2150        });
2151      });
2152
2153      checkModuleCompletion(completions);
2154    }
2155
2156    if (document.readyState === "loading") {
2157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
2158    } else {
2159      initCompletionButtons();
2160    }
2161  })();
2162</script>
2162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="10230" data-end="11735" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Editores de terminal</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>25 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-editores-de-terminal" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
2162<script>
2163  (() => {
2164    if (window.__courseLazyPlayersInitialized) return;
2165    window.__courseLazyPlayersInitialized = true;
2166
2167    function createPlayer(details) {
2168      const videoId = details.dataset.videoId;
2169      const start = details.dataset.start;
2170      const end = details.dataset.end;
2171
2172      const container = document.createElement("div");
2173      container.className = "player-container";
2174
2175      const spinner = document.createElement("div");
2176      spinner.className = "spinner";
2177      container.appendChild(spinner);
2178
2179      const controller = document.createElement("media-controller");
2180      controller.setAttribute("defaultsubtitles", "");
2181
2182      const provider = document.createElement("youtube-media-provider");
2183      provider.setAttribute("slot", "media");
2184      provider.setAttribute("video-id", videoId);
2185      provider.setAttribute("start", start);
2186      provider.setAttribute("end", end);
2187
2188      const controlBar = document.createElement("media-control-bar");
2189
2190      const playBtn = document.createElement("media-play-button");
2191      const timeDisplay = document.createElement("media-time-display");
2192      timeDisplay.setAttribute("showduration", "");
2193      const timeRange = document.createElement("media-time-range");
2194      const playbackRate = document.createElement("media-playback-rate-button");
2195      const muteBtn = document.createElement("media-mute-button");
2196      const volumeRange = document.createElement("media-volume-range");
2197
2198      const captionsBtn = document.createElement("media-captions-button");
2199      captionsBtn.addEventListener("click", (e) => {
2200        e.stopPropagation();
2201        const provider = container.querySelector("youtube-media-provider");
2202        if (provider) provider.toggleCaptions();
2203      });
2204
2205      const qualityBtn = document.createElement("media-chrome-button");
2206      qualityBtn.textContent = "--";
2207      qualityBtn.addEventListener("click", () => {
2208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
2209      });
2210
2211      const fullscreenBtn = document.createElement("media-fullscreen-button");
2212
2213      controlBar.append(
2214        playBtn, timeDisplay, timeRange,
2215        playbackRate, muteBtn, volumeRange,
2216        captionsBtn, qualityBtn, fullscreenBtn
2217      );
2218
2219      const poster = document.createElement("media-poster-image");
2220      poster.setAttribute("slot", "poster");
2221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
2222
2223      controller.append(provider, poster, controlBar);
2224
2225      captionsBtn.setAttribute("aria-checked", "true");
2226      controller.setAttribute("mediasubtitlesshowing", "on");
2227
2228      container.appendChild(controller);
2229      details.appendChild(container);
2230
2231      provider.addEventListener("waiting", () => container.classList.add("loading"));
2232      provider.addEventListener("playing", () => container.classList.remove("loading"));
2233      provider.addEventListener("play", () => container.classList.remove("loading"));
2234
2235      provider._textTracks.addEventListener("change", () => {
2236        const enabled = provider._captionsEnabled;
2237        captionsBtn.setAttribute("aria-checked", enabled);
2238        if (enabled) {
2239          controller.setAttribute("mediasubtitlesshowing", "on");
2240        } else {
2241          controller.removeAttribute("mediasubtitlesshowing");
2242        }
2243      });
2244
2245      provider.addEventListener("qualitychange", (e) => {
2246        qualityBtn.textContent = e.detail.quality;
2247      });
2248    }
2249
2250    function initLazyPlayers() {
2251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
2252        if (details.dataset.lazyInit) return;
2253        details.dataset.lazyInit = "true";
2254        details.addEventListener("toggle", () => {
2255          if (details.open && !details.querySelector(".player-container")) {
2256            createPlayer(details);
2257          } else if (!details.open) {
2258            const provider = details.querySelector("youtube-media-provider");
2259            if (provider && typeof provider.pause === "function") {
2260              provider.pause();
2261            }
2262          }
2263        });
2264      });
2265    }
2266
2267    if (document.readyState === "loading") {
2268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
2269    } else {
2270      initLazyPlayers();
2271    }
2272  })();
2273</script>
2273<script>
2274  (() => {
2275    if (window.__courseCompletionsInitialized) return;
2276    window.__courseCompletionsInitialized = true;
2277
2278    const STORAGE_KEY = "course-completions";
2279
2280    function slugify(text) {
2281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2282    }
2283
2284    function getCompletions() {
2285      try {
2286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
2287      } catch {
2288        return [];
2289      }
2290    }
2291
2292    function saveCompletions(completions) {
2293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
2294    }
2295
2296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
2297    function canonicalFromStored(storedId) {
2298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2299      if (!m) return slugify(storedId);
2300      const [, slug, p, mv, title] = m;
2301      return `${slug}-${p}-${mv}-${slugify(title)}`;
2302    }
2303
2304    function canonicalPlusOne(storedId) {
2305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2306      if (!m) return null;
2307      const [, slug, p, mv, title] = m;
2308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
2309    }
2310
2311    function isCompleted(canonicalId, completions) {
2312      if (completions.includes(canonicalId)) return true;
2313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
2314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
2315    }
2316
2317    function migrateCompletions() {
2318      const completions = getCompletions();
2319      let changed = false;
2320      const migrated = completions.map((c) => {
2321        const canon = canonicalFromStored(c);
2322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
2323        const plusOne = canonicalPlusOne(c);
2324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
2325          if (plusOne !== c) changed = true;
2326          return plusOne;
2327        }
2328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
2329          changed = true;
2330          return canon;
2331        }
2332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
2333        if (canon !== c) {
2334          changed = true;
2335          return canon;
2336        }
2337        return c;
2338      });
2339      // Elimina duplicados tras migración
2340      const deduped = [...new Set(migrated)];
2341      if (changed || deduped.length !== completions.length) {
2342        saveCompletions(deduped);
2343        return deduped;
2344      }
2345      return completions;
2346    }
2347
2348    function updateButtonState(btn, completions) {
2349      const id = btn.dataset.completionId;
2350      const list = completions ?? getCompletions();
2351      btn.classList.toggle("completed", isCompleted(id, list));
2352    }
2353
2354    function checkModuleCompletion(completions) {
2355      const list = completions ?? getCompletions();
2356      document.querySelectorAll(".course-video").forEach((module) => {
2357        const btns = module.querySelectorAll(".complete-btn");
2358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
2359        const check = module.querySelector(".module-check");
2360        if (check) check.classList.toggle("visible", allCompleted);
2361      });
2362    }
2363
2364    function initCompletionButtons() {
2365      const completions = migrateCompletions();
2366
2367      document.querySelectorAll(".complete-btn").forEach((btn) => {
2368        if (btn.dataset.completionInit) return;
2369        btn.dataset.completionInit = "true";
2370        updateButtonState(btn, completions);
2371
2372        btn.addEventListener("click", (e) => {
2373          e.preventDefault();
2374          e.stopPropagation();
2375
2376          const id = btn.dataset.completionId;
2377          let list = getCompletions();
2378          const completed = isCompleted(id, list);
2379
2380          if (completed) {
2381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
2382            list = list.filter((c) =>
2382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
2383          } else {
2384            list.push(id);
2385          }
2386
2387          saveCompletions(list);
2388          updateButtonState(btn, list);
2389          checkModuleCompletion(list);
2390        });
2391      });
2392
2393      checkModuleCompletion(completions);
2394    }
2395
2396    if (document.readyState === "loading") {
2397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
2398    } else {
2399      initCompletionButtons();
2400    }
2401  })();
2402</script>
2402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="11735" data-end="13470" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Visual Studio Code</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>29 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-visual-studio-code" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
2402<script>
2403  (() => {
2404    if (window.__courseLazyPlayersInitialized) return;
2405    window.__courseLazyPlayersInitialized = true;
2406
2407    function createPlayer(details) {
2408      const videoId = details.dataset.videoId;
2409      const start = details.dataset.start;
2410      const end = details.dataset.end;
2411
2412      const container = document.createElement("div");
2413      container.className = "player-container";
2414
2415      const spinner = document.createElement("div");
2416      spinner.className = "spinner";
2417      container.appendChild(spinner);
2418
2419      const controller = document.createElement("media-controller");
2420      controller.setAttribute("defaultsubtitles", "");
2421
2422      const provider = document.createElement("youtube-media-provider");
2423      provider.setAttribute("slot", "media");
2424      provider.setAttribute("video-id", videoId);
2425      provider.setAttribute("start", start);
2426      provider.setAttribute("end", end);
2427
2428      const controlBar = document.createElement("media-control-bar");
2429
2430      const playBtn = document.createElement("media-play-button");
2431      const timeDisplay = document.createElement("media-time-display");
2432      timeDisplay.setAttribute("showduration", "");
2433      const timeRange = document.createElement("media-time-range");
2434      const playbackRate = document.createElement("media-playback-rate-button");
2435      const muteBtn = document.createElement("media-mute-button");
2436      const volumeRange = document.createElement("media-volume-range");
2437
2438      const captionsBtn = document.createElement("media-captions-button");
2439      captionsBtn.addEventListener("click", (e) => {
2440        e.stopPropagation();
2441        const provider = container.querySelector("youtube-media-provider");
2442        if (provider) provider.toggleCaptions();
2443      });
2444
2445      const qualityBtn = document.createElement("media-chrome-button");
2446      qualityBtn.textContent = "--";
2447      qualityBtn.addEventListener("click", () => {
2448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
2449      });
2450
2451      const fullscreenBtn = document.createElement("media-fullscreen-button");
2452
2453      controlBar.append(
2454        playBtn, timeDisplay, timeRange,
2455        playbackRate, muteBtn, volumeRange,
2456        captionsBtn, qualityBtn, fullscreenBtn
2457      );
2458
2459      const poster = document.createElement("media-poster-image");
2460      poster.setAttribute("slot", "poster");
2461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
2462
2463      controller.append(provider, poster, controlBar);
2464
2465      captionsBtn.setAttribute("aria-checked", "true");
2466      controller.setAttribute("mediasubtitlesshowing", "on");
2467
2468      container.appendChild(controller);
2469      details.appendChild(container);
2470
2471      provider.addEventListener("waiting", () => container.classList.add("loading"));
2472      provider.addEventListener("playing", () => container.classList.remove("loading"));
2473      provider.addEventListener("play", () => container.classList.remove("loading"));
2474
2475      provider._textTracks.addEventListener("change", () => {
2476        const enabled = provider._captionsEnabled;
2477        captionsBtn.setAttribute("aria-checked", enabled);
2478        if (enabled) {
2479          controller.setAttribute("mediasubtitlesshowing", "on");
2480        } else {
2481          controller.removeAttribute("mediasubtitlesshowing");
2482        }
2483      });
2484
2485      provider.addEventListener("qualitychange", (e) => {
2486        qualityBtn.textContent = e.detail.quality;
2487      });
2488    }
2489
2490    function initLazyPlayers() {
2491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
2492        if (details.dataset.lazyInit) return;
2493        details.dataset.lazyInit = "true";
2494        details.addEventListener("toggle", () => {
2495          if (details.open && !details.querySelector(".player-container")) {
2496            createPlayer(details);
2497          } else if (!details.open) {
2498            const provider = details.querySelector("youtube-media-provider");
2499            if (provider && typeof provider.pause === "function") {
2500              provider.pause();
2501            }
2502          }
2503        });
2504      });
2505    }
2506
2507    if (document.readyState === "loading") {
2508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
2509    } else {
2510      initLazyPlayers();
2511    }
2512  })();
2513</script>
2513<script>
2514  (() => {
2515    if (window.__courseCompletionsInitialized) return;
2516    window.__courseCompletionsInitialized = true;
2517
2518    const STORAGE_KEY = "course-completions";
2519
2520    function slugify(text) {
2521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2522    }
2523
2524    function getCompletions() {
2525      try {
2526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
2527      } catch {
2528        return [];
2529      }
2530    }
2531
2532    function saveCompletions(completions) {
2533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
2534    }
2535
2536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
2537    function canonicalFromStored(storedId) {
2538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2539      if (!m) return slugify(storedId);
2540      const [, slug, p, mv, title] = m;
2541      return `${slug}-${p}-${mv}-${slugify(title)}`;
2542    }
2543
2544    function canonicalPlusOne(storedId) {
2545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2546      if (!m) return null;
2547      const [, slug, p, mv, title] = m;
2548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
2549    }
2550
2551    function isCompleted(canonicalId, completions) {
2552      if (completions.includes(canonicalId)) return true;
2553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
2554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
2555    }
2556
2557    function migrateCompletions() {
2558      const completions = getCompletions();
2559      let changed = false;
2560      const migrated = completions.map((c) => {
2561        const canon = canonicalFromStored(c);
2562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
2563        const plusOne = canonicalPlusOne(c);
2564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
2565          if (plusOne !== c) changed = true;
2566          return plusOne;
2567        }
2568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
2569          changed = true;
2570          return canon;
2571        }
2572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
2573        if (canon !== c) {
2574          changed = true;
2575          return canon;
2576        }
2577        return c;
2578      });
2579      // Elimina duplicados tras migración
2580      const deduped = [...new Set(migrated)];
2581      if (changed || deduped.length !== completions.length) {
2582        saveCompletions(deduped);
2583        return deduped;
2584      }
2585      return completions;
2586    }
2587
2588    function updateButtonState(btn, completions) {
2589      const id = btn.dataset.completionId;
2590      const list = completions ?? getCompletions();
2591      btn.classList.toggle("completed", isCompleted(id, list));
2592    }
2593
2594    function checkModuleCompletion(completions) {
2595      const list = completions ?? getCompletions();
2596      document.querySelectorAll(".course-video").forEach((module) => {
2597        const btns = module.querySelectorAll(".complete-btn");
2598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
2599        const check = module.querySelector(".module-check");
2600        if (check) check.classList.toggle("visible", allCompleted);
2601      });
2602    }
2603
2604    function initCompletionButtons() {
2605      const completions = migrateCompletions();
2606
2607      document.querySelectorAll(".complete-btn").forEach((btn) => {
2608        if (btn.dataset.completionInit) return;
2609        btn.dataset.completionInit = "true";
2610        updateButtonState(btn, completions);
2611
2612        btn.addEventListener("click", (e) => {
2613          e.preventDefault();
2614          e.stopPropagation();
2615
2616          const id = btn.dataset.completionId;
2617          let list = getCompletions();
2618          const completed = isCompleted(id, list);
2619
2620          if (completed) {
2621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
2622            list = list.filter((c) =>
2622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
2623          } else {
2624            list.push(id);
2625          }
2626
2627          saveCompletions(list);
2628          updateButtonState(btn, list);
2629          checkModuleCompletion(list);
2630        });
2631      });
2632
2633      checkModuleCompletion(completions);
2634    }
2635
2636    if (document.readyState === "loading") {
2637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
2638    } else {
2639      initCompletionButtons();
2640    }
2641  })();
2642</script>
2642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="13470" data-end="14550" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Scripts y alias</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-scripts-y-alias" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
2642<script>
2643  (() => {
2644    if (window.__courseLazyPlayersInitialized) return;
2645    window.__courseLazyPlayersInitialized = true;
2646
2647    function createPlayer(details) {
2648      const videoId = details.dataset.videoId;
2649      const start = details.dataset.start;
2650      const end = details.dataset.end;
2651
2652      const container = document.createElement("div");
2653      container.className = "player-container";
2654
2655      const spinner = document.createElement("div");
2656      spinner.className = "spinner";
2657      container.appendChild(spinner);
2658
2659      const controller = document.createElement("media-controller");
2660      controller.setAttribute("defaultsubtitles", "");
2661
2662      const provider = document.createElement("youtube-media-provider");
2663      provider.setAttribute("slot", "media");
2664      provider.setAttribute("video-id", videoId);
2665      provider.setAttribute("start", start);
2666      provider.setAttribute("end", end);
2667
2668      const controlBar = document.createElement("media-control-bar");
2669
2670      const playBtn = document.createElement("media-play-button");
2671      const timeDisplay = document.createElement("media-time-display");
2672      timeDisplay.setAttribute("showduration", "");
2673      const timeRange = document.createElement("media-time-range");
2674      const playbackRate = document.createElement("media-playback-rate-button");
2675      const muteBtn = document.createElement("media-mute-button");
2676      const volumeRange = document.createElement("media-volume-range");
2677
2678      const captionsBtn = document.createElement("media-captions-button");
2679      captionsBtn.addEventListener("click", (e) => {
2680        e.stopPropagation();
2681        const provider = container.querySelector("youtube-media-provider");
2682        if (provider) provider.toggleCaptions();
2683      });
2684
2685      const qualityBtn = document.createElement("media-chrome-button");
2686      qualityBtn.textContent = "--";
2687      qualityBtn.addEventListener("click", () => {
2688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
2689      });
2690
2691      const fullscreenBtn = document.createElement("media-fullscreen-button");
2692
2693      controlBar.append(
2694        playBtn, timeDisplay, timeRange,
2695        playbackRate, muteBtn, volumeRange,
2696        captionsBtn, qualityBtn, fullscreenBtn
2697      );
2698
2699      const poster = document.createElement("media-poster-image");
2700      poster.setAttribute("slot", "poster");
2701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
2702
2703      controller.append(provider, poster, controlBar);
2704
2705      captionsBtn.setAttribute("aria-checked", "true");
2706      controller.setAttribute("mediasubtitlesshowing", "on");
2707
2708      container.appendChild(controller);
2709      details.appendChild(container);
2710
2711      provider.addEventListener("waiting", () => container.classList.add("loading"));
2712      provider.addEventListener("playing", () => container.classList.remove("loading"));
2713      provider.addEventListener("play", () => container.classList.remove("loading"));
2714
2715      provider._textTracks.addEventListener("change", () => {
2716        const enabled = provider._captionsEnabled;
2717        captionsBtn.setAttribute("aria-checked", enabled);
2718        if (enabled) {
2719          controller.setAttribute("mediasubtitlesshowing", "on");
2720        } else {
2721          controller.removeAttribute("mediasubtitlesshowing");
2722        }
2723      });
2724
2725      provider.addEventListener("qualitychange", (e) => {
2726        qualityBtn.textContent = e.detail.quality;
2727      });
2728    }
2729
2730    function initLazyPlayers() {
2731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
2732        if (details.dataset.lazyInit) return;
2733        details.dataset.lazyInit = "true";
2734        details.addEventListener("toggle", () => {
2735          if (details.open && !details.querySelector(".player-container")) {
2736            createPlayer(details);
2737          } else if (!details.open) {
2738            const provider = details.querySelector("youtube-media-provider");
2739            if (provider && typeof provider.pause === "function") {
2740              provider.pause();
2741            }
2742          }
2743        });
2744      });
2745    }
2746
2747    if (document.readyState === "loading") {
2748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
2749    } else {
2750      initLazyPlayers();
2751    }
2752  })();
2753</script>
2753<script>
2754  (() => {
2755    if (window.__courseCompletionsInitialized) return;
2756    window.__courseCompletionsInitialized = true;
2757
2758    const STORAGE_KEY = "course-completions";
2759
2760    function slugify(text) {
2761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2762    }
2763
2764    function getCompletions() {
2765      try {
2766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
2767      } catch {
2768        return [];
2769      }
2770    }
2771
2772    function saveCompletions(completions) {
2773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
2774    }
2775
2776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
2777    function canonicalFromStored(storedId) {
2778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2779      if (!m) return slugify(storedId);
2780      const [, slug, p, mv, title] = m;
2781      return `${slug}-${p}-${mv}-${slugify(title)}`;
2782    }
2783
2784    function canonicalPlusOne(storedId) {
2785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
2786      if (!m) return null;
2787      const [, slug, p, mv, title] = m;
2788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
2789    }
2790
2791    function isCompleted(canonicalId, completions) {
2792      if (completions.includes(canonicalId)) return true;
2793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
2794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
2795    }
2796
2797    function migrateCompletions() {
2798      const completions = getCompletions();
2799      let changed = false;
2800      const migrated = completions.map((c) => {
2801        const canon = canonicalFromStored(c);
2802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
2803        const plusOne = canonicalPlusOne(c);
2804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
2805          if (plusOne !== c) changed = true;
2806          return plusOne;
2807        }
2808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
2809          changed = true;
2810          return canon;
2811        }
2812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
2813        if (canon !== c) {
2814          changed = true;
2815          return canon;
2816        }
2817        return c;
2818      });
2819      // Elimina duplicados tras migración
2820      const deduped = [...new Set(migrated)];
2821      if (changed || deduped.length !== completions.length) {
2822        saveCompletions(deduped);
2823        return deduped;
2824      }
2825      return completions;
2826    }
2827
2828    function updateButtonState(btn, completions) {
2829      const id = btn.dataset.completionId;
2830      const list = completions ?? getCompletions();
2831      btn.classList.toggle("completed", isCompleted(id, list));
2832    }
2833
2834    function checkModuleCompletion(completions) {
2835      const list = completions ?? getCompletions();
2836      document.querySelectorAll(".course-video").forEach((module) => {
2837        const btns = module.querySelectorAll(".complete-btn");
2838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
2839        const check = module.querySelector(".module-check");
2840        if (check) check.classList.toggle("visible", allCompleted);
2841      });
2842    }
2843
2844    function initCompletionButtons() {
2845      const completions = migrateCompletions();
2846
2847      document.querySelectorAll(".complete-btn").forEach((btn) => {
2848        if (btn.dataset.completionInit) return;
2849        btn.dataset.completionInit = "true";
2850        updateButtonState(btn, completions);
2851
2852        btn.addEventListener("click", (e) => {
2853          e.preventDefault();
2854          e.stopPropagation();
2855
2856          const id = btn.dataset.completionId;
2857          let list = getCompletions();
2858          const completed = isCompleted(id, list);
2859
2860          if (completed) {
2861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
2862            list = list.filter((c) =>
2862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
2863          } else {
2864            list.push(id);
2865          }
2866
2867          saveCompletions(list);
2868          updateButtonState(btn, list);
2869          checkModuleCompletion(list);
2870        });
2871      });
2872
2873      checkModuleCompletion(completions);
2874    }
2875
2876    if (document.readyState === "loading") {
2877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
2878    } else {
2879      initCompletionButtons();
2880    }
2881  })();
2882</script>
2882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="x28OvdVBegU" data-start="14550" data-end="16625" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Preguntas finales</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>35 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-1-preguntas-finales" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
2882<script>
2883  (() => {
2884    if (window.__courseLazyPlayersInitialized) return;
2885    window.__courseLazyPlayersInitialized = true;
2886
2887    function createPlayer(details) {
2888      const videoId = details.dataset.videoId;
2889      const start = details.dataset.start;
2890      const end = details.dataset.end;
2891
2892      const container = document.createElement("div");
2893      container.className = "player-container";
2894
2895      const spinner = document.createElement("div");
2896      spinner.className = "spinner";
2897      container.appendChild(spinner);
2898
2899      const controller = document.createElement("media-controller");
2900      controller.setAttribute("defaultsubtitles", "");
2901
2902      const provider = document.createElement("youtube-media-provider");
2903      provider.setAttribute("slot", "media");
2904      provider.setAttribute("video-id", videoId);
2905      provider.setAttribute("start", start);
2906      provider.setAttribute("end", end);
2907
2908      const controlBar = document.createElement("media-control-bar");
2909
2910      const playBtn = document.createElement("media-play-button");
2911      const timeDisplay = document.createElement("media-time-display");
2912      timeDisplay.setAttribute("showduration", "");
2913      const timeRange = document.createElement("media-time-range");
2914      const playbackRate = document.createElement("media-playback-rate-button");
2915      const muteBtn = document.createElement("media-mute-button");
2916      const volumeRange = document.createElement("media-volume-range");
2917
2918      const captionsBtn = document.createElement("media-captions-button");
2919      captionsBtn.addEventListener("click", (e) => {
2920        e.stopPropagation();
2921        const provider = container.querySelector("youtube-media-provider");
2922        if (provider) provider.toggleCaptions();
2923      });
2924
2925      const qualityBtn = document.createElement("media-chrome-button");
2926      qualityBtn.textContent = "--";
2927      qualityBtn.addEventListener("click", () => {
2928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
2929      });
2930
2931      const fullscreenBtn = document.createElement("media-fullscreen-button");
2932
2933      controlBar.append(
2934        playBtn, timeDisplay, timeRange,
2935        playbackRate, muteBtn, volumeRange,
2936        captionsBtn, qualityBtn, fullscreenBtn
2937      );
2938
2939      const poster = document.createElement("media-poster-image");
2940      poster.setAttribute("slot", "poster");
2941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
2942
2943      controller.append(provider, poster, controlBar);
2944
2945      captionsBtn.setAttribute("aria-checked", "true");
2946      controller.setAttribute("mediasubtitlesshowing", "on");
2947
2948      container.appendChild(controller);
2949      details.appendChild(container);
2950
2951      provider.addEventListener("waiting", () => container.classList.add("loading"));
2952      provider.addEventListener("playing", () => container.classList.remove("loading"));
2953      provider.addEventListener("play", () => container.classList.remove("loading"));
2954
2955      provider._textTracks.addEventListener("change", () => {
2956        const enabled = provider._captionsEnabled;
2957        captionsBtn.setAttribute("aria-checked", enabled);
2958        if (enabled) {
2959          controller.setAttribute("mediasubtitlesshowing", "on");
2960        } else {
2961          controller.removeAttribute("mediasubtitlesshowing");
2962        }
2963      });
2964
2965      provider.addEventListener("qualitychange", (e) => {
2966        qualityBtn.textContent = e.detail.quality;
2967      });
2968    }
2969
2970    function initLazyPlayers() {
2971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
2972        if (details.dataset.lazyInit) return;
2973        details.dataset.lazyInit = "true";
2974        details.addEventListener("toggle", () => {
2975          if (details.open && !details.querySelector(".player-container")) {
2976            createPlayer(details);
2977          } else if (!details.open) {
2978            const provider = details.querySelector("youtube-media-provider");
2979            if (provider && typeof provider.pause === "function") {
2980              provider.pause();
2981            }
2982          }
2983        });
2984      });
2985    }
2986
2987    if (document.readyState === "loading") {
2988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
2989    } else {
2990      initLazyPlayers();
2991    }
2992  })();
2993</script>
2993<script>
2994  (() => {
2995    if (window.__courseCompletionsInitialized) return;
2996    window.__courseCompletionsInitialized = true;
2997
2998    const STORAGE_KEY = "course-completions";
2999
3000    function slugify(text) {
3001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3002    }
3003
3004    function getCompletions() {
3005      try {
3006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
3007      } catch {
3008        return [];
3009      }
3010    }
3011
3012    function saveCompletions(completions) {
3013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
3014    }
3015
3016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
3017    function canonicalFromStored(storedId) {
3018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3019      if (!m) return slugify(storedId);
3020      const [, slug, p, mv, title] = m;
3021      return `${slug}-${p}-${mv}-${slugify(title)}`;
3022    }
3023
3024    function canonicalPlusOne(storedId) {
3025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3026      if (!m) return null;
3027      const [, slug, p, mv, title] = m;
3028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
3029    }
3030
3031    function isCompleted(canonicalId, completions) {
3032      if (completions.includes(canonicalId)) return true;
3033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
3034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
3035    }
3036
3037    function migrateCompletions() {
3038      const completions = getCompletions();
3039      let changed = false;
3040      const migrated = completions.map((c) => {
3041        const canon = canonicalFromStored(c);
3042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
3043        const plusOne = canonicalPlusOne(c);
3044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
3045          if (plusOne !== c) changed = true;
3046          return plusOne;
3047        }
3048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
3049          changed = true;
3050          return canon;
3051        }
3052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
3053        if (canon !== c) {
3054          changed = true;
3055          return canon;
3056        }
3057        return c;
3058      });
3059      // Elimina duplicados tras migración
3060      const deduped = [...new Set(migrated)];
3061      if (changed || deduped.length !== completions.length) {
3062        saveCompletions(deduped);
3063        return deduped;
3064      }
3065      return completions;
3066    }
3067
3068    function updateButtonState(btn, completions) {
3069      const id = btn.dataset.completionId;
3070      const list = completions ?? getCompletions();
3071      btn.classList.toggle("completed", isCompleted(id, list));
3072    }
3073
3074    function checkModuleCompletion(completions) {
3075      const list = completions ?? getCompletions();
3076      document.querySelectorAll(".course-video").forEach((module) => {
3077        const btns = module.querySelectorAll(".complete-btn");
3078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
3079        const check = module.querySelector(".module-check");
3080        if (check) check.classList.toggle("visible", allCompleted);
3081      });
3082    }
3083
3084    function initCompletionButtons() {
3085      const completions = migrateCompletions();
3086
3087      document.querySelectorAll(".complete-btn").forEach((btn) => {
3088        if (btn.dataset.completionInit) return;
3089        btn.dataset.completionInit = "true";
3090        updateButtonState(btn, completions);
3091
3092        btn.addEventListener("click", (e) => {
3093          e.preventDefault();
3094          e.stopPropagation();
3095
3096          const id = btn.dataset.completionId;
3097          let list = getCompletions();
3098          const completed = isCompleted(id, list);
3099
3100          if (completed) {
3101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
3102            list = list.filter((c) =>
3102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
3103          } else {
3104            list.push(id);
3105          }
3106
3107          saveCompletions(list);
3108          updateButtonState(btn, list);
3109          checkModuleCompletion(list);
3110        });
3111      });
3112
3113      checkModuleCompletion(completions);
3114    }
3115
3116    if (document.readyState === "loading") {
3117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
3118    } else {
3119      initCompletionButtons();
3120    }
3121  })();
3122</script>
3122</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 2 📕 Fundamentos de programación Javascript<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="660" data-end="1174" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Presentación inicial</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-presentacion-inicial" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
3122<script>
3123  (() => {
3124    if (window.__courseLazyPlayersInitialized) return;
3125    window.__courseLazyPlayersInitialized = true;
3126
3127    function createPlayer(details) {
3128      const videoId = details.dataset.videoId;
3129      const start = details.dataset.start;
3130      const end = details.dataset.end;
3131
3132      const container = document.createElement("div");
3133      container.className = "player-container";
3134
3135      const spinner = document.createElement("div");
3136      spinner.className = "spinner";
3137      container.appendChild(spinner);
3138
3139      const controller = document.createElement("media-controller");
3140      controller.setAttribute("defaultsubtitles", "");
3141
3142      const provider = document.createElement("youtube-media-provider");
3143      provider.setAttribute("slot", "media");
3144      provider.setAttribute("video-id", videoId);
3145      provider.setAttribute("start", start);
3146      provider.setAttribute("end", end);
3147
3148      const controlBar = document.createElement("media-control-bar");
3149
3150      const playBtn = document.createElement("media-play-button");
3151      const timeDisplay = document.createElement("media-time-display");
3152      timeDisplay.setAttribute("showduration", "");
3153      const timeRange = document.createElement("media-time-range");
3154      const playbackRate = document.createElement("media-playback-rate-button");
3155      const muteBtn = document.createElement("media-mute-button");
3156      const volumeRange = document.createElement("media-volume-range");
3157
3158      const captionsBtn = document.createElement("media-captions-button");
3159      captionsBtn.addEventListener("click", (e) => {
3160        e.stopPropagation();
3161        const provider = container.querySelector("youtube-media-provider");
3162        if (provider) provider.toggleCaptions();
3163      });
3164
3165      const qualityBtn = document.createElement("media-chrome-button");
3166      qualityBtn.textContent = "--";
3167      qualityBtn.addEventListener("click", () => {
3168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
3169      });
3170
3171      const fullscreenBtn = document.createElement("media-fullscreen-button");
3172
3173      controlBar.append(
3174        playBtn, timeDisplay, timeRange,
3175        playbackRate, muteBtn, volumeRange,
3176        captionsBtn, qualityBtn, fullscreenBtn
3177      );
3178
3179      const poster = document.createElement("media-poster-image");
3180      poster.setAttribute("slot", "poster");
3181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
3182
3183      controller.append(provider, poster, controlBar);
3184
3185      captionsBtn.setAttribute("aria-checked", "true");
3186      controller.setAttribute("mediasubtitlesshowing", "on");
3187
3188      container.appendChild(controller);
3189      details.appendChild(container);
3190
3191      provider.addEventListener("waiting", () => container.classList.add("loading"));
3192      provider.addEventListener("playing", () => container.classList.remove("loading"));
3193      provider.addEventListener("play", () => container.classList.remove("loading"));
3194
3195      provider._textTracks.addEventListener("change", () => {
3196        const enabled = provider._captionsEnabled;
3197        captionsBtn.setAttribute("aria-checked", enabled);
3198        if (enabled) {
3199          controller.setAttribute("mediasubtitlesshowing", "on");
3200        } else {
3201          controller.removeAttribute("mediasubtitlesshowing");
3202        }
3203      });
3204
3205      provider.addEventListener("qualitychange", (e) => {
3206        qualityBtn.textContent = e.detail.quality;
3207      });
3208    }
3209
3210    function initLazyPlayers() {
3211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
3212        if (details.dataset.lazyInit) return;
3213        details.dataset.lazyInit = "true";
3214        details.addEventListener("toggle", () => {
3215          if (details.open && !details.querySelector(".player-container")) {
3216            createPlayer(details);
3217          } else if (!details.open) {
3218            const provider = details.querySelector("youtube-media-provider");
3219            if (provider && typeof provider.pause === "function") {
3220              provider.pause();
3221            }
3222          }
3223        });
3224      });
3225    }
3226
3227    if (document.readyState === "loading") {
3228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
3229    } else {
3230      initLazyPlayers();
3231    }
3232  })();
3233</script>
3233<script>
3234  (() => {
3235    if (window.__courseCompletionsInitialized) return;
3236    window.__courseCompletionsInitialized = true;
3237
3238    const STORAGE_KEY = "course-completions";
3239
3240    function slugify(text) {
3241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3242    }
3243
3244    function getCompletions() {
3245      try {
3246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
3247      } catch {
3248        return [];
3249      }
3250    }
3251
3252    function saveCompletions(completions) {
3253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
3254    }
3255
3256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
3257    function canonicalFromStored(storedId) {
3258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3259      if (!m) return slugify(storedId);
3260      const [, slug, p, mv, title] = m;
3261      return `${slug}-${p}-${mv}-${slugify(title)}`;
3262    }
3263
3264    function canonicalPlusOne(storedId) {
3265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3266      if (!m) return null;
3267      const [, slug, p, mv, title] = m;
3268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
3269    }
3270
3271    function isCompleted(canonicalId, completions) {
3272      if (completions.includes(canonicalId)) return true;
3273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
3274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
3275    }
3276
3277    function migrateCompletions() {
3278      const completions = getCompletions();
3279      let changed = false;
3280      const migrated = completions.map((c) => {
3281        const canon = canonicalFromStored(c);
3282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
3283        const plusOne = canonicalPlusOne(c);
3284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
3285          if (plusOne !== c) changed = true;
3286          return plusOne;
3287        }
3288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
3289          changed = true;
3290          return canon;
3291        }
3292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
3293        if (canon !== c) {
3294          changed = true;
3295          return canon;
3296        }
3297        return c;
3298      });
3299      // Elimina duplicados tras migración
3300      const deduped = [...new Set(migrated)];
3301      if (changed || deduped.length !== completions.length) {
3302        saveCompletions(deduped);
3303        return deduped;
3304      }
3305      return completions;
3306    }
3307
3308    function updateButtonState(btn, completions) {
3309      const id = btn.dataset.completionId;
3310      const list = completions ?? getCompletions();
3311      btn.classList.toggle("completed", isCompleted(id, list));
3312    }
3313
3314    function checkModuleCompletion(completions) {
3315      const list = completions ?? getCompletions();
3316      document.querySelectorAll(".course-video").forEach((module) => {
3317        const btns = module.querySelectorAll(".complete-btn");
3318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
3319        const check = module.querySelector(".module-check");
3320        if (check) check.classList.toggle("visible", allCompleted);
3321      });
3322    }
3323
3324    function initCompletionButtons() {
3325      const completions = migrateCompletions();
3326
3327      document.querySelectorAll(".complete-btn").forEach((btn) => {
3328        if (btn.dataset.completionInit) return;
3329        btn.dataset.completionInit = "true";
3330        updateButtonState(btn, completions);
3331
3332        btn.addEventListener("click", (e) => {
3333          e.preventDefault();
3334          e.stopPropagation();
3335
3336          const id = btn.dataset.completionId;
3337          let list = getCompletions();
3338          const completed = isCompleted(id, list);
3339
3340          if (completed) {
3341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
3342            list = list.filter((c) =>
3342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
3343          } else {
3344            list.push(id);
3345          }
3346
3347          saveCompletions(list);
3348          updateButtonState(btn, list);
3349          checkModuleCompletion(list);
3350        });
3351      });
3352
3353      checkModuleCompletion(completions);
3354    }
3355
3356    if (document.readyState === "loading") {
3357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
3358    } else {
3359      initCompletionButtons();
3360    }
3361  })();
3362</script>
3362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="1174" data-end="1921" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Fundamentos de Javascript</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>12 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-fundamentos-de-javascript" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
3362<script>
3363  (() => {
3364    if (window.__courseLazyPlayersInitialized) return;
3365    window.__courseLazyPlayersInitialized = true;
3366
3367    function createPlayer(details) {
3368      const videoId = details.dataset.videoId;
3369      const start = details.dataset.start;
3370      const end = details.dataset.end;
3371
3372      const container = document.createElement("div");
3373      container.className = "player-container";
3374
3375      const spinner = document.createElement("div");
3376      spinner.className = "spinner";
3377      container.appendChild(spinner);
3378
3379      const controller = document.createElement("media-controller");
3380      controller.setAttribute("defaultsubtitles", "");
3381
3382      const provider = document.createElement("youtube-media-provider");
3383      provider.setAttribute("slot", "media");
3384      provider.setAttribute("video-id", videoId);
3385      provider.setAttribute("start", start);
3386      provider.setAttribute("end", end);
3387
3388      const controlBar = document.createElement("media-control-bar");
3389
3390      const playBtn = document.createElement("media-play-button");
3391      const timeDisplay = document.createElement("media-time-display");
3392      timeDisplay.setAttribute("showduration", "");
3393      const timeRange = document.createElement("media-time-range");
3394      const playbackRate = document.createElement("media-playback-rate-button");
3395      const muteBtn = document.createElement("media-mute-button");
3396      const volumeRange = document.createElement("media-volume-range");
3397
3398      const captionsBtn = document.createElement("media-captions-button");
3399      captionsBtn.addEventListener("click", (e) => {
3400        e.stopPropagation();
3401        const provider = container.querySelector("youtube-media-provider");
3402        if (provider) provider.toggleCaptions();
3403      });
3404
3405      const qualityBtn = document.createElement("media-chrome-button");
3406      qualityBtn.textContent = "--";
3407      qualityBtn.addEventListener("click", () => {
3408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
3409      });
3410
3411      const fullscreenBtn = document.createElement("media-fullscreen-button");
3412
3413      controlBar.append(
3414        playBtn, timeDisplay, timeRange,
3415        playbackRate, muteBtn, volumeRange,
3416        captionsBtn, qualityBtn, fullscreenBtn
3417      );
3418
3419      const poster = document.createElement("media-poster-image");
3420      poster.setAttribute("slot", "poster");
3421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
3422
3423      controller.append(provider, poster, controlBar);
3424
3425      captionsBtn.setAttribute("aria-checked", "true");
3426      controller.setAttribute("mediasubtitlesshowing", "on");
3427
3428      container.appendChild(controller);
3429      details.appendChild(container);
3430
3431      provider.addEventListener("waiting", () => container.classList.add("loading"));
3432      provider.addEventListener("playing", () => container.classList.remove("loading"));
3433      provider.addEventListener("play", () => container.classList.remove("loading"));
3434
3435      provider._textTracks.addEventListener("change", () => {
3436        const enabled = provider._captionsEnabled;
3437        captionsBtn.setAttribute("aria-checked", enabled);
3438        if (enabled) {
3439          controller.setAttribute("mediasubtitlesshowing", "on");
3440        } else {
3441          controller.removeAttribute("mediasubtitlesshowing");
3442        }
3443      });
3444
3445      provider.addEventListener("qualitychange", (e) => {
3446        qualityBtn.textContent = e.detail.quality;
3447      });
3448    }
3449
3450    function initLazyPlayers() {
3451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
3452        if (details.dataset.lazyInit) return;
3453        details.dataset.lazyInit = "true";
3454        details.addEventListener("toggle", () => {
3455          if (details.open && !details.querySelector(".player-container")) {
3456            createPlayer(details);
3457          } else if (!details.open) {
3458            const provider = details.querySelector("youtube-media-provider");
3459            if (provider && typeof provider.pause === "function") {
3460              provider.pause();
3461            }
3462          }
3463        });
3464      });
3465    }
3466
3467    if (document.readyState === "loading") {
3468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
3469    } else {
3470      initLazyPlayers();
3471    }
3472  })();
3473</script>
3473<script>
3474  (() => {
3475    if (window.__courseCompletionsInitialized) return;
3476    window.__courseCompletionsInitialized = true;
3477
3478    const STORAGE_KEY = "course-completions";
3479
3480    function slugify(text) {
3481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3482    }
3483
3484    function getCompletions() {
3485      try {
3486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
3487      } catch {
3488        return [];
3489      }
3490    }
3491
3492    function saveCompletions(completions) {
3493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
3494    }
3495
3496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
3497    function canonicalFromStored(storedId) {
3498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3499      if (!m) return slugify(storedId);
3500      const [, slug, p, mv, title] = m;
3501      return `${slug}-${p}-${mv}-${slugify(title)}`;
3502    }
3503
3504    function canonicalPlusOne(storedId) {
3505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3506      if (!m) return null;
3507      const [, slug, p, mv, title] = m;
3508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
3509    }
3510
3511    function isCompleted(canonicalId, completions) {
3512      if (completions.includes(canonicalId)) return true;
3513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
3514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
3515    }
3516
3517    function migrateCompletions() {
3518      const completions = getCompletions();
3519      let changed = false;
3520      const migrated = completions.map((c) => {
3521        const canon = canonicalFromStored(c);
3522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
3523        const plusOne = canonicalPlusOne(c);
3524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
3525          if (plusOne !== c) changed = true;
3526          return plusOne;
3527        }
3528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
3529          changed = true;
3530          return canon;
3531        }
3532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
3533        if (canon !== c) {
3534          changed = true;
3535          return canon;
3536        }
3537        return c;
3538      });
3539      // Elimina duplicados tras migración
3540      const deduped = [...new Set(migrated)];
3541      if (changed || deduped.length !== completions.length) {
3542        saveCompletions(deduped);
3543        return deduped;
3544      }
3545      return completions;
3546    }
3547
3548    function updateButtonState(btn, completions) {
3549      const id = btn.dataset.completionId;
3550      const list = completions ?? getCompletions();
3551      btn.classList.toggle("completed", isCompleted(id, list));
3552    }
3553
3554    function checkModuleCompletion(completions) {
3555      const list = completions ?? getCompletions();
3556      document.querySelectorAll(".course-video").forEach((module) => {
3557        const btns = module.querySelectorAll(".complete-btn");
3558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
3559        const check = module.querySelector(".module-check");
3560        if (check) check.classList.toggle("visible", allCompleted);
3561      });
3562    }
3563
3564    function initCompletionButtons() {
3565      const completions = migrateCompletions();
3566
3567      document.querySelectorAll(".complete-btn").forEach((btn) => {
3568        if (btn.dataset.completionInit) return;
3569        btn.dataset.completionInit = "true";
3570        updateButtonState(btn, completions);
3571
3572        btn.addEventListener("click", (e) => {
3573          e.preventDefault();
3574          e.stopPropagation();
3575
3576          const id = btn.dataset.completionId;
3577          let list = getCompletions();
3578          const completed = isCompleted(id, list);
3579
3580          if (completed) {
3581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
3582            list = list.filter((c) =>
3582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
3583          } else {
3584            list.push(id);
3585          }
3586
3587          saveCompletions(list);
3588          updateButtonState(btn, list);
3589          checkModuleCompletion(list);
3590        });
3591      });
3592
3593      checkModuleCompletion(completions);
3594    }
3595
3596    if (document.readyState === "loading") {
3597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
3598    } else {
3599      initCompletionButtons();
3600    }
3601  })();
3602</script>
3602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="1921" data-end="2677" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Tipo de código</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-tipo-de-codigo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
3602<script>
3603  (() => {
3604    if (window.__courseLazyPlayersInitialized) return;
3605    window.__courseLazyPlayersInitialized = true;
3606
3607    function createPlayer(details) {
3608      const videoId = details.dataset.videoId;
3609      const start = details.dataset.start;
3610      const end = details.dataset.end;
3611
3612      const container = document.createElement("div");
3613      container.className = "player-container";
3614
3615      const spinner = document.createElement("div");
3616      spinner.className = "spinner";
3617      container.appendChild(spinner);
3618
3619      const controller = document.createElement("media-controller");
3620      controller.setAttribute("defaultsubtitles", "");
3621
3622      const provider = document.createElement("youtube-media-provider");
3623      provider.setAttribute("slot", "media");
3624      provider.setAttribute("video-id", videoId);
3625      provider.setAttribute("start", start);
3626      provider.setAttribute("end", end);
3627
3628      const controlBar = document.createElement("media-control-bar");
3629
3630      const playBtn = document.createElement("media-play-button");
3631      const timeDisplay = document.createElement("media-time-display");
3632      timeDisplay.setAttribute("showduration", "");
3633      const timeRange = document.createElement("media-time-range");
3634      const playbackRate = document.createElement("media-playback-rate-button");
3635      const muteBtn = document.createElement("media-mute-button");
3636      const volumeRange = document.createElement("media-volume-range");
3637
3638      const captionsBtn = document.createElement("media-captions-button");
3639      captionsBtn.addEventListener("click", (e) => {
3640        e.stopPropagation();
3641        const provider = container.querySelector("youtube-media-provider");
3642        if (provider) provider.toggleCaptions();
3643      });
3644
3645      const qualityBtn = document.createElement("media-chrome-button");
3646      qualityBtn.textContent = "--";
3647      qualityBtn.addEventListener("click", () => {
3648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
3649      });
3650
3651      const fullscreenBtn = document.createElement("media-fullscreen-button");
3652
3653      controlBar.append(
3654        playBtn, timeDisplay, timeRange,
3655        playbackRate, muteBtn, volumeRange,
3656        captionsBtn, qualityBtn, fullscreenBtn
3657      );
3658
3659      const poster = document.createElement("media-poster-image");
3660      poster.setAttribute("slot", "poster");
3661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
3662
3663      controller.append(provider, poster, controlBar);
3664
3665      captionsBtn.setAttribute("aria-checked", "true");
3666      controller.setAttribute("mediasubtitlesshowing", "on");
3667
3668      container.appendChild(controller);
3669      details.appendChild(container);
3670
3671      provider.addEventListener("waiting", () => container.classList.add("loading"));
3672      provider.addEventListener("playing", () => container.classList.remove("loading"));
3673      provider.addEventListener("play", () => container.classList.remove("loading"));
3674
3675      provider._textTracks.addEventListener("change", () => {
3676        const enabled = provider._captionsEnabled;
3677        captionsBtn.setAttribute("aria-checked", enabled);
3678        if (enabled) {
3679          controller.setAttribute("mediasubtitlesshowing", "on");
3680        } else {
3681          controller.removeAttribute("mediasubtitlesshowing");
3682        }
3683      });
3684
3685      provider.addEventListener("qualitychange", (e) => {
3686        qualityBtn.textContent = e.detail.quality;
3687      });
3688    }
3689
3690    function initLazyPlayers() {
3691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
3692        if (details.dataset.lazyInit) return;
3693        details.dataset.lazyInit = "true";
3694        details.addEventListener("toggle", () => {
3695          if (details.open && !details.querySelector(".player-container")) {
3696            createPlayer(details);
3697          } else if (!details.open) {
3698            const provider = details.querySelector("youtube-media-provider");
3699            if (provider && typeof provider.pause === "function") {
3700              provider.pause();
3701            }
3702          }
3703        });
3704      });
3705    }
3706
3707    if (document.readyState === "loading") {
3708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
3709    } else {
3710      initLazyPlayers();
3711    }
3712  })();
3713</script>
3713<script>
3714  (() => {
3715    if (window.__courseCompletionsInitialized) return;
3716    window.__courseCompletionsInitialized = true;
3717
3718    const STORAGE_KEY = "course-completions";
3719
3720    function slugify(text) {
3721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3722    }
3723
3724    function getCompletions() {
3725      try {
3726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
3727      } catch {
3728        return [];
3729      }
3730    }
3731
3732    function saveCompletions(completions) {
3733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
3734    }
3735
3736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
3737    function canonicalFromStored(storedId) {
3738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3739      if (!m) return slugify(storedId);
3740      const [, slug, p, mv, title] = m;
3741      return `${slug}-${p}-${mv}-${slugify(title)}`;
3742    }
3743
3744    function canonicalPlusOne(storedId) {
3745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3746      if (!m) return null;
3747      const [, slug, p, mv, title] = m;
3748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
3749    }
3750
3751    function isCompleted(canonicalId, completions) {
3752      if (completions.includes(canonicalId)) return true;
3753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
3754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
3755    }
3756
3757    function migrateCompletions() {
3758      const completions = getCompletions();
3759      let changed = false;
3760      const migrated = completions.map((c) => {
3761        const canon = canonicalFromStored(c);
3762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
3763        const plusOne = canonicalPlusOne(c);
3764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
3765          if (plusOne !== c) changed = true;
3766          return plusOne;
3767        }
3768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
3769          changed = true;
3770          return canon;
3771        }
3772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
3773        if (canon !== c) {
3774          changed = true;
3775          return canon;
3776        }
3777        return c;
3778      });
3779      // Elimina duplicados tras migración
3780      const deduped = [...new Set(migrated)];
3781      if (changed || deduped.length !== completions.length) {
3782        saveCompletions(deduped);
3783        return deduped;
3784      }
3785      return completions;
3786    }
3787
3788    function updateButtonState(btn, completions) {
3789      const id = btn.dataset.completionId;
3790      const list = completions ?? getCompletions();
3791      btn.classList.toggle("completed", isCompleted(id, list));
3792    }
3793
3794    function checkModuleCompletion(completions) {
3795      const list = completions ?? getCompletions();
3796      document.querySelectorAll(".course-video").forEach((module) => {
3797        const btns = module.querySelectorAll(".complete-btn");
3798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
3799        const check = module.querySelector(".module-check");
3800        if (check) check.classList.toggle("visible", allCompleted);
3801      });
3802    }
3803
3804    function initCompletionButtons() {
3805      const completions = migrateCompletions();
3806
3807      document.querySelectorAll(".complete-btn").forEach((btn) => {
3808        if (btn.dataset.completionInit) return;
3809        btn.dataset.completionInit = "true";
3810        updateButtonState(btn, completions);
3811
3812        btn.addEventListener("click", (e) => {
3813          e.preventDefault();
3814          e.stopPropagation();
3815
3816          const id = btn.dataset.completionId;
3817          let list = getCompletions();
3818          const completed = isCompleted(id, list);
3819
3820          if (completed) {
3821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
3822            list = list.filter((c) =>
3822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
3823          } else {
3824            list.push(id);
3825          }
3826
3827          saveCompletions(list);
3828          updateButtonState(btn, list);
3829          checkModuleCompletion(list);
3830        });
3831      });
3832
3833      checkModuleCompletion(completions);
3834    }
3835
3836    if (document.readyState === "loading") {
3837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
3838    } else {
3839      initCompletionButtons();
3840    }
3841  })();
3842</script>
3842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="2677" data-end="3865" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Variables y comentarios</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>20 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-variables-y-comentarios" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
3842<script>
3843  (() => {
3844    if (window.__courseLazyPlayersInitialized) return;
3845    window.__courseLazyPlayersInitialized = true;
3846
3847    function createPlayer(details) {
3848      const videoId = details.dataset.videoId;
3849      const start = details.dataset.start;
3850      const end = details.dataset.end;
3851
3852      const container = document.createElement("div");
3853      container.className = "player-container";
3854
3855      const spinner = document.createElement("div");
3856      spinner.className = "spinner";
3857      container.appendChild(spinner);
3858
3859      const controller = document.createElement("media-controller");
3860      controller.setAttribute("defaultsubtitles", "");
3861
3862      const provider = document.createElement("youtube-media-provider");
3863      provider.setAttribute("slot", "media");
3864      provider.setAttribute("video-id", videoId);
3865      provider.setAttribute("start", start);
3866      provider.setAttribute("end", end);
3867
3868      const controlBar = document.createElement("media-control-bar");
3869
3870      const playBtn = document.createElement("media-play-button");
3871      const timeDisplay = document.createElement("media-time-display");
3872      timeDisplay.setAttribute("showduration", "");
3873      const timeRange = document.createElement("media-time-range");
3874      const playbackRate = document.createElement("media-playback-rate-button");
3875      const muteBtn = document.createElement("media-mute-button");
3876      const volumeRange = document.createElement("media-volume-range");
3877
3878      const captionsBtn = document.createElement("media-captions-button");
3879      captionsBtn.addEventListener("click", (e) => {
3880        e.stopPropagation();
3881        const provider = container.querySelector("youtube-media-provider");
3882        if (provider) provider.toggleCaptions();
3883      });
3884
3885      const qualityBtn = document.createElement("media-chrome-button");
3886      qualityBtn.textContent = "--";
3887      qualityBtn.addEventListener("click", () => {
3888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
3889      });
3890
3891      const fullscreenBtn = document.createElement("media-fullscreen-button");
3892
3893      controlBar.append(
3894        playBtn, timeDisplay, timeRange,
3895        playbackRate, muteBtn, volumeRange,
3896        captionsBtn, qualityBtn, fullscreenBtn
3897      );
3898
3899      const poster = document.createElement("media-poster-image");
3900      poster.setAttribute("slot", "poster");
3901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
3902
3903      controller.append(provider, poster, controlBar);
3904
3905      captionsBtn.setAttribute("aria-checked", "true");
3906      controller.setAttribute("mediasubtitlesshowing", "on");
3907
3908      container.appendChild(controller);
3909      details.appendChild(container);
3910
3911      provider.addEventListener("waiting", () => container.classList.add("loading"));
3912      provider.addEventListener("playing", () => container.classList.remove("loading"));
3913      provider.addEventListener("play", () => container.classList.remove("loading"));
3914
3915      provider._textTracks.addEventListener("change", () => {
3916        const enabled = provider._captionsEnabled;
3917        captionsBtn.setAttribute("aria-checked", enabled);
3918        if (enabled) {
3919          controller.setAttribute("mediasubtitlesshowing", "on");
3920        } else {
3921          controller.removeAttribute("mediasubtitlesshowing");
3922        }
3923      });
3924
3925      provider.addEventListener("qualitychange", (e) => {
3926        qualityBtn.textContent = e.detail.quality;
3927      });
3928    }
3929
3930    function initLazyPlayers() {
3931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
3932        if (details.dataset.lazyInit) return;
3933        details.dataset.lazyInit = "true";
3934        details.addEventListener("toggle", () => {
3935          if (details.open && !details.querySelector(".player-container")) {
3936            createPlayer(details);
3937          } else if (!details.open) {
3938            const provider = details.querySelector("youtube-media-provider");
3939            if (provider && typeof provider.pause === "function") {
3940              provider.pause();
3941            }
3942          }
3943        });
3944      });
3945    }
3946
3947    if (document.readyState === "loading") {
3948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
3949    } else {
3950      initLazyPlayers();
3951    }
3952  })();
3953</script>
3953<script>
3954  (() => {
3955    if (window.__courseCompletionsInitialized) return;
3956    window.__courseCompletionsInitialized = true;
3957
3958    const STORAGE_KEY = "course-completions";
3959
3960    function slugify(text) {
3961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3962    }
3963
3964    function getCompletions() {
3965      try {
3966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
3967      } catch {
3968        return [];
3969      }
3970    }
3971
3972    function saveCompletions(completions) {
3973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
3974    }
3975
3976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
3977    function canonicalFromStored(storedId) {
3978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3979      if (!m) return slugify(storedId);
3980      const [, slug, p, mv, title] = m;
3981      return `${slug}-${p}-${mv}-${slugify(title)}`;
3982    }
3983
3984    function canonicalPlusOne(storedId) {
3985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
3986      if (!m) return null;
3987      const [, slug, p, mv, title] = m;
3988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
3989    }
3990
3991    function isCompleted(canonicalId, completions) {
3992      if (completions.includes(canonicalId)) return true;
3993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
3994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
3995    }
3996
3997    function migrateCompletions() {
3998      const completions = getCompletions();
3999      let changed = false;
4000      const migrated = completions.map((c) => {
4001        const canon = canonicalFromStored(c);
4002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
4003        const plusOne = canonicalPlusOne(c);
4004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
4005          if (plusOne !== c) changed = true;
4006          return plusOne;
4007        }
4008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
4009          changed = true;
4010          return canon;
4011        }
4012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
4013        if (canon !== c) {
4014          changed = true;
4015          return canon;
4016        }
4017        return c;
4018      });
4019      // Elimina duplicados tras migración
4020      const deduped = [...new Set(migrated)];
4021      if (changed || deduped.length !== completions.length) {
4022        saveCompletions(deduped);
4023        return deduped;
4024      }
4025      return completions;
4026    }
4027
4028    function updateButtonState(btn, completions) {
4029      const id = btn.dataset.completionId;
4030      const list = completions ?? getCompletions();
4031      btn.classList.toggle("completed", isCompleted(id, list));
4032    }
4033
4034    function checkModuleCompletion(completions) {
4035      const list = completions ?? getCompletions();
4036      document.querySelectorAll(".course-video").forEach((module) => {
4037        const btns = module.querySelectorAll(".complete-btn");
4038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
4039        const check = module.querySelector(".module-check");
4040        if (check) check.classList.toggle("visible", allCompleted);
4041      });
4042    }
4043
4044    function initCompletionButtons() {
4045      const completions = migrateCompletions();
4046
4047      document.querySelectorAll(".complete-btn").forEach((btn) => {
4048        if (btn.dataset.completionInit) return;
4049        btn.dataset.completionInit = "true";
4050        updateButtonState(btn, completions);
4051
4052        btn.addEventListener("click", (e) => {
4053          e.preventDefault();
4054          e.stopPropagation();
4055
4056          const id = btn.dataset.completionId;
4057          let list = getCompletions();
4058          const completed = isCompleted(id, list);
4059
4060          if (completed) {
4061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
4062            list = list.filter((c) =>
4062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
4063          } else {
4064            list.push(id);
4065          }
4066
4067          saveCompletions(list);
4068          updateButtonState(btn, list);
4069          checkModuleCompletion(list);
4070        });
4071      });
4072
4073      checkModuleCompletion(completions);
4074    }
4075
4076    if (document.readyState === "loading") {
4077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
4078    } else {
4079      initCompletionButtons();
4080    }
4081  })();
4082</script>
4082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="3865" data-end="5494" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Tipos de datos primitivos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>27 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-tipos-de-datos-primitivos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
4082<script>
4083  (() => {
4084    if (window.__courseLazyPlayersInitialized) return;
4085    window.__courseLazyPlayersInitialized = true;
4086
4087    function createPlayer(details) {
4088      const videoId = details.dataset.videoId;
4089      const start = details.dataset.start;
4090      const end = details.dataset.end;
4091
4092      const container = document.createElement("div");
4093      container.className = "player-container";
4094
4095      const spinner = document.createElement("div");
4096      spinner.className = "spinner";
4097      container.appendChild(spinner);
4098
4099      const controller = document.createElement("media-controller");
4100      controller.setAttribute("defaultsubtitles", "");
4101
4102      const provider = document.createElement("youtube-media-provider");
4103      provider.setAttribute("slot", "media");
4104      provider.setAttribute("video-id", videoId);
4105      provider.setAttribute("start", start);
4106      provider.setAttribute("end", end);
4107
4108      const controlBar = document.createElement("media-control-bar");
4109
4110      const playBtn = document.createElement("media-play-button");
4111      const timeDisplay = document.createElement("media-time-display");
4112      timeDisplay.setAttribute("showduration", "");
4113      const timeRange = document.createElement("media-time-range");
4114      const playbackRate = document.createElement("media-playback-rate-button");
4115      const muteBtn = document.createElement("media-mute-button");
4116      const volumeRange = document.createElement("media-volume-range");
4117
4118      const captionsBtn = document.createElement("media-captions-button");
4119      captionsBtn.addEventListener("click", (e) => {
4120        e.stopPropagation();
4121        const provider = container.querySelector("youtube-media-provider");
4122        if (provider) provider.toggleCaptions();
4123      });
4124
4125      const qualityBtn = document.createElement("media-chrome-button");
4126      qualityBtn.textContent = "--";
4127      qualityBtn.addEventListener("click", () => {
4128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
4129      });
4130
4131      const fullscreenBtn = document.createElement("media-fullscreen-button");
4132
4133      controlBar.append(
4134        playBtn, timeDisplay, timeRange,
4135        playbackRate, muteBtn, volumeRange,
4136        captionsBtn, qualityBtn, fullscreenBtn
4137      );
4138
4139      const poster = document.createElement("media-poster-image");
4140      poster.setAttribute("slot", "poster");
4141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
4142
4143      controller.append(provider, poster, controlBar);
4144
4145      captionsBtn.setAttribute("aria-checked", "true");
4146      controller.setAttribute("mediasubtitlesshowing", "on");
4147
4148      container.appendChild(controller);
4149      details.appendChild(container);
4150
4151      provider.addEventListener("waiting", () => container.classList.add("loading"));
4152      provider.addEventListener("playing", () => container.classList.remove("loading"));
4153      provider.addEventListener("play", () => container.classList.remove("loading"));
4154
4155      provider._textTracks.addEventListener("change", () => {
4156        const enabled = provider._captionsEnabled;
4157        captionsBtn.setAttribute("aria-checked", enabled);
4158        if (enabled) {
4159          controller.setAttribute("mediasubtitlesshowing", "on");
4160        } else {
4161          controller.removeAttribute("mediasubtitlesshowing");
4162        }
4163      });
4164
4165      provider.addEventListener("qualitychange", (e) => {
4166        qualityBtn.textContent = e.detail.quality;
4167      });
4168    }
4169
4170    function initLazyPlayers() {
4171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
4172        if (details.dataset.lazyInit) return;
4173        details.dataset.lazyInit = "true";
4174        details.addEventListener("toggle", () => {
4175          if (details.open && !details.querySelector(".player-container")) {
4176            createPlayer(details);
4177          } else if (!details.open) {
4178            const provider = details.querySelector("youtube-media-provider");
4179            if (provider && typeof provider.pause === "function") {
4180              provider.pause();
4181            }
4182          }
4183        });
4184      });
4185    }
4186
4187    if (document.readyState === "loading") {
4188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
4189    } else {
4190      initLazyPlayers();
4191    }
4192  })();
4193</script>
4193<script>
4194  (() => {
4195    if (window.__courseCompletionsInitialized) return;
4196    window.__courseCompletionsInitialized = true;
4197
4198    const STORAGE_KEY = "course-completions";
4199
4200    function slugify(text) {
4201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4202    }
4203
4204    function getCompletions() {
4205      try {
4206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
4207      } catch {
4208        return [];
4209      }
4210    }
4211
4212    function saveCompletions(completions) {
4213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
4214    }
4215
4216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
4217    function canonicalFromStored(storedId) {
4218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4219      if (!m) return slugify(storedId);
4220      const [, slug, p, mv, title] = m;
4221      return `${slug}-${p}-${mv}-${slugify(title)}`;
4222    }
4223
4224    function canonicalPlusOne(storedId) {
4225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4226      if (!m) return null;
4227      const [, slug, p, mv, title] = m;
4228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
4229    }
4230
4231    function isCompleted(canonicalId, completions) {
4232      if (completions.includes(canonicalId)) return true;
4233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
4234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
4235    }
4236
4237    function migrateCompletions() {
4238      const completions = getCompletions();
4239      let changed = false;
4240      const migrated = completions.map((c) => {
4241        const canon = canonicalFromStored(c);
4242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
4243        const plusOne = canonicalPlusOne(c);
4244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
4245          if (plusOne !== c) changed = true;
4246          return plusOne;
4247        }
4248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
4249          changed = true;
4250          return canon;
4251        }
4252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
4253        if (canon !== c) {
4254          changed = true;
4255          return canon;
4256        }
4257        return c;
4258      });
4259      // Elimina duplicados tras migración
4260      const deduped = [...new Set(migrated)];
4261      if (changed || deduped.length !== completions.length) {
4262        saveCompletions(deduped);
4263        return deduped;
4264      }
4265      return completions;
4266    }
4267
4268    function updateButtonState(btn, completions) {
4269      const id = btn.dataset.completionId;
4270      const list = completions ?? getCompletions();
4271      btn.classList.toggle("completed", isCompleted(id, list));
4272    }
4273
4274    function checkModuleCompletion(completions) {
4275      const list = completions ?? getCompletions();
4276      document.querySelectorAll(".course-video").forEach((module) => {
4277        const btns = module.querySelectorAll(".complete-btn");
4278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
4279        const check = module.querySelector(".module-check");
4280        if (check) check.classList.toggle("visible", allCompleted);
4281      });
4282    }
4283
4284    function initCompletionButtons() {
4285      const completions = migrateCompletions();
4286
4287      document.querySelectorAll(".complete-btn").forEach((btn) => {
4288        if (btn.dataset.completionInit) return;
4289        btn.dataset.completionInit = "true";
4290        updateButtonState(btn, completions);
4291
4292        btn.addEventListener("click", (e) => {
4293          e.preventDefault();
4294          e.stopPropagation();
4295
4296          const id = btn.dataset.completionId;
4297          let list = getCompletions();
4298          const completed = isCompleted(id, list);
4299
4300          if (completed) {
4301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
4302            list = list.filter((c) =>
4302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
4303          } else {
4304            list.push(id);
4305          }
4306
4307          saveCompletions(list);
4308          updateButtonState(btn, list);
4309          checkModuleCompletion(list);
4310        });
4311      });
4312
4313      checkModuleCompletion(completions);
4314    }
4315
4316    if (document.readyState === "loading") {
4317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
4318    } else {
4319      initCompletionButtons();
4320    }
4321  })();
4322</script>
4322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="5494" data-end="7591" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Tipos de datos avanzados</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>35 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-tipos-de-datos-avanzados" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
4322<script>
4323  (() => {
4324    if (window.__courseLazyPlayersInitialized) return;
4325    window.__courseLazyPlayersInitialized = true;
4326
4327    function createPlayer(details) {
4328      const videoId = details.dataset.videoId;
4329      const start = details.dataset.start;
4330      const end = details.dataset.end;
4331
4332      const container = document.createElement("div");
4333      container.className = "player-container";
4334
4335      const spinner = document.createElement("div");
4336      spinner.className = "spinner";
4337      container.appendChild(spinner);
4338
4339      const controller = document.createElement("media-controller");
4340      controller.setAttribute("defaultsubtitles", "");
4341
4342      const provider = document.createElement("youtube-media-provider");
4343      provider.setAttribute("slot", "media");
4344      provider.setAttribute("video-id", videoId);
4345      provider.setAttribute("start", start);
4346      provider.setAttribute("end", end);
4347
4348      const controlBar = document.createElement("media-control-bar");
4349
4350      const playBtn = document.createElement("media-play-button");
4351      const timeDisplay = document.createElement("media-time-display");
4352      timeDisplay.setAttribute("showduration", "");
4353      const timeRange = document.createElement("media-time-range");
4354      const playbackRate = document.createElement("media-playback-rate-button");
4355      const muteBtn = document.createElement("media-mute-button");
4356      const volumeRange = document.createElement("media-volume-range");
4357
4358      const captionsBtn = document.createElement("media-captions-button");
4359      captionsBtn.addEventListener("click", (e) => {
4360        e.stopPropagation();
4361        const provider = container.querySelector("youtube-media-provider");
4362        if (provider) provider.toggleCaptions();
4363      });
4364
4365      const qualityBtn = document.createElement("media-chrome-button");
4366      qualityBtn.textContent = "--";
4367      qualityBtn.addEventListener("click", () => {
4368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
4369      });
4370
4371      const fullscreenBtn = document.createElement("media-fullscreen-button");
4372
4373      controlBar.append(
4374        playBtn, timeDisplay, timeRange,
4375        playbackRate, muteBtn, volumeRange,
4376        captionsBtn, qualityBtn, fullscreenBtn
4377      );
4378
4379      const poster = document.createElement("media-poster-image");
4380      poster.setAttribute("slot", "poster");
4381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
4382
4383      controller.append(provider, poster, controlBar);
4384
4385      captionsBtn.setAttribute("aria-checked", "true");
4386      controller.setAttribute("mediasubtitlesshowing", "on");
4387
4388      container.appendChild(controller);
4389      details.appendChild(container);
4390
4391      provider.addEventListener("waiting", () => container.classList.add("loading"));
4392      provider.addEventListener("playing", () => container.classList.remove("loading"));
4393      provider.addEventListener("play", () => container.classList.remove("loading"));
4394
4395      provider._textTracks.addEventListener("change", () => {
4396        const enabled = provider._captionsEnabled;
4397        captionsBtn.setAttribute("aria-checked", enabled);
4398        if (enabled) {
4399          controller.setAttribute("mediasubtitlesshowing", "on");
4400        } else {
4401          controller.removeAttribute("mediasubtitlesshowing");
4402        }
4403      });
4404
4405      provider.addEventListener("qualitychange", (e) => {
4406        qualityBtn.textContent = e.detail.quality;
4407      });
4408    }
4409
4410    function initLazyPlayers() {
4411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
4412        if (details.dataset.lazyInit) return;
4413        details.dataset.lazyInit = "true";
4414        details.addEventListener("toggle", () => {
4415          if (details.open && !details.querySelector(".player-container")) {
4416            createPlayer(details);
4417          } else if (!details.open) {
4418            const provider = details.querySelector("youtube-media-provider");
4419            if (provider && typeof provider.pause === "function") {
4420              provider.pause();
4421            }
4422          }
4423        });
4424      });
4425    }
4426
4427    if (document.readyState === "loading") {
4428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
4429    } else {
4430      initLazyPlayers();
4431    }
4432  })();
4433</script>
4433<script>
4434  (() => {
4435    if (window.__courseCompletionsInitialized) return;
4436    window.__courseCompletionsInitialized = true;
4437
4438    const STORAGE_KEY = "course-completions";
4439
4440    function slugify(text) {
4441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4442    }
4443
4444    function getCompletions() {
4445      try {
4446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
4447      } catch {
4448        return [];
4449      }
4450    }
4451
4452    function saveCompletions(completions) {
4453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
4454    }
4455
4456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
4457    function canonicalFromStored(storedId) {
4458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4459      if (!m) return slugify(storedId);
4460      const [, slug, p, mv, title] = m;
4461      return `${slug}-${p}-${mv}-${slugify(title)}`;
4462    }
4463
4464    function canonicalPlusOne(storedId) {
4465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4466      if (!m) return null;
4467      const [, slug, p, mv, title] = m;
4468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
4469    }
4470
4471    function isCompleted(canonicalId, completions) {
4472      if (completions.includes(canonicalId)) return true;
4473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
4474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
4475    }
4476
4477    function migrateCompletions() {
4478      const completions = getCompletions();
4479      let changed = false;
4480      const migrated = completions.map((c) => {
4481        const canon = canonicalFromStored(c);
4482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
4483        const plusOne = canonicalPlusOne(c);
4484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
4485          if (plusOne !== c) changed = true;
4486          return plusOne;
4487        }
4488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
4489          changed = true;
4490          return canon;
4491        }
4492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
4493        if (canon !== c) {
4494          changed = true;
4495          return canon;
4496        }
4497        return c;
4498      });
4499      // Elimina duplicados tras migración
4500      const deduped = [...new Set(migrated)];
4501      if (changed || deduped.length !== completions.length) {
4502        saveCompletions(deduped);
4503        return deduped;
4504      }
4505      return completions;
4506    }
4507
4508    function updateButtonState(btn, completions) {
4509      const id = btn.dataset.completionId;
4510      const list = completions ?? getCompletions();
4511      btn.classList.toggle("completed", isCompleted(id, list));
4512    }
4513
4514    function checkModuleCompletion(completions) {
4515      const list = completions ?? getCompletions();
4516      document.querySelectorAll(".course-video").forEach((module) => {
4517        const btns = module.querySelectorAll(".complete-btn");
4518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
4519        const check = module.querySelector(".module-check");
4520        if (check) check.classList.toggle("visible", allCompleted);
4521      });
4522    }
4523
4524    function initCompletionButtons() {
4525      const completions = migrateCompletions();
4526
4527      document.querySelectorAll(".complete-btn").forEach((btn) => {
4528        if (btn.dataset.completionInit) return;
4529        btn.dataset.completionInit = "true";
4530        updateButtonState(btn, completions);
4531
4532        btn.addEventListener("click", (e) => {
4533          e.preventDefault();
4534          e.stopPropagation();
4535
4536          const id = btn.dataset.completionId;
4537          let list = getCompletions();
4538          const completed = isCompleted(id, list);
4539
4540          if (completed) {
4541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
4542            list = list.filter((c) =>
4542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
4543          } else {
4544            list.push(id);
4545          }
4546
4547          saveCompletions(list);
4548          updateButtonState(btn, list);
4549          checkModuleCompletion(list);
4550        });
4551      });
4552
4553      checkModuleCompletion(completions);
4554    }
4555
4556    if (document.readyState === "loading") {
4557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
4558    } else {
4559      initCompletionButtons();
4560    }
4561  })();
4562</script>
4562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="7890" data-end="9306" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Condicionales</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-condicionales" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
4562<script>
4563  (() => {
4564    if (window.__courseLazyPlayersInitialized) return;
4565    window.__courseLazyPlayersInitialized = true;
4566
4567    function createPlayer(details) {
4568      const videoId = details.dataset.videoId;
4569      const start = details.dataset.start;
4570      const end = details.dataset.end;
4571
4572      const container = document.createElement("div");
4573      container.className = "player-container";
4574
4575      const spinner = document.createElement("div");
4576      spinner.className = "spinner";
4577      container.appendChild(spinner);
4578
4579      const controller = document.createElement("media-controller");
4580      controller.setAttribute("defaultsubtitles", "");
4581
4582      const provider = document.createElement("youtube-media-provider");
4583      provider.setAttribute("slot", "media");
4584      provider.setAttribute("video-id", videoId);
4585      provider.setAttribute("start", start);
4586      provider.setAttribute("end", end);
4587
4588      const controlBar = document.createElement("media-control-bar");
4589
4590      const playBtn = document.createElement("media-play-button");
4591      const timeDisplay = document.createElement("media-time-display");
4592      timeDisplay.setAttribute("showduration", "");
4593      const timeRange = document.createElement("media-time-range");
4594      const playbackRate = document.createElement("media-playback-rate-button");
4595      const muteBtn = document.createElement("media-mute-button");
4596      const volumeRange = document.createElement("media-volume-range");
4597
4598      const captionsBtn = document.createElement("media-captions-button");
4599      captionsBtn.addEventListener("click", (e) => {
4600        e.stopPropagation();
4601        const provider = container.querySelector("youtube-media-provider");
4602        if (provider) provider.toggleCaptions();
4603      });
4604
4605      const qualityBtn = document.createElement("media-chrome-button");
4606      qualityBtn.textContent = "--";
4607      qualityBtn.addEventListener("click", () => {
4608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
4609      });
4610
4611      const fullscreenBtn = document.createElement("media-fullscreen-button");
4612
4613      controlBar.append(
4614        playBtn, timeDisplay, timeRange,
4615        playbackRate, muteBtn, volumeRange,
4616        captionsBtn, qualityBtn, fullscreenBtn
4617      );
4618
4619      const poster = document.createElement("media-poster-image");
4620      poster.setAttribute("slot", "poster");
4621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
4622
4623      controller.append(provider, poster, controlBar);
4624
4625      captionsBtn.setAttribute("aria-checked", "true");
4626      controller.setAttribute("mediasubtitlesshowing", "on");
4627
4628      container.appendChild(controller);
4629      details.appendChild(container);
4630
4631      provider.addEventListener("waiting", () => container.classList.add("loading"));
4632      provider.addEventListener("playing", () => container.classList.remove("loading"));
4633      provider.addEventListener("play", () => container.classList.remove("loading"));
4634
4635      provider._textTracks.addEventListener("change", () => {
4636        const enabled = provider._captionsEnabled;
4637        captionsBtn.setAttribute("aria-checked", enabled);
4638        if (enabled) {
4639          controller.setAttribute("mediasubtitlesshowing", "on");
4640        } else {
4641          controller.removeAttribute("mediasubtitlesshowing");
4642        }
4643      });
4644
4645      provider.addEventListener("qualitychange", (e) => {
4646        qualityBtn.textContent = e.detail.quality;
4647      });
4648    }
4649
4650    function initLazyPlayers() {
4651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
4652        if (details.dataset.lazyInit) return;
4653        details.dataset.lazyInit = "true";
4654        details.addEventListener("toggle", () => {
4655          if (details.open && !details.querySelector(".player-container")) {
4656            createPlayer(details);
4657          } else if (!details.open) {
4658            const provider = details.querySelector("youtube-media-provider");
4659            if (provider && typeof provider.pause === "function") {
4660              provider.pause();
4661            }
4662          }
4663        });
4664      });
4665    }
4666
4667    if (document.readyState === "loading") {
4668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
4669    } else {
4670      initLazyPlayers();
4671    }
4672  })();
4673</script>
4673<script>
4674  (() => {
4675    if (window.__courseCompletionsInitialized) return;
4676    window.__courseCompletionsInitialized = true;
4677
4678    const STORAGE_KEY = "course-completions";
4679
4680    function slugify(text) {
4681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4682    }
4683
4684    function getCompletions() {
4685      try {
4686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
4687      } catch {
4688        return [];
4689      }
4690    }
4691
4692    function saveCompletions(completions) {
4693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
4694    }
4695
4696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
4697    function canonicalFromStored(storedId) {
4698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4699      if (!m) return slugify(storedId);
4700      const [, slug, p, mv, title] = m;
4701      return `${slug}-${p}-${mv}-${slugify(title)}`;
4702    }
4703
4704    function canonicalPlusOne(storedId) {
4705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4706      if (!m) return null;
4707      const [, slug, p, mv, title] = m;
4708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
4709    }
4710
4711    function isCompleted(canonicalId, completions) {
4712      if (completions.includes(canonicalId)) return true;
4713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
4714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
4715    }
4716
4717    function migrateCompletions() {
4718      const completions = getCompletions();
4719      let changed = false;
4720      const migrated = completions.map((c) => {
4721        const canon = canonicalFromStored(c);
4722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
4723        const plusOne = canonicalPlusOne(c);
4724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
4725          if (plusOne !== c) changed = true;
4726          return plusOne;
4727        }
4728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
4729          changed = true;
4730          return canon;
4731        }
4732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
4733        if (canon !== c) {
4734          changed = true;
4735          return canon;
4736        }
4737        return c;
4738      });
4739      // Elimina duplicados tras migración
4740      const deduped = [...new Set(migrated)];
4741      if (changed || deduped.length !== completions.length) {
4742        saveCompletions(deduped);
4743        return deduped;
4744      }
4745      return completions;
4746    }
4747
4748    function updateButtonState(btn, completions) {
4749      const id = btn.dataset.completionId;
4750      const list = completions ?? getCompletions();
4751      btn.classList.toggle("completed", isCompleted(id, list));
4752    }
4753
4754    function checkModuleCompletion(completions) {
4755      const list = completions ?? getCompletions();
4756      document.querySelectorAll(".course-video").forEach((module) => {
4757        const btns = module.querySelectorAll(".complete-btn");
4758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
4759        const check = module.querySelector(".module-check");
4760        if (check) check.classList.toggle("visible", allCompleted);
4761      });
4762    }
4763
4764    function initCompletionButtons() {
4765      const completions = migrateCompletions();
4766
4767      document.querySelectorAll(".complete-btn").forEach((btn) => {
4768        if (btn.dataset.completionInit) return;
4769        btn.dataset.completionInit = "true";
4770        updateButtonState(btn, completions);
4771
4772        btn.addEventListener("click", (e) => {
4773          e.preventDefault();
4774          e.stopPropagation();
4775
4776          const id = btn.dataset.completionId;
4777          let list = getCompletions();
4778          const completed = isCompleted(id, list);
4779
4780          if (completed) {
4781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
4782            list = list.filter((c) =>
4782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
4783          } else {
4784            list.push(id);
4785          }
4786
4787          saveCompletions(list);
4788          updateButtonState(btn, list);
4789          checkModuleCompletion(list);
4790        });
4791      });
4792
4793      checkModuleCompletion(completions);
4794    }
4795
4796    if (document.readyState === "loading") {
4797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
4798    } else {
4799      initCompletionButtons();
4800    }
4801  })();
4802</script>
4802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="9306" data-end="10230" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Bucles</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-bucles" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
4802<script>
4803  (() => {
4804    if (window.__courseLazyPlayersInitialized) return;
4805    window.__courseLazyPlayersInitialized = true;
4806
4807    function createPlayer(details) {
4808      const videoId = details.dataset.videoId;
4809      const start = details.dataset.start;
4810      const end = details.dataset.end;
4811
4812      const container = document.createElement("div");
4813      container.className = "player-container";
4814
4815      const spinner = document.createElement("div");
4816      spinner.className = "spinner";
4817      container.appendChild(spinner);
4818
4819      const controller = document.createElement("media-controller");
4820      controller.setAttribute("defaultsubtitles", "");
4821
4822      const provider = document.createElement("youtube-media-provider");
4823      provider.setAttribute("slot", "media");
4824      provider.setAttribute("video-id", videoId);
4825      provider.setAttribute("start", start);
4826      provider.setAttribute("end", end);
4827
4828      const controlBar = document.createElement("media-control-bar");
4829
4830      const playBtn = document.createElement("media-play-button");
4831      const timeDisplay = document.createElement("media-time-display");
4832      timeDisplay.setAttribute("showduration", "");
4833      const timeRange = document.createElement("media-time-range");
4834      const playbackRate = document.createElement("media-playback-rate-button");
4835      const muteBtn = document.createElement("media-mute-button");
4836      const volumeRange = document.createElement("media-volume-range");
4837
4838      const captionsBtn = document.createElement("media-captions-button");
4839      captionsBtn.addEventListener("click", (e) => {
4840        e.stopPropagation();
4841        const provider = container.querySelector("youtube-media-provider");
4842        if (provider) provider.toggleCaptions();
4843      });
4844
4845      const qualityBtn = document.createElement("media-chrome-button");
4846      qualityBtn.textContent = "--";
4847      qualityBtn.addEventListener("click", () => {
4848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
4849      });
4850
4851      const fullscreenBtn = document.createElement("media-fullscreen-button");
4852
4853      controlBar.append(
4854        playBtn, timeDisplay, timeRange,
4855        playbackRate, muteBtn, volumeRange,
4856        captionsBtn, qualityBtn, fullscreenBtn
4857      );
4858
4859      const poster = document.createElement("media-poster-image");
4860      poster.setAttribute("slot", "poster");
4861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
4862
4863      controller.append(provider, poster, controlBar);
4864
4865      captionsBtn.setAttribute("aria-checked", "true");
4866      controller.setAttribute("mediasubtitlesshowing", "on");
4867
4868      container.appendChild(controller);
4869      details.appendChild(container);
4870
4871      provider.addEventListener("waiting", () => container.classList.add("loading"));
4872      provider.addEventListener("playing", () => container.classList.remove("loading"));
4873      provider.addEventListener("play", () => container.classList.remove("loading"));
4874
4875      provider._textTracks.addEventListener("change", () => {
4876        const enabled = provider._captionsEnabled;
4877        captionsBtn.setAttribute("aria-checked", enabled);
4878        if (enabled) {
4879          controller.setAttribute("mediasubtitlesshowing", "on");
4880        } else {
4881          controller.removeAttribute("mediasubtitlesshowing");
4882        }
4883      });
4884
4885      provider.addEventListener("qualitychange", (e) => {
4886        qualityBtn.textContent = e.detail.quality;
4887      });
4888    }
4889
4890    function initLazyPlayers() {
4891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
4892        if (details.dataset.lazyInit) return;
4893        details.dataset.lazyInit = "true";
4894        details.addEventListener("toggle", () => {
4895          if (details.open && !details.querySelector(".player-container")) {
4896            createPlayer(details);
4897          } else if (!details.open) {
4898            const provider = details.querySelector("youtube-media-provider");
4899            if (provider && typeof provider.pause === "function") {
4900              provider.pause();
4901            }
4902          }
4903        });
4904      });
4905    }
4906
4907    if (document.readyState === "loading") {
4908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
4909    } else {
4910      initLazyPlayers();
4911    }
4912  })();
4913</script>
4913<script>
4914  (() => {
4915    if (window.__courseCompletionsInitialized) return;
4916    window.__courseCompletionsInitialized = true;
4917
4918    const STORAGE_KEY = "course-completions";
4919
4920    function slugify(text) {
4921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4922    }
4923
4924    function getCompletions() {
4925      try {
4926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
4927      } catch {
4928        return [];
4929      }
4930    }
4931
4932    function saveCompletions(completions) {
4933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
4934    }
4935
4936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
4937    function canonicalFromStored(storedId) {
4938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4939      if (!m) return slugify(storedId);
4940      const [, slug, p, mv, title] = m;
4941      return `${slug}-${p}-${mv}-${slugify(title)}`;
4942    }
4943
4944    function canonicalPlusOne(storedId) {
4945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
4946      if (!m) return null;
4947      const [, slug, p, mv, title] = m;
4948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
4949    }
4950
4951    function isCompleted(canonicalId, completions) {
4952      if (completions.includes(canonicalId)) return true;
4953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
4954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
4955    }
4956
4957    function migrateCompletions() {
4958      const completions = getCompletions();
4959      let changed = false;
4960      const migrated = completions.map((c) => {
4961        const canon = canonicalFromStored(c);
4962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
4963        const plusOne = canonicalPlusOne(c);
4964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
4965          if (plusOne !== c) changed = true;
4966          return plusOne;
4967        }
4968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
4969          changed = true;
4970          return canon;
4971        }
4972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
4973        if (canon !== c) {
4974          changed = true;
4975          return canon;
4976        }
4977        return c;
4978      });
4979      // Elimina duplicados tras migración
4980      const deduped = [...new Set(migrated)];
4981      if (changed || deduped.length !== completions.length) {
4982        saveCompletions(deduped);
4983        return deduped;
4984      }
4985      return completions;
4986    }
4987
4988    function updateButtonState(btn, completions) {
4989      const id = btn.dataset.completionId;
4990      const list = completions ?? getCompletions();
4991      btn.classList.toggle("completed", isCompleted(id, list));
4992    }
4993
4994    function checkModuleCompletion(completions) {
4995      const list = completions ?? getCompletions();
4996      document.querySelectorAll(".course-video").forEach((module) => {
4997        const btns = module.querySelectorAll(".complete-btn");
4998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
4999        const check = module.querySelector(".module-check");
5000        if (check) check.classList.toggle("visible", allCompleted);
5001      });
5002    }
5003
5004    function initCompletionButtons() {
5005      const completions = migrateCompletions();
5006
5007      document.querySelectorAll(".complete-btn").forEach((btn) => {
5008        if (btn.dataset.completionInit) return;
5009        btn.dataset.completionInit = "true";
5010        updateButtonState(btn, completions);
5011
5012        btn.addEventListener("click", (e) => {
5013          e.preventDefault();
5014          e.stopPropagation();
5015
5016          const id = btn.dataset.completionId;
5017          let list = getCompletions();
5018          const completed = isCompleted(id, list);
5019
5020          if (completed) {
5021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
5022            list = list.filter((c) =>
5022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
5023          } else {
5024            list.push(id);
5025          }
5026
5027          saveCompletions(list);
5028          updateButtonState(btn, list);
5029          checkModuleCompletion(list);
5030        });
5031      });
5032
5033      checkModuleCompletion(completions);
5034    }
5035
5036    if (document.readyState === "loading") {
5037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
5038    } else {
5039      initCompletionButtons();
5040    }
5041  })();
5042</script>
5042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="10230" data-end="12264" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Funciones</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>34 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-funciones" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
5042<script>
5043  (() => {
5044    if (window.__courseLazyPlayersInitialized) return;
5045    window.__courseLazyPlayersInitialized = true;
5046
5047    function createPlayer(details) {
5048      const videoId = details.dataset.videoId;
5049      const start = details.dataset.start;
5050      const end = details.dataset.end;
5051
5052      const container = document.createElement("div");
5053      container.className = "player-container";
5054
5055      const spinner = document.createElement("div");
5056      spinner.className = "spinner";
5057      container.appendChild(spinner);
5058
5059      const controller = document.createElement("media-controller");
5060      controller.setAttribute("defaultsubtitles", "");
5061
5062      const provider = document.createElement("youtube-media-provider");
5063      provider.setAttribute("slot", "media");
5064      provider.setAttribute("video-id", videoId);
5065      provider.setAttribute("start", start);
5066      provider.setAttribute("end", end);
5067
5068      const controlBar = document.createElement("media-control-bar");
5069
5070      const playBtn = document.createElement("media-play-button");
5071      const timeDisplay = document.createElement("media-time-display");
5072      timeDisplay.setAttribute("showduration", "");
5073      const timeRange = document.createElement("media-time-range");
5074      const playbackRate = document.createElement("media-playback-rate-button");
5075      const muteBtn = document.createElement("media-mute-button");
5076      const volumeRange = document.createElement("media-volume-range");
5077
5078      const captionsBtn = document.createElement("media-captions-button");
5079      captionsBtn.addEventListener("click", (e) => {
5080        e.stopPropagation();
5081        const provider = container.querySelector("youtube-media-provider");
5082        if (provider) provider.toggleCaptions();
5083      });
5084
5085      const qualityBtn = document.createElement("media-chrome-button");
5086      qualityBtn.textContent = "--";
5087      qualityBtn.addEventListener("click", () => {
5088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
5089      });
5090
5091      const fullscreenBtn = document.createElement("media-fullscreen-button");
5092
5093      controlBar.append(
5094        playBtn, timeDisplay, timeRange,
5095        playbackRate, muteBtn, volumeRange,
5096        captionsBtn, qualityBtn, fullscreenBtn
5097      );
5098
5099      const poster = document.createElement("media-poster-image");
5100      poster.setAttribute("slot", "poster");
5101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
5102
5103      controller.append(provider, poster, controlBar);
5104
5105      captionsBtn.setAttribute("aria-checked", "true");
5106      controller.setAttribute("mediasubtitlesshowing", "on");
5107
5108      container.appendChild(controller);
5109      details.appendChild(container);
5110
5111      provider.addEventListener("waiting", () => container.classList.add("loading"));
5112      provider.addEventListener("playing", () => container.classList.remove("loading"));
5113      provider.addEventListener("play", () => container.classList.remove("loading"));
5114
5115      provider._textTracks.addEventListener("change", () => {
5116        const enabled = provider._captionsEnabled;
5117        captionsBtn.setAttribute("aria-checked", enabled);
5118        if (enabled) {
5119          controller.setAttribute("mediasubtitlesshowing", "on");
5120        } else {
5121          controller.removeAttribute("mediasubtitlesshowing");
5122        }
5123      });
5124
5125      provider.addEventListener("qualitychange", (e) => {
5126        qualityBtn.textContent = e.detail.quality;
5127      });
5128    }
5129
5130    function initLazyPlayers() {
5131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
5132        if (details.dataset.lazyInit) return;
5133        details.dataset.lazyInit = "true";
5134        details.addEventListener("toggle", () => {
5135          if (details.open && !details.querySelector(".player-container")) {
5136            createPlayer(details);
5137          } else if (!details.open) {
5138            const provider = details.querySelector("youtube-media-provider");
5139            if (provider && typeof provider.pause === "function") {
5140              provider.pause();
5141            }
5142          }
5143        });
5144      });
5145    }
5146
5147    if (document.readyState === "loading") {
5148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
5149    } else {
5150      initLazyPlayers();
5151    }
5152  })();
5153</script>
5153<script>
5154  (() => {
5155    if (window.__courseCompletionsInitialized) return;
5156    window.__courseCompletionsInitialized = true;
5157
5158    const STORAGE_KEY = "course-completions";
5159
5160    function slugify(text) {
5161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5162    }
5163
5164    function getCompletions() {
5165      try {
5166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
5167      } catch {
5168        return [];
5169      }
5170    }
5171
5172    function saveCompletions(completions) {
5173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
5174    }
5175
5176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
5177    function canonicalFromStored(storedId) {
5178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5179      if (!m) return slugify(storedId);
5180      const [, slug, p, mv, title] = m;
5181      return `${slug}-${p}-${mv}-${slugify(title)}`;
5182    }
5183
5184    function canonicalPlusOne(storedId) {
5185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5186      if (!m) return null;
5187      const [, slug, p, mv, title] = m;
5188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
5189    }
5190
5191    function isCompleted(canonicalId, completions) {
5192      if (completions.includes(canonicalId)) return true;
5193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
5194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
5195    }
5196
5197    function migrateCompletions() {
5198      const completions = getCompletions();
5199      let changed = false;
5200      const migrated = completions.map((c) => {
5201        const canon = canonicalFromStored(c);
5202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
5203        const plusOne = canonicalPlusOne(c);
5204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
5205          if (plusOne !== c) changed = true;
5206          return plusOne;
5207        }
5208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
5209          changed = true;
5210          return canon;
5211        }
5212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
5213        if (canon !== c) {
5214          changed = true;
5215          return canon;
5216        }
5217        return c;
5218      });
5219      // Elimina duplicados tras migración
5220      const deduped = [...new Set(migrated)];
5221      if (changed || deduped.length !== completions.length) {
5222        saveCompletions(deduped);
5223        return deduped;
5224      }
5225      return completions;
5226    }
5227
5228    function updateButtonState(btn, completions) {
5229      const id = btn.dataset.completionId;
5230      const list = completions ?? getCompletions();
5231      btn.classList.toggle("completed", isCompleted(id, list));
5232    }
5233
5234    function checkModuleCompletion(completions) {
5235      const list = completions ?? getCompletions();
5236      document.querySelectorAll(".course-video").forEach((module) => {
5237        const btns = module.querySelectorAll(".complete-btn");
5238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
5239        const check = module.querySelector(".module-check");
5240        if (check) check.classList.toggle("visible", allCompleted);
5241      });
5242    }
5243
5244    function initCompletionButtons() {
5245      const completions = migrateCompletions();
5246
5247      document.querySelectorAll(".complete-btn").forEach((btn) => {
5248        if (btn.dataset.completionInit) return;
5249        btn.dataset.completionInit = "true";
5250        updateButtonState(btn, completions);
5251
5252        btn.addEventListener("click", (e) => {
5253          e.preventDefault();
5254          e.stopPropagation();
5255
5256          const id = btn.dataset.completionId;
5257          let list = getCompletions();
5258          const completed = isCompleted(id, list);
5259
5260          if (completed) {
5261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
5262            list = list.filter((c) =>
5262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
5263          } else {
5264            list.push(id);
5265          }
5266
5267          saveCompletions(list);
5268          updateButtonState(btn, list);
5269          checkModuleCompletion(list);
5270        });
5271      });
5272
5273      checkModuleCompletion(completions);
5274    }
5275
5276    if (document.readyState === "loading") {
5277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
5278    } else {
5279      initCompletionButtons();
5280    }
5281  })();
5282</script>
5282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="12540" data-end="14470" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Bases de las Clases</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>32 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-bases-de-las-clases" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
5282<script>
5283  (() => {
5284    if (window.__courseLazyPlayersInitialized) return;
5285    window.__courseLazyPlayersInitialized = true;
5286
5287    function createPlayer(details) {
5288      const videoId = details.dataset.videoId;
5289      const start = details.dataset.start;
5290      const end = details.dataset.end;
5291
5292      const container = document.createElement("div");
5293      container.className = "player-container";
5294
5295      const spinner = document.createElement("div");
5296      spinner.className = "spinner";
5297      container.appendChild(spinner);
5298
5299      const controller = document.createElement("media-controller");
5300      controller.setAttribute("defaultsubtitles", "");
5301
5302      const provider = document.createElement("youtube-media-provider");
5303      provider.setAttribute("slot", "media");
5304      provider.setAttribute("video-id", videoId);
5305      provider.setAttribute("start", start);
5306      provider.setAttribute("end", end);
5307
5308      const controlBar = document.createElement("media-control-bar");
5309
5310      const playBtn = document.createElement("media-play-button");
5311      const timeDisplay = document.createElement("media-time-display");
5312      timeDisplay.setAttribute("showduration", "");
5313      const timeRange = document.createElement("media-time-range");
5314      const playbackRate = document.createElement("media-playback-rate-button");
5315      const muteBtn = document.createElement("media-mute-button");
5316      const volumeRange = document.createElement("media-volume-range");
5317
5318      const captionsBtn = document.createElement("media-captions-button");
5319      captionsBtn.addEventListener("click", (e) => {
5320        e.stopPropagation();
5321        const provider = container.querySelector("youtube-media-provider");
5322        if (provider) provider.toggleCaptions();
5323      });
5324
5325      const qualityBtn = document.createElement("media-chrome-button");
5326      qualityBtn.textContent = "--";
5327      qualityBtn.addEventListener("click", () => {
5328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
5329      });
5330
5331      const fullscreenBtn = document.createElement("media-fullscreen-button");
5332
5333      controlBar.append(
5334        playBtn, timeDisplay, timeRange,
5335        playbackRate, muteBtn, volumeRange,
5336        captionsBtn, qualityBtn, fullscreenBtn
5337      );
5338
5339      const poster = document.createElement("media-poster-image");
5340      poster.setAttribute("slot", "poster");
5341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
5342
5343      controller.append(provider, poster, controlBar);
5344
5345      captionsBtn.setAttribute("aria-checked", "true");
5346      controller.setAttribute("mediasubtitlesshowing", "on");
5347
5348      container.appendChild(controller);
5349      details.appendChild(container);
5350
5351      provider.addEventListener("waiting", () => container.classList.add("loading"));
5352      provider.addEventListener("playing", () => container.classList.remove("loading"));
5353      provider.addEventListener("play", () => container.classList.remove("loading"));
5354
5355      provider._textTracks.addEventListener("change", () => {
5356        const enabled = provider._captionsEnabled;
5357        captionsBtn.setAttribute("aria-checked", enabled);
5358        if (enabled) {
5359          controller.setAttribute("mediasubtitlesshowing", "on");
5360        } else {
5361          controller.removeAttribute("mediasubtitlesshowing");
5362        }
5363      });
5364
5365      provider.addEventListener("qualitychange", (e) => {
5366        qualityBtn.textContent = e.detail.quality;
5367      });
5368    }
5369
5370    function initLazyPlayers() {
5371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
5372        if (details.dataset.lazyInit) return;
5373        details.dataset.lazyInit = "true";
5374        details.addEventListener("toggle", () => {
5375          if (details.open && !details.querySelector(".player-container")) {
5376            createPlayer(details);
5377          } else if (!details.open) {
5378            const provider = details.querySelector("youtube-media-provider");
5379            if (provider && typeof provider.pause === "function") {
5380              provider.pause();
5381            }
5382          }
5383        });
5384      });
5385    }
5386
5387    if (document.readyState === "loading") {
5388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
5389    } else {
5390      initLazyPlayers();
5391    }
5392  })();
5393</script>
5393<script>
5394  (() => {
5395    if (window.__courseCompletionsInitialized) return;
5396    window.__courseCompletionsInitialized = true;
5397
5398    const STORAGE_KEY = "course-completions";
5399
5400    function slugify(text) {
5401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5402    }
5403
5404    function getCompletions() {
5405      try {
5406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
5407      } catch {
5408        return [];
5409      }
5410    }
5411
5412    function saveCompletions(completions) {
5413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
5414    }
5415
5416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
5417    function canonicalFromStored(storedId) {
5418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5419      if (!m) return slugify(storedId);
5420      const [, slug, p, mv, title] = m;
5421      return `${slug}-${p}-${mv}-${slugify(title)}`;
5422    }
5423
5424    function canonicalPlusOne(storedId) {
5425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5426      if (!m) return null;
5427      const [, slug, p, mv, title] = m;
5428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
5429    }
5430
5431    function isCompleted(canonicalId, completions) {
5432      if (completions.includes(canonicalId)) return true;
5433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
5434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
5435    }
5436
5437    function migrateCompletions() {
5438      const completions = getCompletions();
5439      let changed = false;
5440      const migrated = completions.map((c) => {
5441        const canon = canonicalFromStored(c);
5442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
5443        const plusOne = canonicalPlusOne(c);
5444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
5445          if (plusOne !== c) changed = true;
5446          return plusOne;
5447        }
5448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
5449          changed = true;
5450          return canon;
5451        }
5452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
5453        if (canon !== c) {
5454          changed = true;
5455          return canon;
5456        }
5457        return c;
5458      });
5459      // Elimina duplicados tras migración
5460      const deduped = [...new Set(migrated)];
5461      if (changed || deduped.length !== completions.length) {
5462        saveCompletions(deduped);
5463        return deduped;
5464      }
5465      return completions;
5466    }
5467
5468    function updateButtonState(btn, completions) {
5469      const id = btn.dataset.completionId;
5470      const list = completions ?? getCompletions();
5471      btn.classList.toggle("completed", isCompleted(id, list));
5472    }
5473
5474    function checkModuleCompletion(completions) {
5475      const list = completions ?? getCompletions();
5476      document.querySelectorAll(".course-video").forEach((module) => {
5477        const btns = module.querySelectorAll(".complete-btn");
5478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
5479        const check = module.querySelector(".module-check");
5480        if (check) check.classList.toggle("visible", allCompleted);
5481      });
5482    }
5483
5484    function initCompletionButtons() {
5485      const completions = migrateCompletions();
5486
5487      document.querySelectorAll(".complete-btn").forEach((btn) => {
5488        if (btn.dataset.completionInit) return;
5489        btn.dataset.completionInit = "true";
5490        updateButtonState(btn, completions);
5491
5492        btn.addEventListener("click", (e) => {
5493          e.preventDefault();
5494          e.stopPropagation();
5495
5496          const id = btn.dataset.completionId;
5497          let list = getCompletions();
5498          const completed = isCompleted(id, list);
5499
5500          if (completed) {
5501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
5502            list = list.filter((c) =>
5502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
5503          } else {
5504            list.push(id);
5505          }
5506
5507          saveCompletions(list);
5508          updateButtonState(btn, list);
5509          checkModuleCompletion(list);
5510        });
5511      });
5512
5513      checkModuleCompletion(completions);
5514    }
5515
5516    if (document.readyState === "loading") {
5517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
5518    } else {
5519      initCompletionButtons();
5520    }
5521  })();
5522</script>
5522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="14470" data-end="15880" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Visibilidad y miembros de Clases</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-visibilidad-y-miembros-de-clases" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
5522<script>
5523  (() => {
5524    if (window.__courseLazyPlayersInitialized) return;
5525    window.__courseLazyPlayersInitialized = true;
5526
5527    function createPlayer(details) {
5528      const videoId = details.dataset.videoId;
5529      const start = details.dataset.start;
5530      const end = details.dataset.end;
5531
5532      const container = document.createElement("div");
5533      container.className = "player-container";
5534
5535      const spinner = document.createElement("div");
5536      spinner.className = "spinner";
5537      container.appendChild(spinner);
5538
5539      const controller = document.createElement("media-controller");
5540      controller.setAttribute("defaultsubtitles", "");
5541
5542      const provider = document.createElement("youtube-media-provider");
5543      provider.setAttribute("slot", "media");
5544      provider.setAttribute("video-id", videoId);
5545      provider.setAttribute("start", start);
5546      provider.setAttribute("end", end);
5547
5548      const controlBar = document.createElement("media-control-bar");
5549
5550      const playBtn = document.createElement("media-play-button");
5551      const timeDisplay = document.createElement("media-time-display");
5552      timeDisplay.setAttribute("showduration", "");
5553      const timeRange = document.createElement("media-time-range");
5554      const playbackRate = document.createElement("media-playback-rate-button");
5555      const muteBtn = document.createElement("media-mute-button");
5556      const volumeRange = document.createElement("media-volume-range");
5557
5558      const captionsBtn = document.createElement("media-captions-button");
5559      captionsBtn.addEventListener("click", (e) => {
5560        e.stopPropagation();
5561        const provider = container.querySelector("youtube-media-provider");
5562        if (provider) provider.toggleCaptions();
5563      });
5564
5565      const qualityBtn = document.createElement("media-chrome-button");
5566      qualityBtn.textContent = "--";
5567      qualityBtn.addEventListener("click", () => {
5568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
5569      });
5570
5571      const fullscreenBtn = document.createElement("media-fullscreen-button");
5572
5573      controlBar.append(
5574        playBtn, timeDisplay, timeRange,
5575        playbackRate, muteBtn, volumeRange,
5576        captionsBtn, qualityBtn, fullscreenBtn
5577      );
5578
5579      const poster = document.createElement("media-poster-image");
5580      poster.setAttribute("slot", "poster");
5581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
5582
5583      controller.append(provider, poster, controlBar);
5584
5585      captionsBtn.setAttribute("aria-checked", "true");
5586      controller.setAttribute("mediasubtitlesshowing", "on");
5587
5588      container.appendChild(controller);
5589      details.appendChild(container);
5590
5591      provider.addEventListener("waiting", () => container.classList.add("loading"));
5592      provider.addEventListener("playing", () => container.classList.remove("loading"));
5593      provider.addEventListener("play", () => container.classList.remove("loading"));
5594
5595      provider._textTracks.addEventListener("change", () => {
5596        const enabled = provider._captionsEnabled;
5597        captionsBtn.setAttribute("aria-checked", enabled);
5598        if (enabled) {
5599          controller.setAttribute("mediasubtitlesshowing", "on");
5600        } else {
5601          controller.removeAttribute("mediasubtitlesshowing");
5602        }
5603      });
5604
5605      provider.addEventListener("qualitychange", (e) => {
5606        qualityBtn.textContent = e.detail.quality;
5607      });
5608    }
5609
5610    function initLazyPlayers() {
5611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
5612        if (details.dataset.lazyInit) return;
5613        details.dataset.lazyInit = "true";
5614        details.addEventListener("toggle", () => {
5615          if (details.open && !details.querySelector(".player-container")) {
5616            createPlayer(details);
5617          } else if (!details.open) {
5618            const provider = details.querySelector("youtube-media-provider");
5619            if (provider && typeof provider.pause === "function") {
5620              provider.pause();
5621            }
5622          }
5623        });
5624      });
5625    }
5626
5627    if (document.readyState === "loading") {
5628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
5629    } else {
5630      initLazyPlayers();
5631    }
5632  })();
5633</script>
5633<script>
5634  (() => {
5635    if (window.__courseCompletionsInitialized) return;
5636    window.__courseCompletionsInitialized = true;
5637
5638    const STORAGE_KEY = "course-completions";
5639
5640    function slugify(text) {
5641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5642    }
5643
5644    function getCompletions() {
5645      try {
5646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
5647      } catch {
5648        return [];
5649      }
5650    }
5651
5652    function saveCompletions(completions) {
5653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
5654    }
5655
5656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
5657    function canonicalFromStored(storedId) {
5658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5659      if (!m) return slugify(storedId);
5660      const [, slug, p, mv, title] = m;
5661      return `${slug}-${p}-${mv}-${slugify(title)}`;
5662    }
5663
5664    function canonicalPlusOne(storedId) {
5665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5666      if (!m) return null;
5667      const [, slug, p, mv, title] = m;
5668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
5669    }
5670
5671    function isCompleted(canonicalId, completions) {
5672      if (completions.includes(canonicalId)) return true;
5673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
5674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
5675    }
5676
5677    function migrateCompletions() {
5678      const completions = getCompletions();
5679      let changed = false;
5680      const migrated = completions.map((c) => {
5681        const canon = canonicalFromStored(c);
5682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
5683        const plusOne = canonicalPlusOne(c);
5684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
5685          if (plusOne !== c) changed = true;
5686          return plusOne;
5687        }
5688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
5689          changed = true;
5690          return canon;
5691        }
5692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
5693        if (canon !== c) {
5694          changed = true;
5695          return canon;
5696        }
5697        return c;
5698      });
5699      // Elimina duplicados tras migración
5700      const deduped = [...new Set(migrated)];
5701      if (changed || deduped.length !== completions.length) {
5702        saveCompletions(deduped);
5703        return deduped;
5704      }
5705      return completions;
5706    }
5707
5708    function updateButtonState(btn, completions) {
5709      const id = btn.dataset.completionId;
5710      const list = completions ?? getCompletions();
5711      btn.classList.toggle("completed", isCompleted(id, list));
5712    }
5713
5714    function checkModuleCompletion(completions) {
5715      const list = completions ?? getCompletions();
5716      document.querySelectorAll(".course-video").forEach((module) => {
5717        const btns = module.querySelectorAll(".complete-btn");
5718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
5719        const check = module.querySelector(".module-check");
5720        if (check) check.classList.toggle("visible", allCompleted);
5721      });
5722    }
5723
5724    function initCompletionButtons() {
5725      const completions = migrateCompletions();
5726
5727      document.querySelectorAll(".complete-btn").forEach((btn) => {
5728        if (btn.dataset.completionInit) return;
5729        btn.dataset.completionInit = "true";
5730        updateButtonState(btn, completions);
5731
5732        btn.addEventListener("click", (e) => {
5733          e.preventDefault();
5734          e.stopPropagation();
5735
5736          const id = btn.dataset.completionId;
5737          let list = getCompletions();
5738          const completed = isCompleted(id, list);
5739
5740          if (completed) {
5741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
5742            list = list.filter((c) =>
5742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
5743          } else {
5744            list.push(id);
5745          }
5746
5747          saveCompletions(list);
5748          updateButtonState(btn, list);
5749          checkModuleCompletion(list);
5750        });
5751      });
5752
5753      checkModuleCompletion(completions);
5754    }
5755
5756    if (document.readyState === "loading") {
5757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
5758    } else {
5759      initCompletionButtons();
5760    }
5761  })();
5762</script>
5762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XfekLkbqFpQ" data-start="15880" data-end="17172" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Roadmap sugerido y preguntas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>22 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-2-roadmap-sugerido-y-preguntas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
5762<script>
5763  (() => {
5764    if (window.__courseLazyPlayersInitialized) return;
5765    window.__courseLazyPlayersInitialized = true;
5766
5767    function createPlayer(details) {
5768      const videoId = details.dataset.videoId;
5769      const start = details.dataset.start;
5770      const end = details.dataset.end;
5771
5772      const container = document.createElement("div");
5773      container.className = "player-container";
5774
5775      const spinner = document.createElement("div");
5776      spinner.className = "spinner";
5777      container.appendChild(spinner);
5778
5779      const controller = document.createElement("media-controller");
5780      controller.setAttribute("defaultsubtitles", "");
5781
5782      const provider = document.createElement("youtube-media-provider");
5783      provider.setAttribute("slot", "media");
5784      provider.setAttribute("video-id", videoId);
5785      provider.setAttribute("start", start);
5786      provider.setAttribute("end", end);
5787
5788      const controlBar = document.createElement("media-control-bar");
5789
5790      const playBtn = document.createElement("media-play-button");
5791      const timeDisplay = document.createElement("media-time-display");
5792      timeDisplay.setAttribute("showduration", "");
5793      const timeRange = document.createElement("media-time-range");
5794      const playbackRate = document.createElement("media-playback-rate-button");
5795      const muteBtn = document.createElement("media-mute-button");
5796      const volumeRange = document.createElement("media-volume-range");
5797
5798      const captionsBtn = document.createElement("media-captions-button");
5799      captionsBtn.addEventListener("click", (e) => {
5800        e.stopPropagation();
5801        const provider = container.querySelector("youtube-media-provider");
5802        if (provider) provider.toggleCaptions();
5803      });
5804
5805      const qualityBtn = document.createElement("media-chrome-button");
5806      qualityBtn.textContent = "--";
5807      qualityBtn.addEventListener("click", () => {
5808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
5809      });
5810
5811      const fullscreenBtn = document.createElement("media-fullscreen-button");
5812
5813      controlBar.append(
5814        playBtn, timeDisplay, timeRange,
5815        playbackRate, muteBtn, volumeRange,
5816        captionsBtn, qualityBtn, fullscreenBtn
5817      );
5818
5819      const poster = document.createElement("media-poster-image");
5820      poster.setAttribute("slot", "poster");
5821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
5822
5823      controller.append(provider, poster, controlBar);
5824
5825      captionsBtn.setAttribute("aria-checked", "true");
5826      controller.setAttribute("mediasubtitlesshowing", "on");
5827
5828      container.appendChild(controller);
5829      details.appendChild(container);
5830
5831      provider.addEventListener("waiting", () => container.classList.add("loading"));
5832      provider.addEventListener("playing", () => container.classList.remove("loading"));
5833      provider.addEventListener("play", () => container.classList.remove("loading"));
5834
5835      provider._textTracks.addEventListener("change", () => {
5836        const enabled = provider._captionsEnabled;
5837        captionsBtn.setAttribute("aria-checked", enabled);
5838        if (enabled) {
5839          controller.setAttribute("mediasubtitlesshowing", "on");
5840        } else {
5841          controller.removeAttribute("mediasubtitlesshowing");
5842        }
5843      });
5844
5845      provider.addEventListener("qualitychange", (e) => {
5846        qualityBtn.textContent = e.detail.quality;
5847      });
5848    }
5849
5850    function initLazyPlayers() {
5851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
5852        if (details.dataset.lazyInit) return;
5853        details.dataset.lazyInit = "true";
5854        details.addEventListener("toggle", () => {
5855          if (details.open && !details.querySelector(".player-container")) {
5856            createPlayer(details);
5857          } else if (!details.open) {
5858            const provider = details.querySelector("youtube-media-provider");
5859            if (provider && typeof provider.pause === "function") {
5860              provider.pause();
5861            }
5862          }
5863        });
5864      });
5865    }
5866
5867    if (document.readyState === "loading") {
5868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
5869    } else {
5870      initLazyPlayers();
5871    }
5872  })();
5873</script>
5873<script>
5874  (() => {
5875    if (window.__courseCompletionsInitialized) return;
5876    window.__courseCompletionsInitialized = true;
5877
5878    const STORAGE_KEY = "course-completions";
5879
5880    function slugify(text) {
5881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5882    }
5883
5884    function getCompletions() {
5885      try {
5886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
5887      } catch {
5888        return [];
5889      }
5890    }
5891
5892    function saveCompletions(completions) {
5893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
5894    }
5895
5896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
5897    function canonicalFromStored(storedId) {
5898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5899      if (!m) return slugify(storedId);
5900      const [, slug, p, mv, title] = m;
5901      return `${slug}-${p}-${mv}-${slugify(title)}`;
5902    }
5903
5904    function canonicalPlusOne(storedId) {
5905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
5906      if (!m) return null;
5907      const [, slug, p, mv, title] = m;
5908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
5909    }
5910
5911    function isCompleted(canonicalId, completions) {
5912      if (completions.includes(canonicalId)) return true;
5913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
5914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
5915    }
5916
5917    function migrateCompletions() {
5918      const completions = getCompletions();
5919      let changed = false;
5920      const migrated = completions.map((c) => {
5921        const canon = canonicalFromStored(c);
5922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
5923        const plusOne = canonicalPlusOne(c);
5924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
5925          if (plusOne !== c) changed = true;
5926          return plusOne;
5927        }
5928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
5929          changed = true;
5930          return canon;
5931        }
5932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
5933        if (canon !== c) {
5934          changed = true;
5935          return canon;
5936        }
5937        return c;
5938      });
5939      // Elimina duplicados tras migración
5940      const deduped = [...new Set(migrated)];
5941      if (changed || deduped.length !== completions.length) {
5942        saveCompletions(deduped);
5943        return deduped;
5944      }
5945      return completions;
5946    }
5947
5948    function updateButtonState(btn, completions) {
5949      const id = btn.dataset.completionId;
5950      const list = completions ?? getCompletions();
5951      btn.classList.toggle("completed", isCompleted(id, list));
5952    }
5953
5954    function checkModuleCompletion(completions) {
5955      const list = completions ?? getCompletions();
5956      document.querySelectorAll(".course-video").forEach((module) => {
5957        const btns = module.querySelectorAll(".complete-btn");
5958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
5959        const check = module.querySelector(".module-check");
5960        if (check) check.classList.toggle("visible", allCompleted);
5961      });
5962    }
5963
5964    function initCompletionButtons() {
5965      const completions = migrateCompletions();
5966
5967      document.querySelectorAll(".complete-btn").forEach((btn) => {
5968        if (btn.dataset.completionInit) return;
5969        btn.dataset.completionInit = "true";
5970        updateButtonState(btn, completions);
5971
5972        btn.addEventListener("click", (e) => {
5973          e.preventDefault();
5974          e.stopPropagation();
5975
5976          const id = btn.dataset.completionId;
5977          let list = getCompletions();
5978          const completed = isCompleted(id, list);
5979
5980          if (completed) {
5981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
5982            list = list.filter((c) =>
5982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
5983          } else {
5984            list.push(id);
5985          }
5986
5987          saveCompletions(list);
5988          updateButtonState(btn, list);
5989          checkModuleCompletion(list);
5990        });
5991      });
5992
5993      checkModuleCompletion(completions);
5994    }
5995
5996    if (document.readyState === "loading") {
5997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
5998    } else {
5999      initCompletionButtons();
6000    }
6001  })();
6002</script>
6002</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 3 📕 Control de proyectos Javascript<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="690" data-end="1650" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Presentación inicial</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-presentacion-inicial" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
6002<script>
6003  (() => {
6004    if (window.__courseLazyPlayersInitialized) return;
6005    window.__courseLazyPlayersInitialized = true;
6006
6007    function createPlayer(details) {
6008      const videoId = details.dataset.videoId;
6009      const start = details.dataset.start;
6010      const end = details.dataset.end;
6011
6012      const container = document.createElement("div");
6013      container.className = "player-container";
6014
6015      const spinner = document.createElement("div");
6016      spinner.className = "spinner";
6017      container.appendChild(spinner);
6018
6019      const controller = document.createElement("media-controller");
6020      controller.setAttribute("defaultsubtitles", "");
6021
6022      const provider = document.createElement("youtube-media-provider");
6023      provider.setAttribute("slot", "media");
6024      provider.setAttribute("video-id", videoId);
6025      provider.setAttribute("start", start);
6026      provider.setAttribute("end", end);
6027
6028      const controlBar = document.createElement("media-control-bar");
6029
6030      const playBtn = document.createElement("media-play-button");
6031      const timeDisplay = document.createElement("media-time-display");
6032      timeDisplay.setAttribute("showduration", "");
6033      const timeRange = document.createElement("media-time-range");
6034      const playbackRate = document.createElement("media-playback-rate-button");
6035      const muteBtn = document.createElement("media-mute-button");
6036      const volumeRange = document.createElement("media-volume-range");
6037
6038      const captionsBtn = document.createElement("media-captions-button");
6039      captionsBtn.addEventListener("click", (e) => {
6040        e.stopPropagation();
6041        const provider = container.querySelector("youtube-media-provider");
6042        if (provider) provider.toggleCaptions();
6043      });
6044
6045      const qualityBtn = document.createElement("media-chrome-button");
6046      qualityBtn.textContent = "--";
6047      qualityBtn.addEventListener("click", () => {
6048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
6049      });
6050
6051      const fullscreenBtn = document.createElement("media-fullscreen-button");
6052
6053      controlBar.append(
6054        playBtn, timeDisplay, timeRange,
6055        playbackRate, muteBtn, volumeRange,
6056        captionsBtn, qualityBtn, fullscreenBtn
6057      );
6058
6059      const poster = document.createElement("media-poster-image");
6060      poster.setAttribute("slot", "poster");
6061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
6062
6063      controller.append(provider, poster, controlBar);
6064
6065      captionsBtn.setAttribute("aria-checked", "true");
6066      controller.setAttribute("mediasubtitlesshowing", "on");
6067
6068      container.appendChild(controller);
6069      details.appendChild(container);
6070
6071      provider.addEventListener("waiting", () => container.classList.add("loading"));
6072      provider.addEventListener("playing", () => container.classList.remove("loading"));
6073      provider.addEventListener("play", () => container.classList.remove("loading"));
6074
6075      provider._textTracks.addEventListener("change", () => {
6076        const enabled = provider._captionsEnabled;
6077        captionsBtn.setAttribute("aria-checked", enabled);
6078        if (enabled) {
6079          controller.setAttribute("mediasubtitlesshowing", "on");
6080        } else {
6081          controller.removeAttribute("mediasubtitlesshowing");
6082        }
6083      });
6084
6085      provider.addEventListener("qualitychange", (e) => {
6086        qualityBtn.textContent = e.detail.quality;
6087      });
6088    }
6089
6090    function initLazyPlayers() {
6091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
6092        if (details.dataset.lazyInit) return;
6093        details.dataset.lazyInit = "true";
6094        details.addEventListener("toggle", () => {
6095          if (details.open && !details.querySelector(".player-container")) {
6096            createPlayer(details);
6097          } else if (!details.open) {
6098            const provider = details.querySelector("youtube-media-provider");
6099            if (provider && typeof provider.pause === "function") {
6100              provider.pause();
6101            }
6102          }
6103        });
6104      });
6105    }
6106
6107    if (document.readyState === "loading") {
6108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
6109    } else {
6110      initLazyPlayers();
6111    }
6112  })();
6113</script>
6113<script>
6114  (() => {
6115    if (window.__courseCompletionsInitialized) return;
6116    window.__courseCompletionsInitialized = true;
6117
6118    const STORAGE_KEY = "course-completions";
6119
6120    function slugify(text) {
6121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6122    }
6123
6124    function getCompletions() {
6125      try {
6126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
6127      } catch {
6128        return [];
6129      }
6130    }
6131
6132    function saveCompletions(completions) {
6133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
6134    }
6135
6136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
6137    function canonicalFromStored(storedId) {
6138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6139      if (!m) return slugify(storedId);
6140      const [, slug, p, mv, title] = m;
6141      return `${slug}-${p}-${mv}-${slugify(title)}`;
6142    }
6143
6144    function canonicalPlusOne(storedId) {
6145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6146      if (!m) return null;
6147      const [, slug, p, mv, title] = m;
6148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
6149    }
6150
6151    function isCompleted(canonicalId, completions) {
6152      if (completions.includes(canonicalId)) return true;
6153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
6154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
6155    }
6156
6157    function migrateCompletions() {
6158      const completions = getCompletions();
6159      let changed = false;
6160      const migrated = completions.map((c) => {
6161        const canon = canonicalFromStored(c);
6162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
6163        const plusOne = canonicalPlusOne(c);
6164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
6165          if (plusOne !== c) changed = true;
6166          return plusOne;
6167        }
6168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
6169          changed = true;
6170          return canon;
6171        }
6172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
6173        if (canon !== c) {
6174          changed = true;
6175          return canon;
6176        }
6177        return c;
6178      });
6179      // Elimina duplicados tras migración
6180      const deduped = [...new Set(migrated)];
6181      if (changed || deduped.length !== completions.length) {
6182        saveCompletions(deduped);
6183        return deduped;
6184      }
6185      return completions;
6186    }
6187
6188    function updateButtonState(btn, completions) {
6189      const id = btn.dataset.completionId;
6190      const list = completions ?? getCompletions();
6191      btn.classList.toggle("completed", isCompleted(id, list));
6192    }
6193
6194    function checkModuleCompletion(completions) {
6195      const list = completions ?? getCompletions();
6196      document.querySelectorAll(".course-video").forEach((module) => {
6197        const btns = module.querySelectorAll(".complete-btn");
6198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
6199        const check = module.querySelector(".module-check");
6200        if (check) check.classList.toggle("visible", allCompleted);
6201      });
6202    }
6203
6204    function initCompletionButtons() {
6205      const completions = migrateCompletions();
6206
6207      document.querySelectorAll(".complete-btn").forEach((btn) => {
6208        if (btn.dataset.completionInit) return;
6209        btn.dataset.completionInit = "true";
6210        updateButtonState(btn, completions);
6211
6212        btn.addEventListener("click", (e) => {
6213          e.preventDefault();
6214          e.stopPropagation();
6215
6216          const id = btn.dataset.completionId;
6217          let list = getCompletions();
6218          const completed = isCompleted(id, list);
6219
6220          if (completed) {
6221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
6222            list = list.filter((c) =>
6222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
6223          } else {
6224            list.push(id);
6225          }
6226
6227          saveCompletions(list);
6228          updateButtonState(btn, list);
6229          checkModuleCompletion(list);
6230        });
6231      });
6232
6233      checkModuleCompletion(completions);
6234    }
6235
6236    if (document.readyState === "loading") {
6237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
6238    } else {
6239      initCompletionButtons();
6240    }
6241  })();
6242</script>
6242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="1650" data-end="3775" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Instalación de PNPM y Node</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>35 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-instalacion-de-pnpm-y-node" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
6242<script>
6243  (() => {
6244    if (window.__courseLazyPlayersInitialized) return;
6245    window.__courseLazyPlayersInitialized = true;
6246
6247    function createPlayer(details) {
6248      const videoId = details.dataset.videoId;
6249      const start = details.dataset.start;
6250      const end = details.dataset.end;
6251
6252      const container = document.createElement("div");
6253      container.className = "player-container";
6254
6255      const spinner = document.createElement("div");
6256      spinner.className = "spinner";
6257      container.appendChild(spinner);
6258
6259      const controller = document.createElement("media-controller");
6260      controller.setAttribute("defaultsubtitles", "");
6261
6262      const provider = document.createElement("youtube-media-provider");
6263      provider.setAttribute("slot", "media");
6264      provider.setAttribute("video-id", videoId);
6265      provider.setAttribute("start", start);
6266      provider.setAttribute("end", end);
6267
6268      const controlBar = document.createElement("media-control-bar");
6269
6270      const playBtn = document.createElement("media-play-button");
6271      const timeDisplay = document.createElement("media-time-display");
6272      timeDisplay.setAttribute("showduration", "");
6273      const timeRange = document.createElement("media-time-range");
6274      const playbackRate = document.createElement("media-playback-rate-button");
6275      const muteBtn = document.createElement("media-mute-button");
6276      const volumeRange = document.createElement("media-volume-range");
6277
6278      const captionsBtn = document.createElement("media-captions-button");
6279      captionsBtn.addEventListener("click", (e) => {
6280        e.stopPropagation();
6281        const provider = container.querySelector("youtube-media-provider");
6282        if (provider) provider.toggleCaptions();
6283      });
6284
6285      const qualityBtn = document.createElement("media-chrome-button");
6286      qualityBtn.textContent = "--";
6287      qualityBtn.addEventListener("click", () => {
6288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
6289      });
6290
6291      const fullscreenBtn = document.createElement("media-fullscreen-button");
6292
6293      controlBar.append(
6294        playBtn, timeDisplay, timeRange,
6295        playbackRate, muteBtn, volumeRange,
6296        captionsBtn, qualityBtn, fullscreenBtn
6297      );
6298
6299      const poster = document.createElement("media-poster-image");
6300      poster.setAttribute("slot", "poster");
6301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
6302
6303      controller.append(provider, poster, controlBar);
6304
6305      captionsBtn.setAttribute("aria-checked", "true");
6306      controller.setAttribute("mediasubtitlesshowing", "on");
6307
6308      container.appendChild(controller);
6309      details.appendChild(container);
6310
6311      provider.addEventListener("waiting", () => container.classList.add("loading"));
6312      provider.addEventListener("playing", () => container.classList.remove("loading"));
6313      provider.addEventListener("play", () => container.classList.remove("loading"));
6314
6315      provider._textTracks.addEventListener("change", () => {
6316        const enabled = provider._captionsEnabled;
6317        captionsBtn.setAttribute("aria-checked", enabled);
6318        if (enabled) {
6319          controller.setAttribute("mediasubtitlesshowing", "on");
6320        } else {
6321          controller.removeAttribute("mediasubtitlesshowing");
6322        }
6323      });
6324
6325      provider.addEventListener("qualitychange", (e) => {
6326        qualityBtn.textContent = e.detail.quality;
6327      });
6328    }
6329
6330    function initLazyPlayers() {
6331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
6332        if (details.dataset.lazyInit) return;
6333        details.dataset.lazyInit = "true";
6334        details.addEventListener("toggle", () => {
6335          if (details.open && !details.querySelector(".player-container")) {
6336            createPlayer(details);
6337          } else if (!details.open) {
6338            const provider = details.querySelector("youtube-media-provider");
6339            if (provider && typeof provider.pause === "function") {
6340              provider.pause();
6341            }
6342          }
6343        });
6344      });
6345    }
6346
6347    if (document.readyState === "loading") {
6348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
6349    } else {
6350      initLazyPlayers();
6351    }
6352  })();
6353</script>
6353<script>
6354  (() => {
6355    if (window.__courseCompletionsInitialized) return;
6356    window.__courseCompletionsInitialized = true;
6357
6358    const STORAGE_KEY = "course-completions";
6359
6360    function slugify(text) {
6361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6362    }
6363
6364    function getCompletions() {
6365      try {
6366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
6367      } catch {
6368        return [];
6369      }
6370    }
6371
6372    function saveCompletions(completions) {
6373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
6374    }
6375
6376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
6377    function canonicalFromStored(storedId) {
6378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6379      if (!m) return slugify(storedId);
6380      const [, slug, p, mv, title] = m;
6381      return `${slug}-${p}-${mv}-${slugify(title)}`;
6382    }
6383
6384    function canonicalPlusOne(storedId) {
6385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6386      if (!m) return null;
6387      const [, slug, p, mv, title] = m;
6388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
6389    }
6390
6391    function isCompleted(canonicalId, completions) {
6392      if (completions.includes(canonicalId)) return true;
6393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
6394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
6395    }
6396
6397    function migrateCompletions() {
6398      const completions = getCompletions();
6399      let changed = false;
6400      const migrated = completions.map((c) => {
6401        const canon = canonicalFromStored(c);
6402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
6403        const plusOne = canonicalPlusOne(c);
6404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
6405          if (plusOne !== c) changed = true;
6406          return plusOne;
6407        }
6408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
6409          changed = true;
6410          return canon;
6411        }
6412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
6413        if (canon !== c) {
6414          changed = true;
6415          return canon;
6416        }
6417        return c;
6418      });
6419      // Elimina duplicados tras migración
6420      const deduped = [...new Set(migrated)];
6421      if (changed || deduped.length !== completions.length) {
6422        saveCompletions(deduped);
6423        return deduped;
6424      }
6425      return completions;
6426    }
6427
6428    function updateButtonState(btn, completions) {
6429      const id = btn.dataset.completionId;
6430      const list = completions ?? getCompletions();
6431      btn.classList.toggle("completed", isCompleted(id, list));
6432    }
6433
6434    function checkModuleCompletion(completions) {
6435      const list = completions ?? getCompletions();
6436      document.querySelectorAll(".course-video").forEach((module) => {
6437        const btns = module.querySelectorAll(".complete-btn");
6438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
6439        const check = module.querySelector(".module-check");
6440        if (check) check.classList.toggle("visible", allCompleted);
6441      });
6442    }
6443
6444    function initCompletionButtons() {
6445      const completions = migrateCompletions();
6446
6447      document.querySelectorAll(".complete-btn").forEach((btn) => {
6448        if (btn.dataset.completionInit) return;
6449        btn.dataset.completionInit = "true";
6450        updateButtonState(btn, completions);
6451
6452        btn.addEventListener("click", (e) => {
6453          e.preventDefault();
6454          e.stopPropagation();
6455
6456          const id = btn.dataset.completionId;
6457          let list = getCompletions();
6458          const completed = isCompleted(id, list);
6459
6460          if (completed) {
6461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
6462            list = list.filter((c) =>
6462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
6463          } else {
6464            list.push(id);
6465          }
6466
6467          saveCompletions(list);
6468          updateButtonState(btn, list);
6469          checkModuleCompletion(list);
6470        });
6471      });
6472
6473      checkModuleCompletion(completions);
6474    }
6475
6476    if (document.readyState === "loading") {
6477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
6478    } else {
6479      initCompletionButtons();
6480    }
6481  })();
6482</script>
6482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="3775" data-end="4612" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Rust / Go / Python (Opcional)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-rust-go-python-opcional" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
6482<script>
6483  (() => {
6484    if (window.__courseLazyPlayersInitialized) return;
6485    window.__courseLazyPlayersInitialized = true;
6486
6487    function createPlayer(details) {
6488      const videoId = details.dataset.videoId;
6489      const start = details.dataset.start;
6490      const end = details.dataset.end;
6491
6492      const container = document.createElement("div");
6493      container.className = "player-container";
6494
6495      const spinner = document.createElement("div");
6496      spinner.className = "spinner";
6497      container.appendChild(spinner);
6498
6499      const controller = document.createElement("media-controller");
6500      controller.setAttribute("defaultsubtitles", "");
6501
6502      const provider = document.createElement("youtube-media-provider");
6503      provider.setAttribute("slot", "media");
6504      provider.setAttribute("video-id", videoId);
6505      provider.setAttribute("start", start);
6506      provider.setAttribute("end", end);
6507
6508      const controlBar = document.createElement("media-control-bar");
6509
6510      const playBtn = document.createElement("media-play-button");
6511      const timeDisplay = document.createElement("media-time-display");
6512      timeDisplay.setAttribute("showduration", "");
6513      const timeRange = document.createElement("media-time-range");
6514      const playbackRate = document.createElement("media-playback-rate-button");
6515      const muteBtn = document.createElement("media-mute-button");
6516      const volumeRange = document.createElement("media-volume-range");
6517
6518      const captionsBtn = document.createElement("media-captions-button");
6519      captionsBtn.addEventListener("click", (e) => {
6520        e.stopPropagation();
6521        const provider = container.querySelector("youtube-media-provider");
6522        if (provider) provider.toggleCaptions();
6523      });
6524
6525      const qualityBtn = document.createElement("media-chrome-button");
6526      qualityBtn.textContent = "--";
6527      qualityBtn.addEventListener("click", () => {
6528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
6529      });
6530
6531      const fullscreenBtn = document.createElement("media-fullscreen-button");
6532
6533      controlBar.append(
6534        playBtn, timeDisplay, timeRange,
6535        playbackRate, muteBtn, volumeRange,
6536        captionsBtn, qualityBtn, fullscreenBtn
6537      );
6538
6539      const poster = document.createElement("media-poster-image");
6540      poster.setAttribute("slot", "poster");
6541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
6542
6543      controller.append(provider, poster, controlBar);
6544
6545      captionsBtn.setAttribute("aria-checked", "true");
6546      controller.setAttribute("mediasubtitlesshowing", "on");
6547
6548      container.appendChild(controller);
6549      details.appendChild(container);
6550
6551      provider.addEventListener("waiting", () => container.classList.add("loading"));
6552      provider.addEventListener("playing", () => container.classList.remove("loading"));
6553      provider.addEventListener("play", () => container.classList.remove("loading"));
6554
6555      provider._textTracks.addEventListener("change", () => {
6556        const enabled = provider._captionsEnabled;
6557        captionsBtn.setAttribute("aria-checked", enabled);
6558        if (enabled) {
6559          controller.setAttribute("mediasubtitlesshowing", "on");
6560        } else {
6561          controller.removeAttribute("mediasubtitlesshowing");
6562        }
6563      });
6564
6565      provider.addEventListener("qualitychange", (e) => {
6566        qualityBtn.textContent = e.detail.quality;
6567      });
6568    }
6569
6570    function initLazyPlayers() {
6571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
6572        if (details.dataset.lazyInit) return;
6573        details.dataset.lazyInit = "true";
6574        details.addEventListener("toggle", () => {
6575          if (details.open && !details.querySelector(".player-container")) {
6576            createPlayer(details);
6577          } else if (!details.open) {
6578            const provider = details.querySelector("youtube-media-provider");
6579            if (provider && typeof provider.pause === "function") {
6580              provider.pause();
6581            }
6582          }
6583        });
6584      });
6585    }
6586
6587    if (document.readyState === "loading") {
6588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
6589    } else {
6590      initLazyPlayers();
6591    }
6592  })();
6593</script>
6593<script>
6594  (() => {
6595    if (window.__courseCompletionsInitialized) return;
6596    window.__courseCompletionsInitialized = true;
6597
6598    const STORAGE_KEY = "course-completions";
6599
6600    function slugify(text) {
6601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6602    }
6603
6604    function getCompletions() {
6605      try {
6606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
6607      } catch {
6608        return [];
6609      }
6610    }
6611
6612    function saveCompletions(completions) {
6613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
6614    }
6615
6616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
6617    function canonicalFromStored(storedId) {
6618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6619      if (!m) return slugify(storedId);
6620      const [, slug, p, mv, title] = m;
6621      return `${slug}-${p}-${mv}-${slugify(title)}`;
6622    }
6623
6624    function canonicalPlusOne(storedId) {
6625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6626      if (!m) return null;
6627      const [, slug, p, mv, title] = m;
6628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
6629    }
6630
6631    function isCompleted(canonicalId, completions) {
6632      if (completions.includes(canonicalId)) return true;
6633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
6634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
6635    }
6636
6637    function migrateCompletions() {
6638      const completions = getCompletions();
6639      let changed = false;
6640      const migrated = completions.map((c) => {
6641        const canon = canonicalFromStored(c);
6642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
6643        const plusOne = canonicalPlusOne(c);
6644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
6645          if (plusOne !== c) changed = true;
6646          return plusOne;
6647        }
6648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
6649          changed = true;
6650          return canon;
6651        }
6652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
6653        if (canon !== c) {
6654          changed = true;
6655          return canon;
6656        }
6657        return c;
6658      });
6659      // Elimina duplicados tras migración
6660      const deduped = [...new Set(migrated)];
6661      if (changed || deduped.length !== completions.length) {
6662        saveCompletions(deduped);
6663        return deduped;
6664      }
6665      return completions;
6666    }
6667
6668    function updateButtonState(btn, completions) {
6669      const id = btn.dataset.completionId;
6670      const list = completions ?? getCompletions();
6671      btn.classList.toggle("completed", isCompleted(id, list));
6672    }
6673
6674    function checkModuleCompletion(completions) {
6675      const list = completions ?? getCompletions();
6676      document.querySelectorAll(".course-video").forEach((module) => {
6677        const btns = module.querySelectorAll(".complete-btn");
6678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
6679        const check = module.querySelector(".module-check");
6680        if (check) check.classList.toggle("visible", allCompleted);
6681      });
6682    }
6683
6684    function initCompletionButtons() {
6685      const completions = migrateCompletions();
6686
6687      document.querySelectorAll(".complete-btn").forEach((btn) => {
6688        if (btn.dataset.completionInit) return;
6689        btn.dataset.completionInit = "true";
6690        updateButtonState(btn, completions);
6691
6692        btn.addEventListener("click", (e) => {
6693          e.preventDefault();
6694          e.stopPropagation();
6695
6696          const id = btn.dataset.completionId;
6697          let list = getCompletions();
6698          const completed = isCompleted(id, list);
6699
6700          if (completed) {
6701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
6702            list = list.filter((c) =>
6702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
6703          } else {
6704            list.push(id);
6705          }
6706
6707          saveCompletions(list);
6708          updateButtonState(btn, list);
6709          checkModuleCompletion(list);
6710        });
6711      });
6712
6713      checkModuleCompletion(completions);
6714    }
6715
6716    if (document.readyState === "loading") {
6717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
6718    } else {
6719      initCompletionButtons();
6720    }
6721  })();
6722</script>
6722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="4612" data-end="6400" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Estructura de carpetas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>30 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-estructura-de-carpetas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
6722<script>
6723  (() => {
6724    if (window.__courseLazyPlayersInitialized) return;
6725    window.__courseLazyPlayersInitialized = true;
6726
6727    function createPlayer(details) {
6728      const videoId = details.dataset.videoId;
6729      const start = details.dataset.start;
6730      const end = details.dataset.end;
6731
6732      const container = document.createElement("div");
6733      container.className = "player-container";
6734
6735      const spinner = document.createElement("div");
6736      spinner.className = "spinner";
6737      container.appendChild(spinner);
6738
6739      const controller = document.createElement("media-controller");
6740      controller.setAttribute("defaultsubtitles", "");
6741
6742      const provider = document.createElement("youtube-media-provider");
6743      provider.setAttribute("slot", "media");
6744      provider.setAttribute("video-id", videoId);
6745      provider.setAttribute("start", start);
6746      provider.setAttribute("end", end);
6747
6748      const controlBar = document.createElement("media-control-bar");
6749
6750      const playBtn = document.createElement("media-play-button");
6751      const timeDisplay = document.createElement("media-time-display");
6752      timeDisplay.setAttribute("showduration", "");
6753      const timeRange = document.createElement("media-time-range");
6754      const playbackRate = document.createElement("media-playback-rate-button");
6755      const muteBtn = document.createElement("media-mute-button");
6756      const volumeRange = document.createElement("media-volume-range");
6757
6758      const captionsBtn = document.createElement("media-captions-button");
6759      captionsBtn.addEventListener("click", (e) => {
6760        e.stopPropagation();
6761        const provider = container.querySelector("youtube-media-provider");
6762        if (provider) provider.toggleCaptions();
6763      });
6764
6765      const qualityBtn = document.createElement("media-chrome-button");
6766      qualityBtn.textContent = "--";
6767      qualityBtn.addEventListener("click", () => {
6768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
6769      });
6770
6771      const fullscreenBtn = document.createElement("media-fullscreen-button");
6772
6773      controlBar.append(
6774        playBtn, timeDisplay, timeRange,
6775        playbackRate, muteBtn, volumeRange,
6776        captionsBtn, qualityBtn, fullscreenBtn
6777      );
6778
6779      const poster = document.createElement("media-poster-image");
6780      poster.setAttribute("slot", "poster");
6781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
6782
6783      controller.append(provider, poster, controlBar);
6784
6785      captionsBtn.setAttribute("aria-checked", "true");
6786      controller.setAttribute("mediasubtitlesshowing", "on");
6787
6788      container.appendChild(controller);
6789      details.appendChild(container);
6790
6791      provider.addEventListener("waiting", () => container.classList.add("loading"));
6792      provider.addEventListener("playing", () => container.classList.remove("loading"));
6793      provider.addEventListener("play", () => container.classList.remove("loading"));
6794
6795      provider._textTracks.addEventListener("change", () => {
6796        const enabled = provider._captionsEnabled;
6797        captionsBtn.setAttribute("aria-checked", enabled);
6798        if (enabled) {
6799          controller.setAttribute("mediasubtitlesshowing", "on");
6800        } else {
6801          controller.removeAttribute("mediasubtitlesshowing");
6802        }
6803      });
6804
6805      provider.addEventListener("qualitychange", (e) => {
6806        qualityBtn.textContent = e.detail.quality;
6807      });
6808    }
6809
6810    function initLazyPlayers() {
6811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
6812        if (details.dataset.lazyInit) return;
6813        details.dataset.lazyInit = "true";
6814        details.addEventListener("toggle", () => {
6815          if (details.open && !details.querySelector(".player-container")) {
6816            createPlayer(details);
6817          } else if (!details.open) {
6818            const provider = details.querySelector("youtube-media-provider");
6819            if (provider && typeof provider.pause === "function") {
6820              provider.pause();
6821            }
6822          }
6823        });
6824      });
6825    }
6826
6827    if (document.readyState === "loading") {
6828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
6829    } else {
6830      initLazyPlayers();
6831    }
6832  })();
6833</script>
6833<script>
6834  (() => {
6835    if (window.__courseCompletionsInitialized) return;
6836    window.__courseCompletionsInitialized = true;
6837
6838    const STORAGE_KEY = "course-completions";
6839
6840    function slugify(text) {
6841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6842    }
6843
6844    function getCompletions() {
6845      try {
6846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
6847      } catch {
6848        return [];
6849      }
6850    }
6851
6852    function saveCompletions(completions) {
6853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
6854    }
6855
6856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
6857    function canonicalFromStored(storedId) {
6858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6859      if (!m) return slugify(storedId);
6860      const [, slug, p, mv, title] = m;
6861      return `${slug}-${p}-${mv}-${slugify(title)}`;
6862    }
6863
6864    function canonicalPlusOne(storedId) {
6865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
6866      if (!m) return null;
6867      const [, slug, p, mv, title] = m;
6868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
6869    }
6870
6871    function isCompleted(canonicalId, completions) {
6872      if (completions.includes(canonicalId)) return true;
6873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
6874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
6875    }
6876
6877    function migrateCompletions() {
6878      const completions = getCompletions();
6879      let changed = false;
6880      const migrated = completions.map((c) => {
6881        const canon = canonicalFromStored(c);
6882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
6883        const plusOne = canonicalPlusOne(c);
6884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
6885          if (plusOne !== c) changed = true;
6886          return plusOne;
6887        }
6888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
6889          changed = true;
6890          return canon;
6891        }
6892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
6893        if (canon !== c) {
6894          changed = true;
6895          return canon;
6896        }
6897        return c;
6898      });
6899      // Elimina duplicados tras migración
6900      const deduped = [...new Set(migrated)];
6901      if (changed || deduped.length !== completions.length) {
6902        saveCompletions(deduped);
6903        return deduped;
6904      }
6905      return completions;
6906    }
6907
6908    function updateButtonState(btn, completions) {
6909      const id = btn.dataset.completionId;
6910      const list = completions ?? getCompletions();
6911      btn.classList.toggle("completed", isCompleted(id, list));
6912    }
6913
6914    function checkModuleCompletion(completions) {
6915      const list = completions ?? getCompletions();
6916      document.querySelectorAll(".course-video").forEach((module) => {
6917        const btns = module.querySelectorAll(".complete-btn");
6918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
6919        const check = module.querySelector(".module-check");
6920        if (check) check.classList.toggle("visible", allCompleted);
6921      });
6922    }
6923
6924    function initCompletionButtons() {
6925      const completions = migrateCompletions();
6926
6927      document.querySelectorAll(".complete-btn").forEach((btn) => {
6928        if (btn.dataset.completionInit) return;
6929        btn.dataset.completionInit = "true";
6930        updateButtonState(btn, completions);
6931
6932        btn.addEventListener("click", (e) => {
6933          e.preventDefault();
6934          e.stopPropagation();
6935
6936          const id = btn.dataset.completionId;
6937          let list = getCompletions();
6938          const completed = isCompleted(id, list);
6939
6940          if (completed) {
6941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
6942            list = list.filter((c) =>
6942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
6943          } else {
6944            list.push(id);
6945          }
6946
6947          saveCompletions(list);
6948          updateButtonState(btn, list);
6949          checkModuleCompletion(list);
6950        });
6951      });
6952
6953      checkModuleCompletion(completions);
6954    }
6955
6956    if (document.readyState === "loading") {
6957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
6958    } else {
6959      initCompletionButtons();
6960    }
6961  })();
6962</script>
6962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="6400" data-end="7830" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Servidores locales de desarrollo</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-servidores-locales-de-desarrollo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
6962<script>
6963  (() => {
6964    if (window.__courseLazyPlayersInitialized) return;
6965    window.__courseLazyPlayersInitialized = true;
6966
6967    function createPlayer(details) {
6968      const videoId = details.dataset.videoId;
6969      const start = details.dataset.start;
6970      const end = details.dataset.end;
6971
6972      const container = document.createElement("div");
6973      container.className = "player-container";
6974
6975      const spinner = document.createElement("div");
6976      spinner.className = "spinner";
6977      container.appendChild(spinner);
6978
6979      const controller = document.createElement("media-controller");
6980      controller.setAttribute("defaultsubtitles", "");
6981
6982      const provider = document.createElement("youtube-media-provider");
6983      provider.setAttribute("slot", "media");
6984      provider.setAttribute("video-id", videoId);
6985      provider.setAttribute("start", start);
6986      provider.setAttribute("end", end);
6987
6988      const controlBar = document.createElement("media-control-bar");
6989
6990      const playBtn = document.createElement("media-play-button");
6991      const timeDisplay = document.createElement("media-time-display");
6992      timeDisplay.setAttribute("showduration", "");
6993      const timeRange = document.createElement("media-time-range");
6994      const playbackRate = document.createElement("media-playback-rate-button");
6995      const muteBtn = document.createElement("media-mute-button");
6996      const volumeRange = document.createElement("media-volume-range");
6997
6998      const captionsBtn = document.createElement("media-captions-button");
6999      captionsBtn.addEventListener("click", (e) => {
7000        e.stopPropagation();
7001        const provider = container.querySelector("youtube-media-provider");
7002        if (provider) provider.toggleCaptions();
7003      });
7004
7005      const qualityBtn = document.createElement("media-chrome-button");
7006      qualityBtn.textContent = "--";
7007      qualityBtn.addEventListener("click", () => {
7008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
7009      });
7010
7011      const fullscreenBtn = document.createElement("media-fullscreen-button");
7012
7013      controlBar.append(
7014        playBtn, timeDisplay, timeRange,
7015        playbackRate, muteBtn, volumeRange,
7016        captionsBtn, qualityBtn, fullscreenBtn
7017      );
7018
7019      const poster = document.createElement("media-poster-image");
7020      poster.setAttribute("slot", "poster");
7021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
7022
7023      controller.append(provider, poster, controlBar);
7024
7025      captionsBtn.setAttribute("aria-checked", "true");
7026      controller.setAttribute("mediasubtitlesshowing", "on");
7027
7028      container.appendChild(controller);
7029      details.appendChild(container);
7030
7031      provider.addEventListener("waiting", () => container.classList.add("loading"));
7032      provider.addEventListener("playing", () => container.classList.remove("loading"));
7033      provider.addEventListener("play", () => container.classList.remove("loading"));
7034
7035      provider._textTracks.addEventListener("change", () => {
7036        const enabled = provider._captionsEnabled;
7037        captionsBtn.setAttribute("aria-checked", enabled);
7038        if (enabled) {
7039          controller.setAttribute("mediasubtitlesshowing", "on");
7040        } else {
7041          controller.removeAttribute("mediasubtitlesshowing");
7042        }
7043      });
7044
7045      provider.addEventListener("qualitychange", (e) => {
7046        qualityBtn.textContent = e.detail.quality;
7047      });
7048    }
7049
7050    function initLazyPlayers() {
7051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
7052        if (details.dataset.lazyInit) return;
7053        details.dataset.lazyInit = "true";
7054        details.addEventListener("toggle", () => {
7055          if (details.open && !details.querySelector(".player-container")) {
7056            createPlayer(details);
7057          } else if (!details.open) {
7058            const provider = details.querySelector("youtube-media-provider");
7059            if (provider && typeof provider.pause === "function") {
7060              provider.pause();
7061            }
7062          }
7063        });
7064      });
7065    }
7066
7067    if (document.readyState === "loading") {
7068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
7069    } else {
7070      initLazyPlayers();
7071    }
7072  })();
7073</script>
7073<script>
7074  (() => {
7075    if (window.__courseCompletionsInitialized) return;
7076    window.__courseCompletionsInitialized = true;
7077
7078    const STORAGE_KEY = "course-completions";
7079
7080    function slugify(text) {
7081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7082    }
7083
7084    function getCompletions() {
7085      try {
7086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
7087      } catch {
7088        return [];
7089      }
7090    }
7091
7092    function saveCompletions(completions) {
7093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
7094    }
7095
7096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
7097    function canonicalFromStored(storedId) {
7098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7099      if (!m) return slugify(storedId);
7100      const [, slug, p, mv, title] = m;
7101      return `${slug}-${p}-${mv}-${slugify(title)}`;
7102    }
7103
7104    function canonicalPlusOne(storedId) {
7105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7106      if (!m) return null;
7107      const [, slug, p, mv, title] = m;
7108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
7109    }
7110
7111    function isCompleted(canonicalId, completions) {
7112      if (completions.includes(canonicalId)) return true;
7113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
7114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
7115    }
7116
7117    function migrateCompletions() {
7118      const completions = getCompletions();
7119      let changed = false;
7120      const migrated = completions.map((c) => {
7121        const canon = canonicalFromStored(c);
7122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
7123        const plusOne = canonicalPlusOne(c);
7124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
7125          if (plusOne !== c) changed = true;
7126          return plusOne;
7127        }
7128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
7129          changed = true;
7130          return canon;
7131        }
7132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
7133        if (canon !== c) {
7134          changed = true;
7135          return canon;
7136        }
7137        return c;
7138      });
7139      // Elimina duplicados tras migración
7140      const deduped = [...new Set(migrated)];
7141      if (changed || deduped.length !== completions.length) {
7142        saveCompletions(deduped);
7143        return deduped;
7144      }
7145      return completions;
7146    }
7147
7148    function updateButtonState(btn, completions) {
7149      const id = btn.dataset.completionId;
7150      const list = completions ?? getCompletions();
7151      btn.classList.toggle("completed", isCompleted(id, list));
7152    }
7153
7154    function checkModuleCompletion(completions) {
7155      const list = completions ?? getCompletions();
7156      document.querySelectorAll(".course-video").forEach((module) => {
7157        const btns = module.querySelectorAll(".complete-btn");
7158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
7159        const check = module.querySelector(".module-check");
7160        if (check) check.classList.toggle("visible", allCompleted);
7161      });
7162    }
7163
7164    function initCompletionButtons() {
7165      const completions = migrateCompletions();
7166
7167      document.querySelectorAll(".complete-btn").forEach((btn) => {
7168        if (btn.dataset.completionInit) return;
7169        btn.dataset.completionInit = "true";
7170        updateButtonState(btn, completions);
7171
7172        btn.addEventListener("click", (e) => {
7173          e.preventDefault();
7174          e.stopPropagation();
7175
7176          const id = btn.dataset.completionId;
7177          let list = getCompletions();
7178          const completed = isCompleted(id, list);
7179
7180          if (completed) {
7181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
7182            list = list.filter((c) =>
7182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
7183          } else {
7184            list.push(id);
7185          }
7186
7187          saveCompletions(list);
7188          updateButtonState(btn, list);
7189          checkModuleCompletion(list);
7190        });
7191      });
7192
7193      checkModuleCompletion(completions);
7194    }
7195
7196    if (document.readyState === "loading") {
7197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
7198    } else {
7199      initCompletionButtons();
7200    }
7201  })();
7202</script>
7202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="7830" data-end="8700" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Trabajando con pnpm</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-trabajando-con-pnpm" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
7202<script>
7203  (() => {
7204    if (window.__courseLazyPlayersInitialized) return;
7205    window.__courseLazyPlayersInitialized = true;
7206
7207    function createPlayer(details) {
7208      const videoId = details.dataset.videoId;
7209      const start = details.dataset.start;
7210      const end = details.dataset.end;
7211
7212      const container = document.createElement("div");
7213      container.className = "player-container";
7214
7215      const spinner = document.createElement("div");
7216      spinner.className = "spinner";
7217      container.appendChild(spinner);
7218
7219      const controller = document.createElement("media-controller");
7220      controller.setAttribute("defaultsubtitles", "");
7221
7222      const provider = document.createElement("youtube-media-provider");
7223      provider.setAttribute("slot", "media");
7224      provider.setAttribute("video-id", videoId);
7225      provider.setAttribute("start", start);
7226      provider.setAttribute("end", end);
7227
7228      const controlBar = document.createElement("media-control-bar");
7229
7230      const playBtn = document.createElement("media-play-button");
7231      const timeDisplay = document.createElement("media-time-display");
7232      timeDisplay.setAttribute("showduration", "");
7233      const timeRange = document.createElement("media-time-range");
7234      const playbackRate = document.createElement("media-playback-rate-button");
7235      const muteBtn = document.createElement("media-mute-button");
7236      const volumeRange = document.createElement("media-volume-range");
7237
7238      const captionsBtn = document.createElement("media-captions-button");
7239      captionsBtn.addEventListener("click", (e) => {
7240        e.stopPropagation();
7241        const provider = container.querySelector("youtube-media-provider");
7242        if (provider) provider.toggleCaptions();
7243      });
7244
7245      const qualityBtn = document.createElement("media-chrome-button");
7246      qualityBtn.textContent = "--";
7247      qualityBtn.addEventListener("click", () => {
7248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
7249      });
7250
7251      const fullscreenBtn = document.createElement("media-fullscreen-button");
7252
7253      controlBar.append(
7254        playBtn, timeDisplay, timeRange,
7255        playbackRate, muteBtn, volumeRange,
7256        captionsBtn, qualityBtn, fullscreenBtn
7257      );
7258
7259      const poster = document.createElement("media-poster-image");
7260      poster.setAttribute("slot", "poster");
7261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
7262
7263      controller.append(provider, poster, controlBar);
7264
7265      captionsBtn.setAttribute("aria-checked", "true");
7266      controller.setAttribute("mediasubtitlesshowing", "on");
7267
7268      container.appendChild(controller);
7269      details.appendChild(container);
7270
7271      provider.addEventListener("waiting", () => container.classList.add("loading"));
7272      provider.addEventListener("playing", () => container.classList.remove("loading"));
7273      provider.addEventListener("play", () => container.classList.remove("loading"));
7274
7275      provider._textTracks.addEventListener("change", () => {
7276        const enabled = provider._captionsEnabled;
7277        captionsBtn.setAttribute("aria-checked", enabled);
7278        if (enabled) {
7279          controller.setAttribute("mediasubtitlesshowing", "on");
7280        } else {
7281          controller.removeAttribute("mediasubtitlesshowing");
7282        }
7283      });
7284
7285      provider.addEventListener("qualitychange", (e) => {
7286        qualityBtn.textContent = e.detail.quality;
7287      });
7288    }
7289
7290    function initLazyPlayers() {
7291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
7292        if (details.dataset.lazyInit) return;
7293        details.dataset.lazyInit = "true";
7294        details.addEventListener("toggle", () => {
7295          if (details.open && !details.querySelector(".player-container")) {
7296            createPlayer(details);
7297          } else if (!details.open) {
7298            const provider = details.querySelector("youtube-media-provider");
7299            if (provider && typeof provider.pause === "function") {
7300              provider.pause();
7301            }
7302          }
7303        });
7304      });
7305    }
7306
7307    if (document.readyState === "loading") {
7308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
7309    } else {
7310      initLazyPlayers();
7311    }
7312  })();
7313</script>
7313<script>
7314  (() => {
7315    if (window.__courseCompletionsInitialized) return;
7316    window.__courseCompletionsInitialized = true;
7317
7318    const STORAGE_KEY = "course-completions";
7319
7320    function slugify(text) {
7321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7322    }
7323
7324    function getCompletions() {
7325      try {
7326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
7327      } catch {
7328        return [];
7329      }
7330    }
7331
7332    function saveCompletions(completions) {
7333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
7334    }
7335
7336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
7337    function canonicalFromStored(storedId) {
7338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7339      if (!m) return slugify(storedId);
7340      const [, slug, p, mv, title] = m;
7341      return `${slug}-${p}-${mv}-${slugify(title)}`;
7342    }
7343
7344    function canonicalPlusOne(storedId) {
7345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7346      if (!m) return null;
7347      const [, slug, p, mv, title] = m;
7348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
7349    }
7350
7351    function isCompleted(canonicalId, completions) {
7352      if (completions.includes(canonicalId)) return true;
7353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
7354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
7355    }
7356
7357    function migrateCompletions() {
7358      const completions = getCompletions();
7359      let changed = false;
7360      const migrated = completions.map((c) => {
7361        const canon = canonicalFromStored(c);
7362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
7363        const plusOne = canonicalPlusOne(c);
7364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
7365          if (plusOne !== c) changed = true;
7366          return plusOne;
7367        }
7368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
7369          changed = true;
7370          return canon;
7371        }
7372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
7373        if (canon !== c) {
7374          changed = true;
7375          return canon;
7376        }
7377        return c;
7378      });
7379      // Elimina duplicados tras migración
7380      const deduped = [...new Set(migrated)];
7381      if (changed || deduped.length !== completions.length) {
7382        saveCompletions(deduped);
7383        return deduped;
7384      }
7385      return completions;
7386    }
7387
7388    function updateButtonState(btn, completions) {
7389      const id = btn.dataset.completionId;
7390      const list = completions ?? getCompletions();
7391      btn.classList.toggle("completed", isCompleted(id, list));
7392    }
7393
7394    function checkModuleCompletion(completions) {
7395      const list = completions ?? getCompletions();
7396      document.querySelectorAll(".course-video").forEach((module) => {
7397        const btns = module.querySelectorAll(".complete-btn");
7398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
7399        const check = module.querySelector(".module-check");
7400        if (check) check.classList.toggle("visible", allCompleted);
7401      });
7402    }
7403
7404    function initCompletionButtons() {
7405      const completions = migrateCompletions();
7406
7407      document.querySelectorAll(".complete-btn").forEach((btn) => {
7408        if (btn.dataset.completionInit) return;
7409        btn.dataset.completionInit = "true";
7410        updateButtonState(btn, completions);
7411
7412        btn.addEventListener("click", (e) => {
7413          e.preventDefault();
7414          e.stopPropagation();
7415
7416          const id = btn.dataset.completionId;
7417          let list = getCompletions();
7418          const completed = isCompleted(id, list);
7419
7420          if (completed) {
7421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
7422            list = list.filter((c) =>
7422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
7423          } else {
7424            list.push(id);
7425          }
7426
7427          saveCompletions(list);
7428          updateButtonState(btn, list);
7429          checkModuleCompletion(list);
7430        });
7431      });
7432
7433      checkModuleCompletion(completions);
7434    }
7435
7436    if (document.readyState === "loading") {
7437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
7438    } else {
7439      initCompletionButtons();
7440    }
7441  })();
7442</script>
7442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="8700" data-end="9940" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Dependencias + semver</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-dependencias-semver" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
7442<script>
7443  (() => {
7444    if (window.__courseLazyPlayersInitialized) return;
7445    window.__courseLazyPlayersInitialized = true;
7446
7447    function createPlayer(details) {
7448      const videoId = details.dataset.videoId;
7449      const start = details.dataset.start;
7450      const end = details.dataset.end;
7451
7452      const container = document.createElement("div");
7453      container.className = "player-container";
7454
7455      const spinner = document.createElement("div");
7456      spinner.className = "spinner";
7457      container.appendChild(spinner);
7458
7459      const controller = document.createElement("media-controller");
7460      controller.setAttribute("defaultsubtitles", "");
7461
7462      const provider = document.createElement("youtube-media-provider");
7463      provider.setAttribute("slot", "media");
7464      provider.setAttribute("video-id", videoId);
7465      provider.setAttribute("start", start);
7466      provider.setAttribute("end", end);
7467
7468      const controlBar = document.createElement("media-control-bar");
7469
7470      const playBtn = document.createElement("media-play-button");
7471      const timeDisplay = document.createElement("media-time-display");
7472      timeDisplay.setAttribute("showduration", "");
7473      const timeRange = document.createElement("media-time-range");
7474      const playbackRate = document.createElement("media-playback-rate-button");
7475      const muteBtn = document.createElement("media-mute-button");
7476      const volumeRange = document.createElement("media-volume-range");
7477
7478      const captionsBtn = document.createElement("media-captions-button");
7479      captionsBtn.addEventListener("click", (e) => {
7480        e.stopPropagation();
7481        const provider = container.querySelector("youtube-media-provider");
7482        if (provider) provider.toggleCaptions();
7483      });
7484
7485      const qualityBtn = document.createElement("media-chrome-button");
7486      qualityBtn.textContent = "--";
7487      qualityBtn.addEventListener("click", () => {
7488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
7489      });
7490
7491      const fullscreenBtn = document.createElement("media-fullscreen-button");
7492
7493      controlBar.append(
7494        playBtn, timeDisplay, timeRange,
7495        playbackRate, muteBtn, volumeRange,
7496        captionsBtn, qualityBtn, fullscreenBtn
7497      );
7498
7499      const poster = document.createElement("media-poster-image");
7500      poster.setAttribute("slot", "poster");
7501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
7502
7503      controller.append(provider, poster, controlBar);
7504
7505      captionsBtn.setAttribute("aria-checked", "true");
7506      controller.setAttribute("mediasubtitlesshowing", "on");
7507
7508      container.appendChild(controller);
7509      details.appendChild(container);
7510
7511      provider.addEventListener("waiting", () => container.classList.add("loading"));
7512      provider.addEventListener("playing", () => container.classList.remove("loading"));
7513      provider.addEventListener("play", () => container.classList.remove("loading"));
7514
7515      provider._textTracks.addEventListener("change", () => {
7516        const enabled = provider._captionsEnabled;
7517        captionsBtn.setAttribute("aria-checked", enabled);
7518        if (enabled) {
7519          controller.setAttribute("mediasubtitlesshowing", "on");
7520        } else {
7521          controller.removeAttribute("mediasubtitlesshowing");
7522        }
7523      });
7524
7525      provider.addEventListener("qualitychange", (e) => {
7526        qualityBtn.textContent = e.detail.quality;
7527      });
7528    }
7529
7530    function initLazyPlayers() {
7531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
7532        if (details.dataset.lazyInit) return;
7533        details.dataset.lazyInit = "true";
7534        details.addEventListener("toggle", () => {
7535          if (details.open && !details.querySelector(".player-container")) {
7536            createPlayer(details);
7537          } else if (!details.open) {
7538            const provider = details.querySelector("youtube-media-provider");
7539            if (provider && typeof provider.pause === "function") {
7540              provider.pause();
7541            }
7542          }
7543        });
7544      });
7545    }
7546
7547    if (document.readyState === "loading") {
7548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
7549    } else {
7550      initLazyPlayers();
7551    }
7552  })();
7553</script>
7553<script>
7554  (() => {
7555    if (window.__courseCompletionsInitialized) return;
7556    window.__courseCompletionsInitialized = true;
7557
7558    const STORAGE_KEY = "course-completions";
7559
7560    function slugify(text) {
7561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7562    }
7563
7564    function getCompletions() {
7565      try {
7566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
7567      } catch {
7568        return [];
7569      }
7570    }
7571
7572    function saveCompletions(completions) {
7573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
7574    }
7575
7576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
7577    function canonicalFromStored(storedId) {
7578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7579      if (!m) return slugify(storedId);
7580      const [, slug, p, mv, title] = m;
7581      return `${slug}-${p}-${mv}-${slugify(title)}`;
7582    }
7583
7584    function canonicalPlusOne(storedId) {
7585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7586      if (!m) return null;
7587      const [, slug, p, mv, title] = m;
7588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
7589    }
7590
7591    function isCompleted(canonicalId, completions) {
7592      if (completions.includes(canonicalId)) return true;
7593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
7594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
7595    }
7596
7597    function migrateCompletions() {
7598      const completions = getCompletions();
7599      let changed = false;
7600      const migrated = completions.map((c) => {
7601        const canon = canonicalFromStored(c);
7602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
7603        const plusOne = canonicalPlusOne(c);
7604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
7605          if (plusOne !== c) changed = true;
7606          return plusOne;
7607        }
7608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
7609          changed = true;
7610          return canon;
7611        }
7612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
7613        if (canon !== c) {
7614          changed = true;
7615          return canon;
7616        }
7617        return c;
7618      });
7619      // Elimina duplicados tras migración
7620      const deduped = [...new Set(migrated)];
7621      if (changed || deduped.length !== completions.length) {
7622        saveCompletions(deduped);
7623        return deduped;
7624      }
7625      return completions;
7626    }
7627
7628    function updateButtonState(btn, completions) {
7629      const id = btn.dataset.completionId;
7630      const list = completions ?? getCompletions();
7631      btn.classList.toggle("completed", isCompleted(id, list));
7632    }
7633
7634    function checkModuleCompletion(completions) {
7635      const list = completions ?? getCompletions();
7636      document.querySelectorAll(".course-video").forEach((module) => {
7637        const btns = module.querySelectorAll(".complete-btn");
7638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
7639        const check = module.querySelector(".module-check");
7640        if (check) check.classList.toggle("visible", allCompleted);
7641      });
7642    }
7643
7644    function initCompletionButtons() {
7645      const completions = migrateCompletions();
7646
7647      document.querySelectorAll(".complete-btn").forEach((btn) => {
7648        if (btn.dataset.completionInit) return;
7649        btn.dataset.completionInit = "true";
7650        updateButtonState(btn, completions);
7651
7652        btn.addEventListener("click", (e) => {
7653          e.preventDefault();
7654          e.stopPropagation();
7655
7656          const id = btn.dataset.completionId;
7657          let list = getCompletions();
7658          const completed = isCompleted(id, list);
7659
7660          if (completed) {
7661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
7662            list = list.filter((c) =>
7662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
7663          } else {
7664            list.push(id);
7665          }
7666
7667          saveCompletions(list);
7668          updateButtonState(btn, list);
7669          checkModuleCompletion(list);
7670        });
7671      });
7672
7673      checkModuleCompletion(completions);
7674    }
7675
7676    if (document.readyState === "loading") {
7677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
7678    } else {
7679      initCompletionButtons();
7680    }
7681  })();
7682</script>
7682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="9940" data-end="10430" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Automatizar scripts</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-automatizar-scripts" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
7682<script>
7683  (() => {
7684    if (window.__courseLazyPlayersInitialized) return;
7685    window.__courseLazyPlayersInitialized = true;
7686
7687    function createPlayer(details) {
7688      const videoId = details.dataset.videoId;
7689      const start = details.dataset.start;
7690      const end = details.dataset.end;
7691
7692      const container = document.createElement("div");
7693      container.className = "player-container";
7694
7695      const spinner = document.createElement("div");
7696      spinner.className = "spinner";
7697      container.appendChild(spinner);
7698
7699      const controller = document.createElement("media-controller");
7700      controller.setAttribute("defaultsubtitles", "");
7701
7702      const provider = document.createElement("youtube-media-provider");
7703      provider.setAttribute("slot", "media");
7704      provider.setAttribute("video-id", videoId);
7705      provider.setAttribute("start", start);
7706      provider.setAttribute("end", end);
7707
7708      const controlBar = document.createElement("media-control-bar");
7709
7710      const playBtn = document.createElement("media-play-button");
7711      const timeDisplay = document.createElement("media-time-display");
7712      timeDisplay.setAttribute("showduration", "");
7713      const timeRange = document.createElement("media-time-range");
7714      const playbackRate = document.createElement("media-playback-rate-button");
7715      const muteBtn = document.createElement("media-mute-button");
7716      const volumeRange = document.createElement("media-volume-range");
7717
7718      const captionsBtn = document.createElement("media-captions-button");
7719      captionsBtn.addEventListener("click", (e) => {
7720        e.stopPropagation();
7721        const provider = container.querySelector("youtube-media-provider");
7722        if (provider) provider.toggleCaptions();
7723      });
7724
7725      const qualityBtn = document.createElement("media-chrome-button");
7726      qualityBtn.textContent = "--";
7727      qualityBtn.addEventListener("click", () => {
7728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
7729      });
7730
7731      const fullscreenBtn = document.createElement("media-fullscreen-button");
7732
7733      controlBar.append(
7734        playBtn, timeDisplay, timeRange,
7735        playbackRate, muteBtn, volumeRange,
7736        captionsBtn, qualityBtn, fullscreenBtn
7737      );
7738
7739      const poster = document.createElement("media-poster-image");
7740      poster.setAttribute("slot", "poster");
7741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
7742
7743      controller.append(provider, poster, controlBar);
7744
7745      captionsBtn.setAttribute("aria-checked", "true");
7746      controller.setAttribute("mediasubtitlesshowing", "on");
7747
7748      container.appendChild(controller);
7749      details.appendChild(container);
7750
7751      provider.addEventListener("waiting", () => container.classList.add("loading"));
7752      provider.addEventListener("playing", () => container.classList.remove("loading"));
7753      provider.addEventListener("play", () => container.classList.remove("loading"));
7754
7755      provider._textTracks.addEventListener("change", () => {
7756        const enabled = provider._captionsEnabled;
7757        captionsBtn.setAttribute("aria-checked", enabled);
7758        if (enabled) {
7759          controller.setAttribute("mediasubtitlesshowing", "on");
7760        } else {
7761          controller.removeAttribute("mediasubtitlesshowing");
7762        }
7763      });
7764
7765      provider.addEventListener("qualitychange", (e) => {
7766        qualityBtn.textContent = e.detail.quality;
7767      });
7768    }
7769
7770    function initLazyPlayers() {
7771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
7772        if (details.dataset.lazyInit) return;
7773        details.dataset.lazyInit = "true";
7774        details.addEventListener("toggle", () => {
7775          if (details.open && !details.querySelector(".player-container")) {
7776            createPlayer(details);
7777          } else if (!details.open) {
7778            const provider = details.querySelector("youtube-media-provider");
7779            if (provider && typeof provider.pause === "function") {
7780              provider.pause();
7781            }
7782          }
7783        });
7784      });
7785    }
7786
7787    if (document.readyState === "loading") {
7788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
7789    } else {
7790      initLazyPlayers();
7791    }
7792  })();
7793</script>
7793<script>
7794  (() => {
7795    if (window.__courseCompletionsInitialized) return;
7796    window.__courseCompletionsInitialized = true;
7797
7798    const STORAGE_KEY = "course-completions";
7799
7800    function slugify(text) {
7801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7802    }
7803
7804    function getCompletions() {
7805      try {
7806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
7807      } catch {
7808        return [];
7809      }
7810    }
7811
7812    function saveCompletions(completions) {
7813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
7814    }
7815
7816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
7817    function canonicalFromStored(storedId) {
7818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7819      if (!m) return slugify(storedId);
7820      const [, slug, p, mv, title] = m;
7821      return `${slug}-${p}-${mv}-${slugify(title)}`;
7822    }
7823
7824    function canonicalPlusOne(storedId) {
7825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
7826      if (!m) return null;
7827      const [, slug, p, mv, title] = m;
7828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
7829    }
7830
7831    function isCompleted(canonicalId, completions) {
7832      if (completions.includes(canonicalId)) return true;
7833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
7834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
7835    }
7836
7837    function migrateCompletions() {
7838      const completions = getCompletions();
7839      let changed = false;
7840      const migrated = completions.map((c) => {
7841        const canon = canonicalFromStored(c);
7842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
7843        const plusOne = canonicalPlusOne(c);
7844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
7845          if (plusOne !== c) changed = true;
7846          return plusOne;
7847        }
7848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
7849          changed = true;
7850          return canon;
7851        }
7852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
7853        if (canon !== c) {
7854          changed = true;
7855          return canon;
7856        }
7857        return c;
7858      });
7859      // Elimina duplicados tras migración
7860      const deduped = [...new Set(migrated)];
7861      if (changed || deduped.length !== completions.length) {
7862        saveCompletions(deduped);
7863        return deduped;
7864      }
7865      return completions;
7866    }
7867
7868    function updateButtonState(btn, completions) {
7869      const id = btn.dataset.completionId;
7870      const list = completions ?? getCompletions();
7871      btn.classList.toggle("completed", isCompleted(id, list));
7872    }
7873
7874    function checkModuleCompletion(completions) {
7875      const list = completions ?? getCompletions();
7876      document.querySelectorAll(".course-video").forEach((module) => {
7877        const btns = module.querySelectorAll(".complete-btn");
7878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
7879        const check = module.querySelector(".module-check");
7880        if (check) check.classList.toggle("visible", allCompleted);
7881      });
7882    }
7883
7884    function initCompletionButtons() {
7885      const completions = migrateCompletions();
7886
7887      document.querySelectorAll(".complete-btn").forEach((btn) => {
7888        if (btn.dataset.completionInit) return;
7889        btn.dataset.completionInit = "true";
7890        updateButtonState(btn, completions);
7891
7892        btn.addEventListener("click", (e) => {
7893          e.preventDefault();
7894          e.stopPropagation();
7895
7896          const id = btn.dataset.completionId;
7897          let list = getCompletions();
7898          const completed = isCompleted(id, list);
7899
7900          if (completed) {
7901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
7902            list = list.filter((c) =>
7902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
7903          } else {
7904            list.push(id);
7905          }
7906
7907          saveCompletions(list);
7908          updateButtonState(btn, list);
7909          checkModuleCompletion(list);
7910        });
7911      });
7912
7913      checkModuleCompletion(completions);
7914    }
7915
7916    if (document.readyState === "loading") {
7917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
7918    } else {
7919      initCompletionButtons();
7920    }
7921  })();
7922</script>
7922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="10430" data-end="10490" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Mensaje para Youtube</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>1 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-mensaje-para-youtube" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
7922<script>
7923  (() => {
7924    if (window.__courseLazyPlayersInitialized) return;
7925    window.__courseLazyPlayersInitialized = true;
7926
7927    function createPlayer(details) {
7928      const videoId = details.dataset.videoId;
7929      const start = details.dataset.start;
7930      const end = details.dataset.end;
7931
7932      const container = document.createElement("div");
7933      container.className = "player-container";
7934
7935      const spinner = document.createElement("div");
7936      spinner.className = "spinner";
7937      container.appendChild(spinner);
7938
7939      const controller = document.createElement("media-controller");
7940      controller.setAttribute("defaultsubtitles", "");
7941
7942      const provider = document.createElement("youtube-media-provider");
7943      provider.setAttribute("slot", "media");
7944      provider.setAttribute("video-id", videoId);
7945      provider.setAttribute("start", start);
7946      provider.setAttribute("end", end);
7947
7948      const controlBar = document.createElement("media-control-bar");
7949
7950      const playBtn = document.createElement("media-play-button");
7951      const timeDisplay = document.createElement("media-time-display");
7952      timeDisplay.setAttribute("showduration", "");
7953      const timeRange = document.createElement("media-time-range");
7954      const playbackRate = document.createElement("media-playback-rate-button");
7955      const muteBtn = document.createElement("media-mute-button");
7956      const volumeRange = document.createElement("media-volume-range");
7957
7958      const captionsBtn = document.createElement("media-captions-button");
7959      captionsBtn.addEventListener("click", (e) => {
7960        e.stopPropagation();
7961        const provider = container.querySelector("youtube-media-provider");
7962        if (provider) provider.toggleCaptions();
7963      });
7964
7965      const qualityBtn = document.createElement("media-chrome-button");
7966      qualityBtn.textContent = "--";
7967      qualityBtn.addEventListener("click", () => {
7968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
7969      });
7970
7971      const fullscreenBtn = document.createElement("media-fullscreen-button");
7972
7973      controlBar.append(
7974        playBtn, timeDisplay, timeRange,
7975        playbackRate, muteBtn, volumeRange,
7976        captionsBtn, qualityBtn, fullscreenBtn
7977      );
7978
7979      const poster = document.createElement("media-poster-image");
7980      poster.setAttribute("slot", "poster");
7981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
7982
7983      controller.append(provider, poster, controlBar);
7984
7985      captionsBtn.setAttribute("aria-checked", "true");
7986      controller.setAttribute("mediasubtitlesshowing", "on");
7987
7988      container.appendChild(controller);
7989      details.appendChild(container);
7990
7991      provider.addEventListener("waiting", () => container.classList.add("loading"));
7992      provider.addEventListener("playing", () => container.classList.remove("loading"));
7993      provider.addEventListener("play", () => container.classList.remove("loading"));
7994
7995      provider._textTracks.addEventListener("change", () => {
7996        const enabled = provider._captionsEnabled;
7997        captionsBtn.setAttribute("aria-checked", enabled);
7998        if (enabled) {
7999          controller.setAttribute("mediasubtitlesshowing", "on");
8000        } else {
8001          controller.removeAttribute("mediasubtitlesshowing");
8002        }
8003      });
8004
8005      provider.addEventListener("qualitychange", (e) => {
8006        qualityBtn.textContent = e.detail.quality;
8007      });
8008    }
8009
8010    function initLazyPlayers() {
8011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
8012        if (details.dataset.lazyInit) return;
8013        details.dataset.lazyInit = "true";
8014        details.addEventListener("toggle", () => {
8015          if (details.open && !details.querySelector(".player-container")) {
8016            createPlayer(details);
8017          } else if (!details.open) {
8018            const provider = details.querySelector("youtube-media-provider");
8019            if (provider && typeof provider.pause === "function") {
8020              provider.pause();
8021            }
8022          }
8023        });
8024      });
8025    }
8026
8027    if (document.readyState === "loading") {
8028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
8029    } else {
8030      initLazyPlayers();
8031    }
8032  })();
8033</script>
8033<script>
8034  (() => {
8035    if (window.__courseCompletionsInitialized) return;
8036    window.__courseCompletionsInitialized = true;
8037
8038    const STORAGE_KEY = "course-completions";
8039
8040    function slugify(text) {
8041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8042    }
8043
8044    function getCompletions() {
8045      try {
8046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
8047      } catch {
8048        return [];
8049      }
8050    }
8051
8052    function saveCompletions(completions) {
8053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
8054    }
8055
8056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
8057    function canonicalFromStored(storedId) {
8058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8059      if (!m) return slugify(storedId);
8060      const [, slug, p, mv, title] = m;
8061      return `${slug}-${p}-${mv}-${slugify(title)}`;
8062    }
8063
8064    function canonicalPlusOne(storedId) {
8065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8066      if (!m) return null;
8067      const [, slug, p, mv, title] = m;
8068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
8069    }
8070
8071    function isCompleted(canonicalId, completions) {
8072      if (completions.includes(canonicalId)) return true;
8073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
8074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
8075    }
8076
8077    function migrateCompletions() {
8078      const completions = getCompletions();
8079      let changed = false;
8080      const migrated = completions.map((c) => {
8081        const canon = canonicalFromStored(c);
8082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
8083        const plusOne = canonicalPlusOne(c);
8084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
8085          if (plusOne !== c) changed = true;
8086          return plusOne;
8087        }
8088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
8089          changed = true;
8090          return canon;
8091        }
8092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
8093        if (canon !== c) {
8094          changed = true;
8095          return canon;
8096        }
8097        return c;
8098      });
8099      // Elimina duplicados tras migración
8100      const deduped = [...new Set(migrated)];
8101      if (changed || deduped.length !== completions.length) {
8102        saveCompletions(deduped);
8103        return deduped;
8104      }
8105      return completions;
8106    }
8107
8108    function updateButtonState(btn, completions) {
8109      const id = btn.dataset.completionId;
8110      const list = completions ?? getCompletions();
8111      btn.classList.toggle("completed", isCompleted(id, list));
8112    }
8113
8114    function checkModuleCompletion(completions) {
8115      const list = completions ?? getCompletions();
8116      document.querySelectorAll(".course-video").forEach((module) => {
8117        const btns = module.querySelectorAll(".complete-btn");
8118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
8119        const check = module.querySelector(".module-check");
8120        if (check) check.classList.toggle("visible", allCompleted);
8121      });
8122    }
8123
8124    function initCompletionButtons() {
8125      const completions = migrateCompletions();
8126
8127      document.querySelectorAll(".complete-btn").forEach((btn) => {
8128        if (btn.dataset.completionInit) return;
8129        btn.dataset.completionInit = "true";
8130        updateButtonState(btn, completions);
8131
8132        btn.addEventListener("click", (e) => {
8133          e.preventDefault();
8134          e.stopPropagation();
8135
8136          const id = btn.dataset.completionId;
8137          let list = getCompletions();
8138          const completed = isCompleted(id, list);
8139
8140          if (completed) {
8141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
8142            list = list.filter((c) =>
8142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
8143          } else {
8144            list.push(id);
8145          }
8146
8147          saveCompletions(list);
8148          updateButtonState(btn, list);
8149          checkModuleCompletion(list);
8150        });
8151      });
8152
8153      checkModuleCompletion(completions);
8154    }
8155
8156    if (document.readyState === "loading") {
8157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
8158    } else {
8159      initCompletionButtons();
8160    }
8161  })();
8162</script>
8162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="10490" data-end="12050" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Linters y formateadores</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>26 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-linters-y-formateadores" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
8162<script>
8163  (() => {
8164    if (window.__courseLazyPlayersInitialized) return;
8165    window.__courseLazyPlayersInitialized = true;
8166
8167    function createPlayer(details) {
8168      const videoId = details.dataset.videoId;
8169      const start = details.dataset.start;
8170      const end = details.dataset.end;
8171
8172      const container = document.createElement("div");
8173      container.className = "player-container";
8174
8175      const spinner = document.createElement("div");
8176      spinner.className = "spinner";
8177      container.appendChild(spinner);
8178
8179      const controller = document.createElement("media-controller");
8180      controller.setAttribute("defaultsubtitles", "");
8181
8182      const provider = document.createElement("youtube-media-provider");
8183      provider.setAttribute("slot", "media");
8184      provider.setAttribute("video-id", videoId);
8185      provider.setAttribute("start", start);
8186      provider.setAttribute("end", end);
8187
8188      const controlBar = document.createElement("media-control-bar");
8189
8190      const playBtn = document.createElement("media-play-button");
8191      const timeDisplay = document.createElement("media-time-display");
8192      timeDisplay.setAttribute("showduration", "");
8193      const timeRange = document.createElement("media-time-range");
8194      const playbackRate = document.createElement("media-playback-rate-button");
8195      const muteBtn = document.createElement("media-mute-button");
8196      const volumeRange = document.createElement("media-volume-range");
8197
8198      const captionsBtn = document.createElement("media-captions-button");
8199      captionsBtn.addEventListener("click", (e) => {
8200        e.stopPropagation();
8201        const provider = container.querySelector("youtube-media-provider");
8202        if (provider) provider.toggleCaptions();
8203      });
8204
8205      const qualityBtn = document.createElement("media-chrome-button");
8206      qualityBtn.textContent = "--";
8207      qualityBtn.addEventListener("click", () => {
8208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
8209      });
8210
8211      const fullscreenBtn = document.createElement("media-fullscreen-button");
8212
8213      controlBar.append(
8214        playBtn, timeDisplay, timeRange,
8215        playbackRate, muteBtn, volumeRange,
8216        captionsBtn, qualityBtn, fullscreenBtn
8217      );
8218
8219      const poster = document.createElement("media-poster-image");
8220      poster.setAttribute("slot", "poster");
8221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
8222
8223      controller.append(provider, poster, controlBar);
8224
8225      captionsBtn.setAttribute("aria-checked", "true");
8226      controller.setAttribute("mediasubtitlesshowing", "on");
8227
8228      container.appendChild(controller);
8229      details.appendChild(container);
8230
8231      provider.addEventListener("waiting", () => container.classList.add("loading"));
8232      provider.addEventListener("playing", () => container.classList.remove("loading"));
8233      provider.addEventListener("play", () => container.classList.remove("loading"));
8234
8235      provider._textTracks.addEventListener("change", () => {
8236        const enabled = provider._captionsEnabled;
8237        captionsBtn.setAttribute("aria-checked", enabled);
8238        if (enabled) {
8239          controller.setAttribute("mediasubtitlesshowing", "on");
8240        } else {
8241          controller.removeAttribute("mediasubtitlesshowing");
8242        }
8243      });
8244
8245      provider.addEventListener("qualitychange", (e) => {
8246        qualityBtn.textContent = e.detail.quality;
8247      });
8248    }
8249
8250    function initLazyPlayers() {
8251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
8252        if (details.dataset.lazyInit) return;
8253        details.dataset.lazyInit = "true";
8254        details.addEventListener("toggle", () => {
8255          if (details.open && !details.querySelector(".player-container")) {
8256            createPlayer(details);
8257          } else if (!details.open) {
8258            const provider = details.querySelector("youtube-media-provider");
8259            if (provider && typeof provider.pause === "function") {
8260              provider.pause();
8261            }
8262          }
8263        });
8264      });
8265    }
8266
8267    if (document.readyState === "loading") {
8268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
8269    } else {
8270      initLazyPlayers();
8271    }
8272  })();
8273</script>
8273<script>
8274  (() => {
8275    if (window.__courseCompletionsInitialized) return;
8276    window.__courseCompletionsInitialized = true;
8277
8278    const STORAGE_KEY = "course-completions";
8279
8280    function slugify(text) {
8281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8282    }
8283
8284    function getCompletions() {
8285      try {
8286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
8287      } catch {
8288        return [];
8289      }
8290    }
8291
8292    function saveCompletions(completions) {
8293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
8294    }
8295
8296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
8297    function canonicalFromStored(storedId) {
8298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8299      if (!m) return slugify(storedId);
8300      const [, slug, p, mv, title] = m;
8301      return `${slug}-${p}-${mv}-${slugify(title)}`;
8302    }
8303
8304    function canonicalPlusOne(storedId) {
8305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8306      if (!m) return null;
8307      const [, slug, p, mv, title] = m;
8308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
8309    }
8310
8311    function isCompleted(canonicalId, completions) {
8312      if (completions.includes(canonicalId)) return true;
8313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
8314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
8315    }
8316
8317    function migrateCompletions() {
8318      const completions = getCompletions();
8319      let changed = false;
8320      const migrated = completions.map((c) => {
8321        const canon = canonicalFromStored(c);
8322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
8323        const plusOne = canonicalPlusOne(c);
8324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
8325          if (plusOne !== c) changed = true;
8326          return plusOne;
8327        }
8328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
8329          changed = true;
8330          return canon;
8331        }
8332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
8333        if (canon !== c) {
8334          changed = true;
8335          return canon;
8336        }
8337        return c;
8338      });
8339      // Elimina duplicados tras migración
8340      const deduped = [...new Set(migrated)];
8341      if (changed || deduped.length !== completions.length) {
8342        saveCompletions(deduped);
8343        return deduped;
8344      }
8345      return completions;
8346    }
8347
8348    function updateButtonState(btn, completions) {
8349      const id = btn.dataset.completionId;
8350      const list = completions ?? getCompletions();
8351      btn.classList.toggle("completed", isCompleted(id, list));
8352    }
8353
8354    function checkModuleCompletion(completions) {
8355      const list = completions ?? getCompletions();
8356      document.querySelectorAll(".course-video").forEach((module) => {
8357        const btns = module.querySelectorAll(".complete-btn");
8358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
8359        const check = module.querySelector(".module-check");
8360        if (check) check.classList.toggle("visible", allCompleted);
8361      });
8362    }
8363
8364    function initCompletionButtons() {
8365      const completions = migrateCompletions();
8366
8367      document.querySelectorAll(".complete-btn").forEach((btn) => {
8368        if (btn.dataset.completionInit) return;
8369        btn.dataset.completionInit = "true";
8370        updateButtonState(btn, completions);
8371
8372        btn.addEventListener("click", (e) => {
8373          e.preventDefault();
8374          e.stopPropagation();
8375
8376          const id = btn.dataset.completionId;
8377          let list = getCompletions();
8378          const completed = isCompleted(id, list);
8379
8380          if (completed) {
8381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
8382            list = list.filter((c) =>
8382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
8383          } else {
8384            list.push(id);
8385          }
8386
8387          saveCompletions(list);
8388          updateButtonState(btn, list);
8389          checkModuleCompletion(list);
8390        });
8391      });
8392
8393      checkModuleCompletion(completions);
8394    }
8395
8396    if (document.readyState === "loading") {
8397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
8398    } else {
8399      initCompletionButtons();
8400    }
8401  })();
8402</script>
8402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="12050" data-end="13810" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Git</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>29 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-git" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
8402<script>
8403  (() => {
8404    if (window.__courseLazyPlayersInitialized) return;
8405    window.__courseLazyPlayersInitialized = true;
8406
8407    function createPlayer(details) {
8408      const videoId = details.dataset.videoId;
8409      const start = details.dataset.start;
8410      const end = details.dataset.end;
8411
8412      const container = document.createElement("div");
8413      container.className = "player-container";
8414
8415      const spinner = document.createElement("div");
8416      spinner.className = "spinner";
8417      container.appendChild(spinner);
8418
8419      const controller = document.createElement("media-controller");
8420      controller.setAttribute("defaultsubtitles", "");
8421
8422      const provider = document.createElement("youtube-media-provider");
8423      provider.setAttribute("slot", "media");
8424      provider.setAttribute("video-id", videoId);
8425      provider.setAttribute("start", start);
8426      provider.setAttribute("end", end);
8427
8428      const controlBar = document.createElement("media-control-bar");
8429
8430      const playBtn = document.createElement("media-play-button");
8431      const timeDisplay = document.createElement("media-time-display");
8432      timeDisplay.setAttribute("showduration", "");
8433      const timeRange = document.createElement("media-time-range");
8434      const playbackRate = document.createElement("media-playback-rate-button");
8435      const muteBtn = document.createElement("media-mute-button");
8436      const volumeRange = document.createElement("media-volume-range");
8437
8438      const captionsBtn = document.createElement("media-captions-button");
8439      captionsBtn.addEventListener("click", (e) => {
8440        e.stopPropagation();
8441        const provider = container.querySelector("youtube-media-provider");
8442        if (provider) provider.toggleCaptions();
8443      });
8444
8445      const qualityBtn = document.createElement("media-chrome-button");
8446      qualityBtn.textContent = "--";
8447      qualityBtn.addEventListener("click", () => {
8448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
8449      });
8450
8451      const fullscreenBtn = document.createElement("media-fullscreen-button");
8452
8453      controlBar.append(
8454        playBtn, timeDisplay, timeRange,
8455        playbackRate, muteBtn, volumeRange,
8456        captionsBtn, qualityBtn, fullscreenBtn
8457      );
8458
8459      const poster = document.createElement("media-poster-image");
8460      poster.setAttribute("slot", "poster");
8461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
8462
8463      controller.append(provider, poster, controlBar);
8464
8465      captionsBtn.setAttribute("aria-checked", "true");
8466      controller.setAttribute("mediasubtitlesshowing", "on");
8467
8468      container.appendChild(controller);
8469      details.appendChild(container);
8470
8471      provider.addEventListener("waiting", () => container.classList.add("loading"));
8472      provider.addEventListener("playing", () => container.classList.remove("loading"));
8473      provider.addEventListener("play", () => container.classList.remove("loading"));
8474
8475      provider._textTracks.addEventListener("change", () => {
8476        const enabled = provider._captionsEnabled;
8477        captionsBtn.setAttribute("aria-checked", enabled);
8478        if (enabled) {
8479          controller.setAttribute("mediasubtitlesshowing", "on");
8480        } else {
8481          controller.removeAttribute("mediasubtitlesshowing");
8482        }
8483      });
8484
8485      provider.addEventListener("qualitychange", (e) => {
8486        qualityBtn.textContent = e.detail.quality;
8487      });
8488    }
8489
8490    function initLazyPlayers() {
8491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
8492        if (details.dataset.lazyInit) return;
8493        details.dataset.lazyInit = "true";
8494        details.addEventListener("toggle", () => {
8495          if (details.open && !details.querySelector(".player-container")) {
8496            createPlayer(details);
8497          } else if (!details.open) {
8498            const provider = details.querySelector("youtube-media-provider");
8499            if (provider && typeof provider.pause === "function") {
8500              provider.pause();
8501            }
8502          }
8503        });
8504      });
8505    }
8506
8507    if (document.readyState === "loading") {
8508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
8509    } else {
8510      initLazyPlayers();
8511    }
8512  })();
8513</script>
8513<script>
8514  (() => {
8515    if (window.__courseCompletionsInitialized) return;
8516    window.__courseCompletionsInitialized = true;
8517
8518    const STORAGE_KEY = "course-completions";
8519
8520    function slugify(text) {
8521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8522    }
8523
8524    function getCompletions() {
8525      try {
8526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
8527      } catch {
8528        return [];
8529      }
8530    }
8531
8532    function saveCompletions(completions) {
8533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
8534    }
8535
8536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
8537    function canonicalFromStored(storedId) {
8538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8539      if (!m) return slugify(storedId);
8540      const [, slug, p, mv, title] = m;
8541      return `${slug}-${p}-${mv}-${slugify(title)}`;
8542    }
8543
8544    function canonicalPlusOne(storedId) {
8545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8546      if (!m) return null;
8547      const [, slug, p, mv, title] = m;
8548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
8549    }
8550
8551    function isCompleted(canonicalId, completions) {
8552      if (completions.includes(canonicalId)) return true;
8553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
8554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
8555    }
8556
8557    function migrateCompletions() {
8558      const completions = getCompletions();
8559      let changed = false;
8560      const migrated = completions.map((c) => {
8561        const canon = canonicalFromStored(c);
8562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
8563        const plusOne = canonicalPlusOne(c);
8564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
8565          if (plusOne !== c) changed = true;
8566          return plusOne;
8567        }
8568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
8569          changed = true;
8570          return canon;
8571        }
8572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
8573        if (canon !== c) {
8574          changed = true;
8575          return canon;
8576        }
8577        return c;
8578      });
8579      // Elimina duplicados tras migración
8580      const deduped = [...new Set(migrated)];
8581      if (changed || deduped.length !== completions.length) {
8582        saveCompletions(deduped);
8583        return deduped;
8584      }
8585      return completions;
8586    }
8587
8588    function updateButtonState(btn, completions) {
8589      const id = btn.dataset.completionId;
8590      const list = completions ?? getCompletions();
8591      btn.classList.toggle("completed", isCompleted(id, list));
8592    }
8593
8594    function checkModuleCompletion(completions) {
8595      const list = completions ?? getCompletions();
8596      document.querySelectorAll(".course-video").forEach((module) => {
8597        const btns = module.querySelectorAll(".complete-btn");
8598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
8599        const check = module.querySelector(".module-check");
8600        if (check) check.classList.toggle("visible", allCompleted);
8601      });
8602    }
8603
8604    function initCompletionButtons() {
8605      const completions = migrateCompletions();
8606
8607      document.querySelectorAll(".complete-btn").forEach((btn) => {
8608        if (btn.dataset.completionInit) return;
8609        btn.dataset.completionInit = "true";
8610        updateButtonState(btn, completions);
8611
8612        btn.addEventListener("click", (e) => {
8613          e.preventDefault();
8614          e.stopPropagation();
8615
8616          const id = btn.dataset.completionId;
8617          let list = getCompletions();
8618          const completed = isCompleted(id, list);
8619
8620          if (completed) {
8621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
8622            list = list.filter((c) =>
8622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
8623          } else {
8624            list.push(id);
8625          }
8626
8627          saveCompletions(list);
8628          updateButtonState(btn, list);
8629          checkModuleCompletion(list);
8630        });
8631      });
8632
8633      checkModuleCompletion(completions);
8634    }
8635
8636    if (document.readyState === "loading") {
8637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
8638    } else {
8639      initCompletionButtons();
8640    }
8641  })();
8642</script>
8642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="13810" data-end="16920" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. GitHub</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>52 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-github" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
8642<script>
8643  (() => {
8644    if (window.__courseLazyPlayersInitialized) return;
8645    window.__courseLazyPlayersInitialized = true;
8646
8647    function createPlayer(details) {
8648      const videoId = details.dataset.videoId;
8649      const start = details.dataset.start;
8650      const end = details.dataset.end;
8651
8652      const container = document.createElement("div");
8653      container.className = "player-container";
8654
8655      const spinner = document.createElement("div");
8656      spinner.className = "spinner";
8657      container.appendChild(spinner);
8658
8659      const controller = document.createElement("media-controller");
8660      controller.setAttribute("defaultsubtitles", "");
8661
8662      const provider = document.createElement("youtube-media-provider");
8663      provider.setAttribute("slot", "media");
8664      provider.setAttribute("video-id", videoId);
8665      provider.setAttribute("start", start);
8666      provider.setAttribute("end", end);
8667
8668      const controlBar = document.createElement("media-control-bar");
8669
8670      const playBtn = document.createElement("media-play-button");
8671      const timeDisplay = document.createElement("media-time-display");
8672      timeDisplay.setAttribute("showduration", "");
8673      const timeRange = document.createElement("media-time-range");
8674      const playbackRate = document.createElement("media-playback-rate-button");
8675      const muteBtn = document.createElement("media-mute-button");
8676      const volumeRange = document.createElement("media-volume-range");
8677
8678      const captionsBtn = document.createElement("media-captions-button");
8679      captionsBtn.addEventListener("click", (e) => {
8680        e.stopPropagation();
8681        const provider = container.querySelector("youtube-media-provider");
8682        if (provider) provider.toggleCaptions();
8683      });
8684
8685      const qualityBtn = document.createElement("media-chrome-button");
8686      qualityBtn.textContent = "--";
8687      qualityBtn.addEventListener("click", () => {
8688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
8689      });
8690
8691      const fullscreenBtn = document.createElement("media-fullscreen-button");
8692
8693      controlBar.append(
8694        playBtn, timeDisplay, timeRange,
8695        playbackRate, muteBtn, volumeRange,
8696        captionsBtn, qualityBtn, fullscreenBtn
8697      );
8698
8699      const poster = document.createElement("media-poster-image");
8700      poster.setAttribute("slot", "poster");
8701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
8702
8703      controller.append(provider, poster, controlBar);
8704
8705      captionsBtn.setAttribute("aria-checked", "true");
8706      controller.setAttribute("mediasubtitlesshowing", "on");
8707
8708      container.appendChild(controller);
8709      details.appendChild(container);
8710
8711      provider.addEventListener("waiting", () => container.classList.add("loading"));
8712      provider.addEventListener("playing", () => container.classList.remove("loading"));
8713      provider.addEventListener("play", () => container.classList.remove("loading"));
8714
8715      provider._textTracks.addEventListener("change", () => {
8716        const enabled = provider._captionsEnabled;
8717        captionsBtn.setAttribute("aria-checked", enabled);
8718        if (enabled) {
8719          controller.setAttribute("mediasubtitlesshowing", "on");
8720        } else {
8721          controller.removeAttribute("mediasubtitlesshowing");
8722        }
8723      });
8724
8725      provider.addEventListener("qualitychange", (e) => {
8726        qualityBtn.textContent = e.detail.quality;
8727      });
8728    }
8729
8730    function initLazyPlayers() {
8731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
8732        if (details.dataset.lazyInit) return;
8733        details.dataset.lazyInit = "true";
8734        details.addEventListener("toggle", () => {
8735          if (details.open && !details.querySelector(".player-container")) {
8736            createPlayer(details);
8737          } else if (!details.open) {
8738            const provider = details.querySelector("youtube-media-provider");
8739            if (provider && typeof provider.pause === "function") {
8740              provider.pause();
8741            }
8742          }
8743        });
8744      });
8745    }
8746
8747    if (document.readyState === "loading") {
8748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
8749    } else {
8750      initLazyPlayers();
8751    }
8752  })();
8753</script>
8753<script>
8754  (() => {
8755    if (window.__courseCompletionsInitialized) return;
8756    window.__courseCompletionsInitialized = true;
8757
8758    const STORAGE_KEY = "course-completions";
8759
8760    function slugify(text) {
8761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8762    }
8763
8764    function getCompletions() {
8765      try {
8766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
8767      } catch {
8768        return [];
8769      }
8770    }
8771
8772    function saveCompletions(completions) {
8773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
8774    }
8775
8776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
8777    function canonicalFromStored(storedId) {
8778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8779      if (!m) return slugify(storedId);
8780      const [, slug, p, mv, title] = m;
8781      return `${slug}-${p}-${mv}-${slugify(title)}`;
8782    }
8783
8784    function canonicalPlusOne(storedId) {
8785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
8786      if (!m) return null;
8787      const [, slug, p, mv, title] = m;
8788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
8789    }
8790
8791    function isCompleted(canonicalId, completions) {
8792      if (completions.includes(canonicalId)) return true;
8793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
8794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
8795    }
8796
8797    function migrateCompletions() {
8798      const completions = getCompletions();
8799      let changed = false;
8800      const migrated = completions.map((c) => {
8801        const canon = canonicalFromStored(c);
8802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
8803        const plusOne = canonicalPlusOne(c);
8804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
8805          if (plusOne !== c) changed = true;
8806          return plusOne;
8807        }
8808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
8809          changed = true;
8810          return canon;
8811        }
8812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
8813        if (canon !== c) {
8814          changed = true;
8815          return canon;
8816        }
8817        return c;
8818      });
8819      // Elimina duplicados tras migración
8820      const deduped = [...new Set(migrated)];
8821      if (changed || deduped.length !== completions.length) {
8822        saveCompletions(deduped);
8823        return deduped;
8824      }
8825      return completions;
8826    }
8827
8828    function updateButtonState(btn, completions) {
8829      const id = btn.dataset.completionId;
8830      const list = completions ?? getCompletions();
8831      btn.classList.toggle("completed", isCompleted(id, list));
8832    }
8833
8834    function checkModuleCompletion(completions) {
8835      const list = completions ?? getCompletions();
8836      document.querySelectorAll(".course-video").forEach((module) => {
8837        const btns = module.querySelectorAll(".complete-btn");
8838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
8839        const check = module.querySelector(".module-check");
8840        if (check) check.classList.toggle("visible", allCompleted);
8841      });
8842    }
8843
8844    function initCompletionButtons() {
8845      const completions = migrateCompletions();
8846
8847      document.querySelectorAll(".complete-btn").forEach((btn) => {
8848        if (btn.dataset.completionInit) return;
8849        btn.dataset.completionInit = "true";
8850        updateButtonState(btn, completions);
8851
8852        btn.addEventListener("click", (e) => {
8853          e.preventDefault();
8854          e.stopPropagation();
8855
8856          const id = btn.dataset.completionId;
8857          let list = getCompletions();
8858          const completed = isCompleted(id, list);
8859
8860          if (completed) {
8861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
8862            list = list.filter((c) =>
8862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
8863          } else {
8864            list.push(id);
8865          }
8866
8867          saveCompletions(list);
8868          updateButtonState(btn, list);
8869          checkModuleCompletion(list);
8870        });
8871      });
8872
8873      checkModuleCompletion(completions);
8874    }
8875
8876    if (document.readyState === "loading") {
8877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
8878    } else {
8879      initCompletionButtons();
8880    }
8881  })();
8882</script>
8882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="16920" data-end="17008" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Cursor dancing</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>1 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-cursor-dancing" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
8882<script>
8883  (() => {
8884    if (window.__courseLazyPlayersInitialized) return;
8885    window.__courseLazyPlayersInitialized = true;
8886
8887    function createPlayer(details) {
8888      const videoId = details.dataset.videoId;
8889      const start = details.dataset.start;
8890      const end = details.dataset.end;
8891
8892      const container = document.createElement("div");
8893      container.className = "player-container";
8894
8895      const spinner = document.createElement("div");
8896      spinner.className = "spinner";
8897      container.appendChild(spinner);
8898
8899      const controller = document.createElement("media-controller");
8900      controller.setAttribute("defaultsubtitles", "");
8901
8902      const provider = document.createElement("youtube-media-provider");
8903      provider.setAttribute("slot", "media");
8904      provider.setAttribute("video-id", videoId);
8905      provider.setAttribute("start", start);
8906      provider.setAttribute("end", end);
8907
8908      const controlBar = document.createElement("media-control-bar");
8909
8910      const playBtn = document.createElement("media-play-button");
8911      const timeDisplay = document.createElement("media-time-display");
8912      timeDisplay.setAttribute("showduration", "");
8913      const timeRange = document.createElement("media-time-range");
8914      const playbackRate = document.createElement("media-playback-rate-button");
8915      const muteBtn = document.createElement("media-mute-button");
8916      const volumeRange = document.createElement("media-volume-range");
8917
8918      const captionsBtn = document.createElement("media-captions-button");
8919      captionsBtn.addEventListener("click", (e) => {
8920        e.stopPropagation();
8921        const provider = container.querySelector("youtube-media-provider");
8922        if (provider) provider.toggleCaptions();
8923      });
8924
8925      const qualityBtn = document.createElement("media-chrome-button");
8926      qualityBtn.textContent = "--";
8927      qualityBtn.addEventListener("click", () => {
8928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
8929      });
8930
8931      const fullscreenBtn = document.createElement("media-fullscreen-button");
8932
8933      controlBar.append(
8934        playBtn, timeDisplay, timeRange,
8935        playbackRate, muteBtn, volumeRange,
8936        captionsBtn, qualityBtn, fullscreenBtn
8937      );
8938
8939      const poster = document.createElement("media-poster-image");
8940      poster.setAttribute("slot", "poster");
8941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
8942
8943      controller.append(provider, poster, controlBar);
8944
8945      captionsBtn.setAttribute("aria-checked", "true");
8946      controller.setAttribute("mediasubtitlesshowing", "on");
8947
8948      container.appendChild(controller);
8949      details.appendChild(container);
8950
8951      provider.addEventListener("waiting", () => container.classList.add("loading"));
8952      provider.addEventListener("playing", () => container.classList.remove("loading"));
8953      provider.addEventListener("play", () => container.classList.remove("loading"));
8954
8955      provider._textTracks.addEventListener("change", () => {
8956        const enabled = provider._captionsEnabled;
8957        captionsBtn.setAttribute("aria-checked", enabled);
8958        if (enabled) {
8959          controller.setAttribute("mediasubtitlesshowing", "on");
8960        } else {
8961          controller.removeAttribute("mediasubtitlesshowing");
8962        }
8963      });
8964
8965      provider.addEventListener("qualitychange", (e) => {
8966        qualityBtn.textContent = e.detail.quality;
8967      });
8968    }
8969
8970    function initLazyPlayers() {
8971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
8972        if (details.dataset.lazyInit) return;
8973        details.dataset.lazyInit = "true";
8974        details.addEventListener("toggle", () => {
8975          if (details.open && !details.querySelector(".player-container")) {
8976            createPlayer(details);
8977          } else if (!details.open) {
8978            const provider = details.querySelector("youtube-media-provider");
8979            if (provider && typeof provider.pause === "function") {
8980              provider.pause();
8981            }
8982          }
8983        });
8984      });
8985    }
8986
8987    if (document.readyState === "loading") {
8988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
8989    } else {
8990      initLazyPlayers();
8991    }
8992  })();
8993</script>
8993<script>
8994  (() => {
8995    if (window.__courseCompletionsInitialized) return;
8996    window.__courseCompletionsInitialized = true;
8997
8998    const STORAGE_KEY = "course-completions";
8999
9000    function slugify(text) {
9001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9002    }
9003
9004    function getCompletions() {
9005      try {
9006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
9007      } catch {
9008        return [];
9009      }
9010    }
9011
9012    function saveCompletions(completions) {
9013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
9014    }
9015
9016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
9017    function canonicalFromStored(storedId) {
9018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9019      if (!m) return slugify(storedId);
9020      const [, slug, p, mv, title] = m;
9021      return `${slug}-${p}-${mv}-${slugify(title)}`;
9022    }
9023
9024    function canonicalPlusOne(storedId) {
9025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9026      if (!m) return null;
9027      const [, slug, p, mv, title] = m;
9028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
9029    }
9030
9031    function isCompleted(canonicalId, completions) {
9032      if (completions.includes(canonicalId)) return true;
9033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
9034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
9035    }
9036
9037    function migrateCompletions() {
9038      const completions = getCompletions();
9039      let changed = false;
9040      const migrated = completions.map((c) => {
9041        const canon = canonicalFromStored(c);
9042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
9043        const plusOne = canonicalPlusOne(c);
9044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
9045          if (plusOne !== c) changed = true;
9046          return plusOne;
9047        }
9048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
9049          changed = true;
9050          return canon;
9051        }
9052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
9053        if (canon !== c) {
9054          changed = true;
9055          return canon;
9056        }
9057        return c;
9058      });
9059      // Elimina duplicados tras migración
9060      const deduped = [...new Set(migrated)];
9061      if (changed || deduped.length !== completions.length) {
9062        saveCompletions(deduped);
9063        return deduped;
9064      }
9065      return completions;
9066    }
9067
9068    function updateButtonState(btn, completions) {
9069      const id = btn.dataset.completionId;
9070      const list = completions ?? getCompletions();
9071      btn.classList.toggle("completed", isCompleted(id, list));
9072    }
9073
9074    function checkModuleCompletion(completions) {
9075      const list = completions ?? getCompletions();
9076      document.querySelectorAll(".course-video").forEach((module) => {
9077        const btns = module.querySelectorAll(".complete-btn");
9078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
9079        const check = module.querySelector(".module-check");
9080        if (check) check.classList.toggle("visible", allCompleted);
9081      });
9082    }
9083
9084    function initCompletionButtons() {
9085      const completions = migrateCompletions();
9086
9087      document.querySelectorAll(".complete-btn").forEach((btn) => {
9088        if (btn.dataset.completionInit) return;
9089        btn.dataset.completionInit = "true";
9090        updateButtonState(btn, completions);
9091
9092        btn.addEventListener("click", (e) => {
9093          e.preventDefault();
9094          e.stopPropagation();
9095
9096          const id = btn.dataset.completionId;
9097          let list = getCompletions();
9098          const completed = isCompleted(id, list);
9099
9100          if (completed) {
9101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
9102            list = list.filter((c) =>
9102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
9103          } else {
9104            list.push(id);
9105          }
9106
9107          saveCompletions(list);
9108          updateButtonState(btn, list);
9109          checkModuleCompletion(list);
9110        });
9111      });
9112
9113      checkModuleCompletion(completions);
9114    }
9115
9116    if (document.readyState === "loading") {
9117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
9118    } else {
9119      initCompletionButtons();
9120    }
9121  })();
9122</script>
9122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="17008" data-end="17797" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>14. Despliegue en GitHub Pages</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-despliegue-en-github-pages" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
9122<script>
9123  (() => {
9124    if (window.__courseLazyPlayersInitialized) return;
9125    window.__courseLazyPlayersInitialized = true;
9126
9127    function createPlayer(details) {
9128      const videoId = details.dataset.videoId;
9129      const start = details.dataset.start;
9130      const end = details.dataset.end;
9131
9132      const container = document.createElement("div");
9133      container.className = "player-container";
9134
9135      const spinner = document.createElement("div");
9136      spinner.className = "spinner";
9137      container.appendChild(spinner);
9138
9139      const controller = document.createElement("media-controller");
9140      controller.setAttribute("defaultsubtitles", "");
9141
9142      const provider = document.createElement("youtube-media-provider");
9143      provider.setAttribute("slot", "media");
9144      provider.setAttribute("video-id", videoId);
9145      provider.setAttribute("start", start);
9146      provider.setAttribute("end", end);
9147
9148      const controlBar = document.createElement("media-control-bar");
9149
9150      const playBtn = document.createElement("media-play-button");
9151      const timeDisplay = document.createElement("media-time-display");
9152      timeDisplay.setAttribute("showduration", "");
9153      const timeRange = document.createElement("media-time-range");
9154      const playbackRate = document.createElement("media-playback-rate-button");
9155      const muteBtn = document.createElement("media-mute-button");
9156      const volumeRange = document.createElement("media-volume-range");
9157
9158      const captionsBtn = document.createElement("media-captions-button");
9159      captionsBtn.addEventListener("click", (e) => {
9160        e.stopPropagation();
9161        const provider = container.querySelector("youtube-media-provider");
9162        if (provider) provider.toggleCaptions();
9163      });
9164
9165      const qualityBtn = document.createElement("media-chrome-button");
9166      qualityBtn.textContent = "--";
9167      qualityBtn.addEventListener("click", () => {
9168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
9169      });
9170
9171      const fullscreenBtn = document.createElement("media-fullscreen-button");
9172
9173      controlBar.append(
9174        playBtn, timeDisplay, timeRange,
9175        playbackRate, muteBtn, volumeRange,
9176        captionsBtn, qualityBtn, fullscreenBtn
9177      );
9178
9179      const poster = document.createElement("media-poster-image");
9180      poster.setAttribute("slot", "poster");
9181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
9182
9183      controller.append(provider, poster, controlBar);
9184
9185      captionsBtn.setAttribute("aria-checked", "true");
9186      controller.setAttribute("mediasubtitlesshowing", "on");
9187
9188      container.appendChild(controller);
9189      details.appendChild(container);
9190
9191      provider.addEventListener("waiting", () => container.classList.add("loading"));
9192      provider.addEventListener("playing", () => container.classList.remove("loading"));
9193      provider.addEventListener("play", () => container.classList.remove("loading"));
9194
9195      provider._textTracks.addEventListener("change", () => {
9196        const enabled = provider._captionsEnabled;
9197        captionsBtn.setAttribute("aria-checked", enabled);
9198        if (enabled) {
9199          controller.setAttribute("mediasubtitlesshowing", "on");
9200        } else {
9201          controller.removeAttribute("mediasubtitlesshowing");
9202        }
9203      });
9204
9205      provider.addEventListener("qualitychange", (e) => {
9206        qualityBtn.textContent = e.detail.quality;
9207      });
9208    }
9209
9210    function initLazyPlayers() {
9211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
9212        if (details.dataset.lazyInit) return;
9213        details.dataset.lazyInit = "true";
9214        details.addEventListener("toggle", () => {
9215          if (details.open && !details.querySelector(".player-container")) {
9216            createPlayer(details);
9217          } else if (!details.open) {
9218            const provider = details.querySelector("youtube-media-provider");
9219            if (provider && typeof provider.pause === "function") {
9220              provider.pause();
9221            }
9222          }
9223        });
9224      });
9225    }
9226
9227    if (document.readyState === "loading") {
9228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
9229    } else {
9230      initLazyPlayers();
9231    }
9232  })();
9233</script>
9233<script>
9234  (() => {
9235    if (window.__courseCompletionsInitialized) return;
9236    window.__courseCompletionsInitialized = true;
9237
9238    const STORAGE_KEY = "course-completions";
9239
9240    function slugify(text) {
9241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9242    }
9243
9244    function getCompletions() {
9245      try {
9246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
9247      } catch {
9248        return [];
9249      }
9250    }
9251
9252    function saveCompletions(completions) {
9253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
9254    }
9255
9256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
9257    function canonicalFromStored(storedId) {
9258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9259      if (!m) return slugify(storedId);
9260      const [, slug, p, mv, title] = m;
9261      return `${slug}-${p}-${mv}-${slugify(title)}`;
9262    }
9263
9264    function canonicalPlusOne(storedId) {
9265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9266      if (!m) return null;
9267      const [, slug, p, mv, title] = m;
9268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
9269    }
9270
9271    function isCompleted(canonicalId, completions) {
9272      if (completions.includes(canonicalId)) return true;
9273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
9274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
9275    }
9276
9277    function migrateCompletions() {
9278      const completions = getCompletions();
9279      let changed = false;
9280      const migrated = completions.map((c) => {
9281        const canon = canonicalFromStored(c);
9282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
9283        const plusOne = canonicalPlusOne(c);
9284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
9285          if (plusOne !== c) changed = true;
9286          return plusOne;
9287        }
9288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
9289          changed = true;
9290          return canon;
9291        }
9292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
9293        if (canon !== c) {
9294          changed = true;
9295          return canon;
9296        }
9297        return c;
9298      });
9299      // Elimina duplicados tras migración
9300      const deduped = [...new Set(migrated)];
9301      if (changed || deduped.length !== completions.length) {
9302        saveCompletions(deduped);
9303        return deduped;
9304      }
9305      return completions;
9306    }
9307
9308    function updateButtonState(btn, completions) {
9309      const id = btn.dataset.completionId;
9310      const list = completions ?? getCompletions();
9311      btn.classList.toggle("completed", isCompleted(id, list));
9312    }
9313
9314    function checkModuleCompletion(completions) {
9315      const list = completions ?? getCompletions();
9316      document.querySelectorAll(".course-video").forEach((module) => {
9317        const btns = module.querySelectorAll(".complete-btn");
9318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
9319        const check = module.querySelector(".module-check");
9320        if (check) check.classList.toggle("visible", allCompleted);
9321      });
9322    }
9323
9324    function initCompletionButtons() {
9325      const completions = migrateCompletions();
9326
9327      document.querySelectorAll(".complete-btn").forEach((btn) => {
9328        if (btn.dataset.completionInit) return;
9329        btn.dataset.completionInit = "true";
9330        updateButtonState(btn, completions);
9331
9332        btn.addEventListener("click", (e) => {
9333          e.preventDefault();
9334          e.stopPropagation();
9335
9336          const id = btn.dataset.completionId;
9337          let list = getCompletions();
9338          const completed = isCompleted(id, list);
9339
9340          if (completed) {
9341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
9342            list = list.filter((c) =>
9342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
9343          } else {
9344            list.push(id);
9345          }
9346
9347          saveCompletions(list);
9348          updateButtonState(btn, list);
9349          checkModuleCompletion(list);
9350        });
9351      });
9352
9353      checkModuleCompletion(completions);
9354    }
9355
9356    if (document.readyState === "loading") {
9357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
9358    } else {
9359      initCompletionButtons();
9360    }
9361  })();
9362</script>
9362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Pvjs0h306pI" data-start="17797" data-end="19165" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>15. Preguntas finales</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>23 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-3-preguntas-finales" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
9362<script>
9363  (() => {
9364    if (window.__courseLazyPlayersInitialized) return;
9365    window.__courseLazyPlayersInitialized = true;
9366
9367    function createPlayer(details) {
9368      const videoId = details.dataset.videoId;
9369      const start = details.dataset.start;
9370      const end = details.dataset.end;
9371
9372      const container = document.createElement("div");
9373      container.className = "player-container";
9374
9375      const spinner = document.createElement("div");
9376      spinner.className = "spinner";
9377      container.appendChild(spinner);
9378
9379      const controller = document.createElement("media-controller");
9380      controller.setAttribute("defaultsubtitles", "");
9381
9382      const provider = document.createElement("youtube-media-provider");
9383      provider.setAttribute("slot", "media");
9384      provider.setAttribute("video-id", videoId);
9385      provider.setAttribute("start", start);
9386      provider.setAttribute("end", end);
9387
9388      const controlBar = document.createElement("media-control-bar");
9389
9390      const playBtn = document.createElement("media-play-button");
9391      const timeDisplay = document.createElement("media-time-display");
9392      timeDisplay.setAttribute("showduration", "");
9393      const timeRange = document.createElement("media-time-range");
9394      const playbackRate = document.createElement("media-playback-rate-button");
9395      const muteBtn = document.createElement("media-mute-button");
9396      const volumeRange = document.createElement("media-volume-range");
9397
9398      const captionsBtn = document.createElement("media-captions-button");
9399      captionsBtn.addEventListener("click", (e) => {
9400        e.stopPropagation();
9401        const provider = container.querySelector("youtube-media-provider");
9402        if (provider) provider.toggleCaptions();
9403      });
9404
9405      const qualityBtn = document.createElement("media-chrome-button");
9406      qualityBtn.textContent = "--";
9407      qualityBtn.addEventListener("click", () => {
9408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
9409      });
9410
9411      const fullscreenBtn = document.createElement("media-fullscreen-button");
9412
9413      controlBar.append(
9414        playBtn, timeDisplay, timeRange,
9415        playbackRate, muteBtn, volumeRange,
9416        captionsBtn, qualityBtn, fullscreenBtn
9417      );
9418
9419      const poster = document.createElement("media-poster-image");
9420      poster.setAttribute("slot", "poster");
9421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
9422
9423      controller.append(provider, poster, controlBar);
9424
9425      captionsBtn.setAttribute("aria-checked", "true");
9426      controller.setAttribute("mediasubtitlesshowing", "on");
9427
9428      container.appendChild(controller);
9429      details.appendChild(container);
9430
9431      provider.addEventListener("waiting", () => container.classList.add("loading"));
9432      provider.addEventListener("playing", () => container.classList.remove("loading"));
9433      provider.addEventListener("play", () => container.classList.remove("loading"));
9434
9435      provider._textTracks.addEventListener("change", () => {
9436        const enabled = provider._captionsEnabled;
9437        captionsBtn.setAttribute("aria-checked", enabled);
9438        if (enabled) {
9439          controller.setAttribute("mediasubtitlesshowing", "on");
9440        } else {
9441          controller.removeAttribute("mediasubtitlesshowing");
9442        }
9443      });
9444
9445      provider.addEventListener("qualitychange", (e) => {
9446        qualityBtn.textContent = e.detail.quality;
9447      });
9448    }
9449
9450    function initLazyPlayers() {
9451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
9452        if (details.dataset.lazyInit) return;
9453        details.dataset.lazyInit = "true";
9454        details.addEventListener("toggle", () => {
9455          if (details.open && !details.querySelector(".player-container")) {
9456            createPlayer(details);
9457          } else if (!details.open) {
9458            const provider = details.querySelector("youtube-media-provider");
9459            if (provider && typeof provider.pause === "function") {
9460              provider.pause();
9461            }
9462          }
9463        });
9464      });
9465    }
9466
9467    if (document.readyState === "loading") {
9468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
9469    } else {
9470      initLazyPlayers();
9471    }
9472  })();
9473</script>
9473<script>
9474  (() => {
9475    if (window.__courseCompletionsInitialized) return;
9476    window.__courseCompletionsInitialized = true;
9477
9478    const STORAGE_KEY = "course-completions";
9479
9480    function slugify(text) {
9481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9482    }
9483
9484    function getCompletions() {
9485      try {
9486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
9487      } catch {
9488        return [];
9489      }
9490    }
9491
9492    function saveCompletions(completions) {
9493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
9494    }
9495
9496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
9497    function canonicalFromStored(storedId) {
9498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9499      if (!m) return slugify(storedId);
9500      const [, slug, p, mv, title] = m;
9501      return `${slug}-${p}-${mv}-${slugify(title)}`;
9502    }
9503
9504    function canonicalPlusOne(storedId) {
9505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9506      if (!m) return null;
9507      const [, slug, p, mv, title] = m;
9508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
9509    }
9510
9511    function isCompleted(canonicalId, completions) {
9512      if (completions.includes(canonicalId)) return true;
9513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
9514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
9515    }
9516
9517    function migrateCompletions() {
9518      const completions = getCompletions();
9519      let changed = false;
9520      const migrated = completions.map((c) => {
9521        const canon = canonicalFromStored(c);
9522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
9523        const plusOne = canonicalPlusOne(c);
9524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
9525          if (plusOne !== c) changed = true;
9526          return plusOne;
9527        }
9528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
9529          changed = true;
9530          return canon;
9531        }
9532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
9533        if (canon !== c) {
9534          changed = true;
9535          return canon;
9536        }
9537        return c;
9538      });
9539      // Elimina duplicados tras migración
9540      const deduped = [...new Set(migrated)];
9541      if (changed || deduped.length !== completions.length) {
9542        saveCompletions(deduped);
9543        return deduped;
9544      }
9545      return completions;
9546    }
9547
9548    function updateButtonState(btn, completions) {
9549      const id = btn.dataset.completionId;
9550      const list = completions ?? getCompletions();
9551      btn.classList.toggle("completed", isCompleted(id, list));
9552    }
9553
9554    function checkModuleCompletion(completions) {
9555      const list = completions ?? getCompletions();
9556      document.querySelectorAll(".course-video").forEach((module) => {
9557        const btns = module.querySelectorAll(".complete-btn");
9558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
9559        const check = module.querySelector(".module-check");
9560        if (check) check.classList.toggle("visible", allCompleted);
9561      });
9562    }
9563
9564    function initCompletionButtons() {
9565      const completions = migrateCompletions();
9566
9567      document.querySelectorAll(".complete-btn").forEach((btn) => {
9568        if (btn.dataset.completionInit) return;
9569        btn.dataset.completionInit = "true";
9570        updateButtonState(btn, completions);
9571
9572        btn.addEventListener("click", (e) => {
9573          e.preventDefault();
9574          e.stopPropagation();
9575
9576          const id = btn.dataset.completionId;
9577          let list = getCompletions();
9578          const completed = isCompleted(id, list);
9579
9580          if (completed) {
9581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
9582            list = list.filter((c) =>
9582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
9583          } else {
9584            list.push(id);
9585          }
9586
9587          saveCompletions(list);
9588          updateButtonState(btn, list);
9589          checkModuleCompletion(list);
9590        });
9591      });
9592
9593      checkModuleCompletion(completions);
9594    }
9595
9596    if (document.readyState === "loading") {
9597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
9598    } else {
9599      initCompletionButtons();
9600    }
9601  })();
9602</script>
9602</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 4 📕 Inteligencia Artificial<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="687" data-end="1335" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Presentación inicial</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-presentacion-inicial" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
9602<script>
9603  (() => {
9604    if (window.__courseLazyPlayersInitialized) return;
9605    window.__courseLazyPlayersInitialized = true;
9606
9607    function createPlayer(details) {
9608      const videoId = details.dataset.videoId;
9609      const start = details.dataset.start;
9610      const end = details.dataset.end;
9611
9612      const container = document.createElement("div");
9613      container.className = "player-container";
9614
9615      const spinner = document.createElement("div");
9616      spinner.className = "spinner";
9617      container.appendChild(spinner);
9618
9619      const controller = document.createElement("media-controller");
9620      controller.setAttribute("defaultsubtitles", "");
9621
9622      const provider = document.createElement("youtube-media-provider");
9623      provider.setAttribute("slot", "media");
9624      provider.setAttribute("video-id", videoId);
9625      provider.setAttribute("start", start);
9626      provider.setAttribute("end", end);
9627
9628      const controlBar = document.createElement("media-control-bar");
9629
9630      const playBtn = document.createElement("media-play-button");
9631      const timeDisplay = document.createElement("media-time-display");
9632      timeDisplay.setAttribute("showduration", "");
9633      const timeRange = document.createElement("media-time-range");
9634      const playbackRate = document.createElement("media-playback-rate-button");
9635      const muteBtn = document.createElement("media-mute-button");
9636      const volumeRange = document.createElement("media-volume-range");
9637
9638      const captionsBtn = document.createElement("media-captions-button");
9639      captionsBtn.addEventListener("click", (e) => {
9640        e.stopPropagation();
9641        const provider = container.querySelector("youtube-media-provider");
9642        if (provider) provider.toggleCaptions();
9643      });
9644
9645      const qualityBtn = document.createElement("media-chrome-button");
9646      qualityBtn.textContent = "--";
9647      qualityBtn.addEventListener("click", () => {
9648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
9649      });
9650
9651      const fullscreenBtn = document.createElement("media-fullscreen-button");
9652
9653      controlBar.append(
9654        playBtn, timeDisplay, timeRange,
9655        playbackRate, muteBtn, volumeRange,
9656        captionsBtn, qualityBtn, fullscreenBtn
9657      );
9658
9659      const poster = document.createElement("media-poster-image");
9660      poster.setAttribute("slot", "poster");
9661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
9662
9663      controller.append(provider, poster, controlBar);
9664
9665      captionsBtn.setAttribute("aria-checked", "true");
9666      controller.setAttribute("mediasubtitlesshowing", "on");
9667
9668      container.appendChild(controller);
9669      details.appendChild(container);
9670
9671      provider.addEventListener("waiting", () => container.classList.add("loading"));
9672      provider.addEventListener("playing", () => container.classList.remove("loading"));
9673      provider.addEventListener("play", () => container.classList.remove("loading"));
9674
9675      provider._textTracks.addEventListener("change", () => {
9676        const enabled = provider._captionsEnabled;
9677        captionsBtn.setAttribute("aria-checked", enabled);
9678        if (enabled) {
9679          controller.setAttribute("mediasubtitlesshowing", "on");
9680        } else {
9681          controller.removeAttribute("mediasubtitlesshowing");
9682        }
9683      });
9684
9685      provider.addEventListener("qualitychange", (e) => {
9686        qualityBtn.textContent = e.detail.quality;
9687      });
9688    }
9689
9690    function initLazyPlayers() {
9691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
9692        if (details.dataset.lazyInit) return;
9693        details.dataset.lazyInit = "true";
9694        details.addEventListener("toggle", () => {
9695          if (details.open && !details.querySelector(".player-container")) {
9696            createPlayer(details);
9697          } else if (!details.open) {
9698            const provider = details.querySelector("youtube-media-provider");
9699            if (provider && typeof provider.pause === "function") {
9700              provider.pause();
9701            }
9702          }
9703        });
9704      });
9705    }
9706
9707    if (document.readyState === "loading") {
9708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
9709    } else {
9710      initLazyPlayers();
9711    }
9712  })();
9713</script>
9713<script>
9714  (() => {
9715    if (window.__courseCompletionsInitialized) return;
9716    window.__courseCompletionsInitialized = true;
9717
9718    const STORAGE_KEY = "course-completions";
9719
9720    function slugify(text) {
9721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9722    }
9723
9724    function getCompletions() {
9725      try {
9726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
9727      } catch {
9728        return [];
9729      }
9730    }
9731
9732    function saveCompletions(completions) {
9733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
9734    }
9735
9736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
9737    function canonicalFromStored(storedId) {
9738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9739      if (!m) return slugify(storedId);
9740      const [, slug, p, mv, title] = m;
9741      return `${slug}-${p}-${mv}-${slugify(title)}`;
9742    }
9743
9744    function canonicalPlusOne(storedId) {
9745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9746      if (!m) return null;
9747      const [, slug, p, mv, title] = m;
9748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
9749    }
9750
9751    function isCompleted(canonicalId, completions) {
9752      if (completions.includes(canonicalId)) return true;
9753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
9754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
9755    }
9756
9757    function migrateCompletions() {
9758      const completions = getCompletions();
9759      let changed = false;
9760      const migrated = completions.map((c) => {
9761        const canon = canonicalFromStored(c);
9762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
9763        const plusOne = canonicalPlusOne(c);
9764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
9765          if (plusOne !== c) changed = true;
9766          return plusOne;
9767        }
9768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
9769          changed = true;
9770          return canon;
9771        }
9772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
9773        if (canon !== c) {
9774          changed = true;
9775          return canon;
9776        }
9777        return c;
9778      });
9779      // Elimina duplicados tras migración
9780      const deduped = [...new Set(migrated)];
9781      if (changed || deduped.length !== completions.length) {
9782        saveCompletions(deduped);
9783        return deduped;
9784      }
9785      return completions;
9786    }
9787
9788    function updateButtonState(btn, completions) {
9789      const id = btn.dataset.completionId;
9790      const list = completions ?? getCompletions();
9791      btn.classList.toggle("completed", isCompleted(id, list));
9792    }
9793
9794    function checkModuleCompletion(completions) {
9795      const list = completions ?? getCompletions();
9796      document.querySelectorAll(".course-video").forEach((module) => {
9797        const btns = module.querySelectorAll(".complete-btn");
9798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
9799        const check = module.querySelector(".module-check");
9800        if (check) check.classList.toggle("visible", allCompleted);
9801      });
9802    }
9803
9804    function initCompletionButtons() {
9805      const completions = migrateCompletions();
9806
9807      document.querySelectorAll(".complete-btn").forEach((btn) => {
9808        if (btn.dataset.completionInit) return;
9809        btn.dataset.completionInit = "true";
9810        updateButtonState(btn, completions);
9811
9812        btn.addEventListener("click", (e) => {
9813          e.preventDefault();
9814          e.stopPropagation();
9815
9816          const id = btn.dataset.completionId;
9817          let list = getCompletions();
9818          const completed = isCompleted(id, list);
9819
9820          if (completed) {
9821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
9822            list = list.filter((c) =>
9822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
9823          } else {
9824            list.push(id);
9825          }
9826
9827          saveCompletions(list);
9828          updateButtonState(btn, list);
9829          checkModuleCompletion(list);
9830        });
9831      });
9832
9833      checkModuleCompletion(completions);
9834    }
9835
9836    if (document.readyState === "loading") {
9837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
9838    } else {
9839      initCompletionButtons();
9840    }
9841  })();
9842</script>
9842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="1335" data-end="2053" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Desmitificación &quot;Voy a usar IA&quot;</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>12 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-desmitificacion-voy-a-usar-ia" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
9842<script>
9843  (() => {
9844    if (window.__courseLazyPlayersInitialized) return;
9845    window.__courseLazyPlayersInitialized = true;
9846
9847    function createPlayer(details) {
9848      const videoId = details.dataset.videoId;
9849      const start = details.dataset.start;
9850      const end = details.dataset.end;
9851
9852      const container = document.createElement("div");
9853      container.className = "player-container";
9854
9855      const spinner = document.createElement("div");
9856      spinner.className = "spinner";
9857      container.appendChild(spinner);
9858
9859      const controller = document.createElement("media-controller");
9860      controller.setAttribute("defaultsubtitles", "");
9861
9862      const provider = document.createElement("youtube-media-provider");
9863      provider.setAttribute("slot", "media");
9864      provider.setAttribute("video-id", videoId);
9865      provider.setAttribute("start", start);
9866      provider.setAttribute("end", end);
9867
9868      const controlBar = document.createElement("media-control-bar");
9869
9870      const playBtn = document.createElement("media-play-button");
9871      const timeDisplay = document.createElement("media-time-display");
9872      timeDisplay.setAttribute("showduration", "");
9873      const timeRange = document.createElement("media-time-range");
9874      const playbackRate = document.createElement("media-playback-rate-button");
9875      const muteBtn = document.createElement("media-mute-button");
9876      const volumeRange = document.createElement("media-volume-range");
9877
9878      const captionsBtn = document.createElement("media-captions-button");
9879      captionsBtn.addEventListener("click", (e) => {
9880        e.stopPropagation();
9881        const provider = container.querySelector("youtube-media-provider");
9882        if (provider) provider.toggleCaptions();
9883      });
9884
9885      const qualityBtn = document.createElement("media-chrome-button");
9886      qualityBtn.textContent = "--";
9887      qualityBtn.addEventListener("click", () => {
9888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
9889      });
9890
9891      const fullscreenBtn = document.createElement("media-fullscreen-button");
9892
9893      controlBar.append(
9894        playBtn, timeDisplay, timeRange,
9895        playbackRate, muteBtn, volumeRange,
9896        captionsBtn, qualityBtn, fullscreenBtn
9897      );
9898
9899      const poster = document.createElement("media-poster-image");
9900      poster.setAttribute("slot", "poster");
9901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
9902
9903      controller.append(provider, poster, controlBar);
9904
9905      captionsBtn.setAttribute("aria-checked", "true");
9906      controller.setAttribute("mediasubtitlesshowing", "on");
9907
9908      container.appendChild(controller);
9909      details.appendChild(container);
9910
9911      provider.addEventListener("waiting", () => container.classList.add("loading"));
9912      provider.addEventListener("playing", () => container.classList.remove("loading"));
9913      provider.addEventListener("play", () => container.classList.remove("loading"));
9914
9915      provider._textTracks.addEventListener("change", () => {
9916        const enabled = provider._captionsEnabled;
9917        captionsBtn.setAttribute("aria-checked", enabled);
9918        if (enabled) {
9919          controller.setAttribute("mediasubtitlesshowing", "on");
9920        } else {
9921          controller.removeAttribute("mediasubtitlesshowing");
9922        }
9923      });
9924
9925      provider.addEventListener("qualitychange", (e) => {
9926        qualityBtn.textContent = e.detail.quality;
9927      });
9928    }
9929
9930    function initLazyPlayers() {
9931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
9932        if (details.dataset.lazyInit) return;
9933        details.dataset.lazyInit = "true";
9934        details.addEventListener("toggle", () => {
9935          if (details.open && !details.querySelector(".player-container")) {
9936            createPlayer(details);
9937          } else if (!details.open) {
9938            const provider = details.querySelector("youtube-media-provider");
9939            if (provider && typeof provider.pause === "function") {
9940              provider.pause();
9941            }
9942          }
9943        });
9944      });
9945    }
9946
9947    if (document.readyState === "loading") {
9948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
9949    } else {
9950      initLazyPlayers();
9951    }
9952  })();
9953</script>
9953<script>
9954  (() => {
9955    if (window.__courseCompletionsInitialized) return;
9956    window.__courseCompletionsInitialized = true;
9957
9958    const STORAGE_KEY = "course-completions";
9959
9960    function slugify(text) {
9961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9962    }
9963
9964    function getCompletions() {
9965      try {
9966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
9967      } catch {
9968        return [];
9969      }
9970    }
9971
9972    function saveCompletions(completions) {
9973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
9974    }
9975
9976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
9977    function canonicalFromStored(storedId) {
9978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9979      if (!m) return slugify(storedId);
9980      const [, slug, p, mv, title] = m;
9981      return `${slug}-${p}-${mv}-${slugify(title)}`;
9982    }
9983
9984    function canonicalPlusOne(storedId) {
9985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
9986      if (!m) return null;
9987      const [, slug, p, mv, title] = m;
9988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
9989    }
9990
9991    function isCompleted(canonicalId, completions) {
9992      if (completions.includes(canonicalId)) return true;
9993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
9994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
9995    }
9996
9997    function migrateCompletions() {
9998      const completions = getCompletions();
9999      let changed = false;
10000      const migrated = completions.map((c) => {
10001        const canon = canonicalFromStored(c);
10002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
10003        const plusOne = canonicalPlusOne(c);
10004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
10005          if (plusOne !== c) changed = true;
10006          return plusOne;
10007        }
10008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
10009          changed = true;
10010          return canon;
10011        }
10012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
10013        if (canon !== c) {
10014          changed = true;
10015          return canon;
10016        }
10017        return c;
10018      });
10019      // Elimina duplicados tras migración
10020      const deduped = [...new Set(migrated)];
10021      if (changed || deduped.length !== completions.length) {
10022        saveCompletions(deduped);
10023        return deduped;
10024      }
10025      return completions;
10026    }
10027
10028    function updateButtonState(btn, completions) {
10029      const id = btn.dataset.completionId;
10030      const list = completions ?? getCompletions();
10031      btn.classList.toggle("completed", isCompleted(id, list));
10032    }
10033
10034    function checkModuleCompletion(completions) {
10035      const list = completions ?? getCompletions();
10036      document.querySelectorAll(".course-video").forEach((module) => {
10037        const btns = module.querySelectorAll(".complete-btn");
10038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
10039        const check = module.querySelector(".module-check");
10040        if (check) check.classList.toggle("visible", allCompleted);
10041      });
10042    }
10043
10044    function initCompletionButtons() {
10045      const completions = migrateCompletions();
10046
10047      document.querySelectorAll(".complete-btn").forEach((btn) => {
10048        if (btn.dataset.completionInit) return;
10049        btn.dataset.completionInit = "true";
10050        updateButtonState(btn, completions);
10051
10052        btn.addEventListener("click", (e) => {
10053          e.preventDefault();
10054          e.stopPropagation();
10055
10056          const id = btn.dataset.completionId;
10057          let list = getCompletions();
10058          const completed = isCompleted(id, list);
10059
10060          if (completed) {
10061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
10062            list = list.filter((c) =>
10062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
10063          } else {
10064            list.push(id);
10065          }
10066
10067          saveCompletions(list);
10068          updateButtonState(btn, list);
10069          checkModuleCompletion(list);
10070        });
10071      });
10072
10073      checkModuleCompletion(completions);
10074    }
10075
10076    if (document.readyState === "loading") {
10077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
10078    } else {
10079      initCompletionButtons();
10080    }
10081  })();
10082</script>
10082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="2053" data-end="3145" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Evolución IA para desarrollo</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-evolucion-ia-para-desarrollo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
10082<script>
10083  (() => {
10084    if (window.__courseLazyPlayersInitialized) return;
10085    window.__courseLazyPlayersInitialized = true;
10086
10087    function createPlayer(details) {
10088      const videoId = details.dataset.videoId;
10089      const start = details.dataset.start;
10090      const end = details.dataset.end;
10091
10092      const container = document.createElement("div");
10093      container.className = "player-container";
10094
10095      const spinner = document.createElement("div");
10096      spinner.className = "spinner";
10097      container.appendChild(spinner);
10098
10099      const controller = document.createElement("media-controller");
10100      controller.setAttribute("defaultsubtitles", "");
10101
10102      const provider = document.createElement("youtube-media-provider");
10103      provider.setAttribute("slot", "media");
10104      provider.setAttribute("video-id", videoId);
10105      provider.setAttribute("start", start);
10106      provider.setAttribute("end", end);
10107
10108      const controlBar = document.createElement("media-control-bar");
10109
10110      const playBtn = document.createElement("media-play-button");
10111      const timeDisplay = document.createElement("media-time-display");
10112      timeDisplay.setAttribute("showduration", "");
10113      const timeRange = document.createElement("media-time-range");
10114      const playbackRate = document.createElement("media-playback-rate-button");
10115      const muteBtn = document.createElement("media-mute-button");
10116      const volumeRange = document.createElement("media-volume-range");
10117
10118      const captionsBtn = document.createElement("media-captions-button");
10119      captionsBtn.addEventListener("click", (e) => {
10120        e.stopPropagation();
10121        const provider = container.querySelector("youtube-media-provider");
10122        if (provider) provider.toggleCaptions();
10123      });
10124
10125      const qualityBtn = document.createElement("media-chrome-button");
10126      qualityBtn.textContent = "--";
10127      qualityBtn.addEventListener("click", () => {
10128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
10129      });
10130
10131      const fullscreenBtn = document.createElement("media-fullscreen-button");
10132
10133      controlBar.append(
10134        playBtn, timeDisplay, timeRange,
10135        playbackRate, muteBtn, volumeRange,
10136        captionsBtn, qualityBtn, fullscreenBtn
10137      );
10138
10139      const poster = document.createElement("media-poster-image");
10140      poster.setAttribute("slot", "poster");
10141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
10142
10143      controller.append(provider, poster, controlBar);
10144
10145      captionsBtn.setAttribute("aria-checked", "true");
10146      controller.setAttribute("mediasubtitlesshowing", "on");
10147
10148      container.appendChild(controller);
10149      details.appendChild(container);
10150
10151      provider.addEventListener("waiting", () => container.classList.add("loading"));
10152      provider.addEventListener("playing", () => container.classList.remove("loading"));
10153      provider.addEventListener("play", () => container.classList.remove("loading"));
10154
10155      provider._textTracks.addEventListener("change", () => {
10156        const enabled = provider._captionsEnabled;
10157        captionsBtn.setAttribute("aria-checked", enabled);
10158        if (enabled) {
10159          controller.setAttribute("mediasubtitlesshowing", "on");
10160        } else {
10161          controller.removeAttribute("mediasubtitlesshowing");
10162        }
10163      });
10164
10165      provider.addEventListener("qualitychange", (e) => {
10166        qualityBtn.textContent = e.detail.quality;
10167      });
10168    }
10169
10170    function initLazyPlayers() {
10171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
10172        if (details.dataset.lazyInit) return;
10173        details.dataset.lazyInit = "true";
10174        details.addEventListener("toggle", () => {
10175          if (details.open && !details.querySelector(".player-container")) {
10176            createPlayer(details);
10177          } else if (!details.open) {
10178            const provider = details.querySelector("youtube-media-provider");
10179            if (provider && typeof provider.pause === "function") {
10180              provider.pause();
10181            }
10182          }
10183        });
10184      });
10185    }
10186
10187    if (document.readyState === "loading") {
10188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
10189    } else {
10190      initLazyPlayers();
10191    }
10192  })();
10193</script>
10193<script>
10194  (() => {
10195    if (window.__courseCompletionsInitialized) return;
10196    window.__courseCompletionsInitialized = true;
10197
10198    const STORAGE_KEY = "course-completions";
10199
10200    function slugify(text) {
10201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10202    }
10203
10204    function getCompletions() {
10205      try {
10206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
10207      } catch {
10208        return [];
10209      }
10210    }
10211
10212    function saveCompletions(completions) {
10213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
10214    }
10215
10216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
10217    function canonicalFromStored(storedId) {
10218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10219      if (!m) return slugify(storedId);
10220      const [, slug, p, mv, title] = m;
10221      return `${slug}-${p}-${mv}-${slugify(title)}`;
10222    }
10223
10224    function canonicalPlusOne(storedId) {
10225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10226      if (!m) return null;
10227      const [, slug, p, mv, title] = m;
10228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
10229    }
10230
10231    function isCompleted(canonicalId, completions) {
10232      if (completions.includes(canonicalId)) return true;
10233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
10234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
10235    }
10236
10237    function migrateCompletions() {
10238      const completions = getCompletions();
10239      let changed = false;
10240      const migrated = completions.map((c) => {
10241        const canon = canonicalFromStored(c);
10242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
10243        const plusOne = canonicalPlusOne(c);
10244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
10245          if (plusOne !== c) changed = true;
10246          return plusOne;
10247        }
10248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
10249          changed = true;
10250          return canon;
10251        }
10252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
10253        if (canon !== c) {
10254          changed = true;
10255          return canon;
10256        }
10257        return c;
10258      });
10259      // Elimina duplicados tras migración
10260      const deduped = [...new Set(migrated)];
10261      if (changed || deduped.length !== completions.length) {
10262        saveCompletions(deduped);
10263        return deduped;
10264      }
10265      return completions;
10266    }
10267
10268    function updateButtonState(btn, completions) {
10269      const id = btn.dataset.completionId;
10270      const list = completions ?? getCompletions();
10271      btn.classList.toggle("completed", isCompleted(id, list));
10272    }
10273
10274    function checkModuleCompletion(completions) {
10275      const list = completions ?? getCompletions();
10276      document.querySelectorAll(".course-video").forEach((module) => {
10277        const btns = module.querySelectorAll(".complete-btn");
10278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
10279        const check = module.querySelector(".module-check");
10280        if (check) check.classList.toggle("visible", allCompleted);
10281      });
10282    }
10283
10284    function initCompletionButtons() {
10285      const completions = migrateCompletions();
10286
10287      document.querySelectorAll(".complete-btn").forEach((btn) => {
10288        if (btn.dataset.completionInit) return;
10289        btn.dataset.completionInit = "true";
10290        updateButtonState(btn, completions);
10291
10292        btn.addEventListener("click", (e) => {
10293          e.preventDefault();
10294          e.stopPropagation();
10295
10296          const id = btn.dataset.completionId;
10297          let list = getCompletions();
10298          const completed = isCompleted(id, list);
10299
10300          if (completed) {
10301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
10302            list = list.filter((c) =>
10302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
10303          } else {
10304            list.push(id);
10305          }
10306
10307          saveCompletions(list);
10308          updateButtonState(btn, list);
10309          checkModuleCompletion(list);
10310        });
10311      });
10312
10313      checkModuleCompletion(completions);
10314    }
10315
10316    if (document.readyState === "loading") {
10317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
10318    } else {
10319      initCompletionButtons();
10320    }
10321  })();
10322</script>
10322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="3145" data-end="4215" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Vibe Coding / Vibe Learning</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-vibe-coding-vibe-learning" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
10322<script>
10323  (() => {
10324    if (window.__courseLazyPlayersInitialized) return;
10325    window.__courseLazyPlayersInitialized = true;
10326
10327    function createPlayer(details) {
10328      const videoId = details.dataset.videoId;
10329      const start = details.dataset.start;
10330      const end = details.dataset.end;
10331
10332      const container = document.createElement("div");
10333      container.className = "player-container";
10334
10335      const spinner = document.createElement("div");
10336      spinner.className = "spinner";
10337      container.appendChild(spinner);
10338
10339      const controller = document.createElement("media-controller");
10340      controller.setAttribute("defaultsubtitles", "");
10341
10342      const provider = document.createElement("youtube-media-provider");
10343      provider.setAttribute("slot", "media");
10344      provider.setAttribute("video-id", videoId);
10345      provider.setAttribute("start", start);
10346      provider.setAttribute("end", end);
10347
10348      const controlBar = document.createElement("media-control-bar");
10349
10350      const playBtn = document.createElement("media-play-button");
10351      const timeDisplay = document.createElement("media-time-display");
10352      timeDisplay.setAttribute("showduration", "");
10353      const timeRange = document.createElement("media-time-range");
10354      const playbackRate = document.createElement("media-playback-rate-button");
10355      const muteBtn = document.createElement("media-mute-button");
10356      const volumeRange = document.createElement("media-volume-range");
10357
10358      const captionsBtn = document.createElement("media-captions-button");
10359      captionsBtn.addEventListener("click", (e) => {
10360        e.stopPropagation();
10361        const provider = container.querySelector("youtube-media-provider");
10362        if (provider) provider.toggleCaptions();
10363      });
10364
10365      const qualityBtn = document.createElement("media-chrome-button");
10366      qualityBtn.textContent = "--";
10367      qualityBtn.addEventListener("click", () => {
10368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
10369      });
10370
10371      const fullscreenBtn = document.createElement("media-fullscreen-button");
10372
10373      controlBar.append(
10374        playBtn, timeDisplay, timeRange,
10375        playbackRate, muteBtn, volumeRange,
10376        captionsBtn, qualityBtn, fullscreenBtn
10377      );
10378
10379      const poster = document.createElement("media-poster-image");
10380      poster.setAttribute("slot", "poster");
10381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
10382
10383      controller.append(provider, poster, controlBar);
10384
10385      captionsBtn.setAttribute("aria-checked", "true");
10386      controller.setAttribute("mediasubtitlesshowing", "on");
10387
10388      container.appendChild(controller);
10389      details.appendChild(container);
10390
10391      provider.addEventListener("waiting", () => container.classList.add("loading"));
10392      provider.addEventListener("playing", () => container.classList.remove("loading"));
10393      provider.addEventListener("play", () => container.classList.remove("loading"));
10394
10395      provider._textTracks.addEventListener("change", () => {
10396        const enabled = provider._captionsEnabled;
10397        captionsBtn.setAttribute("aria-checked", enabled);
10398        if (enabled) {
10399          controller.setAttribute("mediasubtitlesshowing", "on");
10400        } else {
10401          controller.removeAttribute("mediasubtitlesshowing");
10402        }
10403      });
10404
10405      provider.addEventListener("qualitychange", (e) => {
10406        qualityBtn.textContent = e.detail.quality;
10407      });
10408    }
10409
10410    function initLazyPlayers() {
10411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
10412        if (details.dataset.lazyInit) return;
10413        details.dataset.lazyInit = "true";
10414        details.addEventListener("toggle", () => {
10415          if (details.open && !details.querySelector(".player-container")) {
10416            createPlayer(details);
10417          } else if (!details.open) {
10418            const provider = details.querySelector("youtube-media-provider");
10419            if (provider && typeof provider.pause === "function") {
10420              provider.pause();
10421            }
10422          }
10423        });
10424      });
10425    }
10426
10427    if (document.readyState === "loading") {
10428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
10429    } else {
10430      initLazyPlayers();
10431    }
10432  })();
10433</script>
10433<script>
10434  (() => {
10435    if (window.__courseCompletionsInitialized) return;
10436    window.__courseCompletionsInitialized = true;
10437
10438    const STORAGE_KEY = "course-completions";
10439
10440    function slugify(text) {
10441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10442    }
10443
10444    function getCompletions() {
10445      try {
10446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
10447      } catch {
10448        return [];
10449      }
10450    }
10451
10452    function saveCompletions(completions) {
10453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
10454    }
10455
10456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
10457    function canonicalFromStored(storedId) {
10458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10459      if (!m) return slugify(storedId);
10460      const [, slug, p, mv, title] = m;
10461      return `${slug}-${p}-${mv}-${slugify(title)}`;
10462    }
10463
10464    function canonicalPlusOne(storedId) {
10465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10466      if (!m) return null;
10467      const [, slug, p, mv, title] = m;
10468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
10469    }
10470
10471    function isCompleted(canonicalId, completions) {
10472      if (completions.includes(canonicalId)) return true;
10473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
10474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
10475    }
10476
10477    function migrateCompletions() {
10478      const completions = getCompletions();
10479      let changed = false;
10480      const migrated = completions.map((c) => {
10481        const canon = canonicalFromStored(c);
10482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
10483        const plusOne = canonicalPlusOne(c);
10484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
10485          if (plusOne !== c) changed = true;
10486          return plusOne;
10487        }
10488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
10489          changed = true;
10490          return canon;
10491        }
10492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
10493        if (canon !== c) {
10494          changed = true;
10495          return canon;
10496        }
10497        return c;
10498      });
10499      // Elimina duplicados tras migración
10500      const deduped = [...new Set(migrated)];
10501      if (changed || deduped.length !== completions.length) {
10502        saveCompletions(deduped);
10503        return deduped;
10504      }
10505      return completions;
10506    }
10507
10508    function updateButtonState(btn, completions) {
10509      const id = btn.dataset.completionId;
10510      const list = completions ?? getCompletions();
10511      btn.classList.toggle("completed", isCompleted(id, list));
10512    }
10513
10514    function checkModuleCompletion(completions) {
10515      const list = completions ?? getCompletions();
10516      document.querySelectorAll(".course-video").forEach((module) => {
10517        const btns = module.querySelectorAll(".complete-btn");
10518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
10519        const check = module.querySelector(".module-check");
10520        if (check) check.classList.toggle("visible", allCompleted);
10521      });
10522    }
10523
10524    function initCompletionButtons() {
10525      const completions = migrateCompletions();
10526
10527      document.querySelectorAll(".complete-btn").forEach((btn) => {
10528        if (btn.dataset.completionInit) return;
10529        btn.dataset.completionInit = "true";
10530        updateButtonState(btn, completions);
10531
10532        btn.addEventListener("click", (e) => {
10533          e.preventDefault();
10534          e.stopPropagation();
10535
10536          const id = btn.dataset.completionId;
10537          let list = getCompletions();
10538          const completed = isCompleted(id, list);
10539
10540          if (completed) {
10541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
10542            list = list.filter((c) =>
10542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
10543          } else {
10544            list.push(id);
10545          }
10546
10547          saveCompletions(list);
10548          updateButtonState(btn, list);
10549          checkModuleCompletion(list);
10550        });
10551      });
10552
10553      checkModuleCompletion(completions);
10554    }
10555
10556    if (document.readyState === "loading") {
10557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
10558    } else {
10559      initCompletionButtons();
10560    }
10561  })();
10562</script>
10562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="4215" data-end="5840" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. LLM (Loros estocásticos)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>27 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-llm-loros-estocasticos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
10562<script>
10563  (() => {
10564    if (window.__courseLazyPlayersInitialized) return;
10565    window.__courseLazyPlayersInitialized = true;
10566
10567    function createPlayer(details) {
10568      const videoId = details.dataset.videoId;
10569      const start = details.dataset.start;
10570      const end = details.dataset.end;
10571
10572      const container = document.createElement("div");
10573      container.className = "player-container";
10574
10575      const spinner = document.createElement("div");
10576      spinner.className = "spinner";
10577      container.appendChild(spinner);
10578
10579      const controller = document.createElement("media-controller");
10580      controller.setAttribute("defaultsubtitles", "");
10581
10582      const provider = document.createElement("youtube-media-provider");
10583      provider.setAttribute("slot", "media");
10584      provider.setAttribute("video-id", videoId);
10585      provider.setAttribute("start", start);
10586      provider.setAttribute("end", end);
10587
10588      const controlBar = document.createElement("media-control-bar");
10589
10590      const playBtn = document.createElement("media-play-button");
10591      const timeDisplay = document.createElement("media-time-display");
10592      timeDisplay.setAttribute("showduration", "");
10593      const timeRange = document.createElement("media-time-range");
10594      const playbackRate = document.createElement("media-playback-rate-button");
10595      const muteBtn = document.createElement("media-mute-button");
10596      const volumeRange = document.createElement("media-volume-range");
10597
10598      const captionsBtn = document.createElement("media-captions-button");
10599      captionsBtn.addEventListener("click", (e) => {
10600        e.stopPropagation();
10601        const provider = container.querySelector("youtube-media-provider");
10602        if (provider) provider.toggleCaptions();
10603      });
10604
10605      const qualityBtn = document.createElement("media-chrome-button");
10606      qualityBtn.textContent = "--";
10607      qualityBtn.addEventListener("click", () => {
10608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
10609      });
10610
10611      const fullscreenBtn = document.createElement("media-fullscreen-button");
10612
10613      controlBar.append(
10614        playBtn, timeDisplay, timeRange,
10615        playbackRate, muteBtn, volumeRange,
10616        captionsBtn, qualityBtn, fullscreenBtn
10617      );
10618
10619      const poster = document.createElement("media-poster-image");
10620      poster.setAttribute("slot", "poster");
10621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
10622
10623      controller.append(provider, poster, controlBar);
10624
10625      captionsBtn.setAttribute("aria-checked", "true");
10626      controller.setAttribute("mediasubtitlesshowing", "on");
10627
10628      container.appendChild(controller);
10629      details.appendChild(container);
10630
10631      provider.addEventListener("waiting", () => container.classList.add("loading"));
10632      provider.addEventListener("playing", () => container.classList.remove("loading"));
10633      provider.addEventListener("play", () => container.classList.remove("loading"));
10634
10635      provider._textTracks.addEventListener("change", () => {
10636        const enabled = provider._captionsEnabled;
10637        captionsBtn.setAttribute("aria-checked", enabled);
10638        if (enabled) {
10639          controller.setAttribute("mediasubtitlesshowing", "on");
10640        } else {
10641          controller.removeAttribute("mediasubtitlesshowing");
10642        }
10643      });
10644
10645      provider.addEventListener("qualitychange", (e) => {
10646        qualityBtn.textContent = e.detail.quality;
10647      });
10648    }
10649
10650    function initLazyPlayers() {
10651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
10652        if (details.dataset.lazyInit) return;
10653        details.dataset.lazyInit = "true";
10654        details.addEventListener("toggle", () => {
10655          if (details.open && !details.querySelector(".player-container")) {
10656            createPlayer(details);
10657          } else if (!details.open) {
10658            const provider = details.querySelector("youtube-media-provider");
10659            if (provider && typeof provider.pause === "function") {
10660              provider.pause();
10661            }
10662          }
10663        });
10664      });
10665    }
10666
10667    if (document.readyState === "loading") {
10668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
10669    } else {
10670      initLazyPlayers();
10671    }
10672  })();
10673</script>
10673<script>
10674  (() => {
10675    if (window.__courseCompletionsInitialized) return;
10676    window.__courseCompletionsInitialized = true;
10677
10678    const STORAGE_KEY = "course-completions";
10679
10680    function slugify(text) {
10681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10682    }
10683
10684    function getCompletions() {
10685      try {
10686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
10687      } catch {
10688        return [];
10689      }
10690    }
10691
10692    function saveCompletions(completions) {
10693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
10694    }
10695
10696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
10697    function canonicalFromStored(storedId) {
10698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10699      if (!m) return slugify(storedId);
10700      const [, slug, p, mv, title] = m;
10701      return `${slug}-${p}-${mv}-${slugify(title)}`;
10702    }
10703
10704    function canonicalPlusOne(storedId) {
10705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10706      if (!m) return null;
10707      const [, slug, p, mv, title] = m;
10708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
10709    }
10710
10711    function isCompleted(canonicalId, completions) {
10712      if (completions.includes(canonicalId)) return true;
10713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
10714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
10715    }
10716
10717    function migrateCompletions() {
10718      const completions = getCompletions();
10719      let changed = false;
10720      const migrated = completions.map((c) => {
10721        const canon = canonicalFromStored(c);
10722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
10723        const plusOne = canonicalPlusOne(c);
10724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
10725          if (plusOne !== c) changed = true;
10726          return plusOne;
10727        }
10728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
10729          changed = true;
10730          return canon;
10731        }
10732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
10733        if (canon !== c) {
10734          changed = true;
10735          return canon;
10736        }
10737        return c;
10738      });
10739      // Elimina duplicados tras migración
10740      const deduped = [...new Set(migrated)];
10741      if (changed || deduped.length !== completions.length) {
10742        saveCompletions(deduped);
10743        return deduped;
10744      }
10745      return completions;
10746    }
10747
10748    function updateButtonState(btn, completions) {
10749      const id = btn.dataset.completionId;
10750      const list = completions ?? getCompletions();
10751      btn.classList.toggle("completed", isCompleted(id, list));
10752    }
10753
10754    function checkModuleCompletion(completions) {
10755      const list = completions ?? getCompletions();
10756      document.querySelectorAll(".course-video").forEach((module) => {
10757        const btns = module.querySelectorAll(".complete-btn");
10758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
10759        const check = module.querySelector(".module-check");
10760        if (check) check.classList.toggle("visible", allCompleted);
10761      });
10762    }
10763
10764    function initCompletionButtons() {
10765      const completions = migrateCompletions();
10766
10767      document.querySelectorAll(".complete-btn").forEach((btn) => {
10768        if (btn.dataset.completionInit) return;
10769        btn.dataset.completionInit = "true";
10770        updateButtonState(btn, completions);
10771
10772        btn.addEventListener("click", (e) => {
10773          e.preventDefault();
10774          e.stopPropagation();
10775
10776          const id = btn.dataset.completionId;
10777          let list = getCompletions();
10778          const completed = isCompleted(id, list);
10779
10780          if (completed) {
10781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
10782            list = list.filter((c) =>
10782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
10783          } else {
10784            list.push(id);
10785          }
10786
10787          saveCompletions(list);
10788          updateButtonState(btn, list);
10789          checkModuleCompletion(list);
10790        });
10791      });
10792
10793      checkModuleCompletion(completions);
10794    }
10795
10796    if (document.readyState === "loading") {
10797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
10798    } else {
10799      initCompletionButtons();
10800    }
10801  })();
10802</script>
10802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="5840" data-end="7762" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Modelos LLM</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>32 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-modelos-llm" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
10802<script>
10803  (() => {
10804    if (window.__courseLazyPlayersInitialized) return;
10805    window.__courseLazyPlayersInitialized = true;
10806
10807    function createPlayer(details) {
10808      const videoId = details.dataset.videoId;
10809      const start = details.dataset.start;
10810      const end = details.dataset.end;
10811
10812      const container = document.createElement("div");
10813      container.className = "player-container";
10814
10815      const spinner = document.createElement("div");
10816      spinner.className = "spinner";
10817      container.appendChild(spinner);
10818
10819      const controller = document.createElement("media-controller");
10820      controller.setAttribute("defaultsubtitles", "");
10821
10822      const provider = document.createElement("youtube-media-provider");
10823      provider.setAttribute("slot", "media");
10824      provider.setAttribute("video-id", videoId);
10825      provider.setAttribute("start", start);
10826      provider.setAttribute("end", end);
10827
10828      const controlBar = document.createElement("media-control-bar");
10829
10830      const playBtn = document.createElement("media-play-button");
10831      const timeDisplay = document.createElement("media-time-display");
10832      timeDisplay.setAttribute("showduration", "");
10833      const timeRange = document.createElement("media-time-range");
10834      const playbackRate = document.createElement("media-playback-rate-button");
10835      const muteBtn = document.createElement("media-mute-button");
10836      const volumeRange = document.createElement("media-volume-range");
10837
10838      const captionsBtn = document.createElement("media-captions-button");
10839      captionsBtn.addEventListener("click", (e) => {
10840        e.stopPropagation();
10841        const provider = container.querySelector("youtube-media-provider");
10842        if (provider) provider.toggleCaptions();
10843      });
10844
10845      const qualityBtn = document.createElement("media-chrome-button");
10846      qualityBtn.textContent = "--";
10847      qualityBtn.addEventListener("click", () => {
10848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
10849      });
10850
10851      const fullscreenBtn = document.createElement("media-fullscreen-button");
10852
10853      controlBar.append(
10854        playBtn, timeDisplay, timeRange,
10855        playbackRate, muteBtn, volumeRange,
10856        captionsBtn, qualityBtn, fullscreenBtn
10857      );
10858
10859      const poster = document.createElement("media-poster-image");
10860      poster.setAttribute("slot", "poster");
10861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
10862
10863      controller.append(provider, poster, controlBar);
10864
10865      captionsBtn.setAttribute("aria-checked", "true");
10866      controller.setAttribute("mediasubtitlesshowing", "on");
10867
10868      container.appendChild(controller);
10869      details.appendChild(container);
10870
10871      provider.addEventListener("waiting", () => container.classList.add("loading"));
10872      provider.addEventListener("playing", () => container.classList.remove("loading"));
10873      provider.addEventListener("play", () => container.classList.remove("loading"));
10874
10875      provider._textTracks.addEventListener("change", () => {
10876        const enabled = provider._captionsEnabled;
10877        captionsBtn.setAttribute("aria-checked", enabled);
10878        if (enabled) {
10879          controller.setAttribute("mediasubtitlesshowing", "on");
10880        } else {
10881          controller.removeAttribute("mediasubtitlesshowing");
10882        }
10883      });
10884
10885      provider.addEventListener("qualitychange", (e) => {
10886        qualityBtn.textContent = e.detail.quality;
10887      });
10888    }
10889
10890    function initLazyPlayers() {
10891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
10892        if (details.dataset.lazyInit) return;
10893        details.dataset.lazyInit = "true";
10894        details.addEventListener("toggle", () => {
10895          if (details.open && !details.querySelector(".player-container")) {
10896            createPlayer(details);
10897          } else if (!details.open) {
10898            const provider = details.querySelector("youtube-media-provider");
10899            if (provider && typeof provider.pause === "function") {
10900              provider.pause();
10901            }
10902          }
10903        });
10904      });
10905    }
10906
10907    if (document.readyState === "loading") {
10908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
10909    } else {
10910      initLazyPlayers();
10911    }
10912  })();
10913</script>
10913<script>
10914  (() => {
10915    if (window.__courseCompletionsInitialized) return;
10916    window.__courseCompletionsInitialized = true;
10917
10918    const STORAGE_KEY = "course-completions";
10919
10920    function slugify(text) {
10921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10922    }
10923
10924    function getCompletions() {
10925      try {
10926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
10927      } catch {
10928        return [];
10929      }
10930    }
10931
10932    function saveCompletions(completions) {
10933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
10934    }
10935
10936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
10937    function canonicalFromStored(storedId) {
10938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10939      if (!m) return slugify(storedId);
10940      const [, slug, p, mv, title] = m;
10941      return `${slug}-${p}-${mv}-${slugify(title)}`;
10942    }
10943
10944    function canonicalPlusOne(storedId) {
10945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
10946      if (!m) return null;
10947      const [, slug, p, mv, title] = m;
10948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
10949    }
10950
10951    function isCompleted(canonicalId, completions) {
10952      if (completions.includes(canonicalId)) return true;
10953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
10954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
10955    }
10956
10957    function migrateCompletions() {
10958      const completions = getCompletions();
10959      let changed = false;
10960      const migrated = completions.map((c) => {
10961        const canon = canonicalFromStored(c);
10962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
10963        const plusOne = canonicalPlusOne(c);
10964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
10965          if (plusOne !== c) changed = true;
10966          return plusOne;
10967        }
10968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
10969          changed = true;
10970          return canon;
10971        }
10972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
10973        if (canon !== c) {
10974          changed = true;
10975          return canon;
10976        }
10977        return c;
10978      });
10979      // Elimina duplicados tras migración
10980      const deduped = [...new Set(migrated)];
10981      if (changed || deduped.length !== completions.length) {
10982        saveCompletions(deduped);
10983        return deduped;
10984      }
10985      return completions;
10986    }
10987
10988    function updateButtonState(btn, completions) {
10989      const id = btn.dataset.completionId;
10990      const list = completions ?? getCompletions();
10991      btn.classList.toggle("completed", isCompleted(id, list));
10992    }
10993
10994    function checkModuleCompletion(completions) {
10995      const list = completions ?? getCompletions();
10996      document.querySelectorAll(".course-video").forEach((module) => {
10997        const btns = module.querySelectorAll(".complete-btn");
10998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
10999        const check = module.querySelector(".module-check");
11000        if (check) check.classList.toggle("visible", allCompleted);
11001      });
11002    }
11003
11004    function initCompletionButtons() {
11005      const completions = migrateCompletions();
11006
11007      document.querySelectorAll(".complete-btn").forEach((btn) => {
11008        if (btn.dataset.completionInit) return;
11009        btn.dataset.completionInit = "true";
11010        updateButtonState(btn, completions);
11011
11012        btn.addEventListener("click", (e) => {
11013          e.preventDefault();
11014          e.stopPropagation();
11015
11016          const id = btn.dataset.completionId;
11017          let list = getCompletions();
11018          const completed = isCompleted(id, list);
11019
11020          if (completed) {
11021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
11022            list = list.filter((c) =>
11022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
11023          } else {
11024            list.push(id);
11025          }
11026
11027          saveCompletions(list);
11028          updateButtonState(btn, list);
11029          checkModuleCompletion(list);
11030        });
11031      });
11032
11033      checkModuleCompletion(completions);
11034    }
11035
11036    if (document.readyState === "loading") {
11037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
11038    } else {
11039      initCompletionButtons();
11040    }
11041  })();
11042</script>
11042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="7762" data-end="12395" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. OpenCode</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>1 hr 17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-opencode" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
11042<script>
11043  (() => {
11044    if (window.__courseLazyPlayersInitialized) return;
11045    window.__courseLazyPlayersInitialized = true;
11046
11047    function createPlayer(details) {
11048      const videoId = details.dataset.videoId;
11049      const start = details.dataset.start;
11050      const end = details.dataset.end;
11051
11052      const container = document.createElement("div");
11053      container.className = "player-container";
11054
11055      const spinner = document.createElement("div");
11056      spinner.className = "spinner";
11057      container.appendChild(spinner);
11058
11059      const controller = document.createElement("media-controller");
11060      controller.setAttribute("defaultsubtitles", "");
11061
11062      const provider = document.createElement("youtube-media-provider");
11063      provider.setAttribute("slot", "media");
11064      provider.setAttribute("video-id", videoId);
11065      provider.setAttribute("start", start);
11066      provider.setAttribute("end", end);
11067
11068      const controlBar = document.createElement("media-control-bar");
11069
11070      const playBtn = document.createElement("media-play-button");
11071      const timeDisplay = document.createElement("media-time-display");
11072      timeDisplay.setAttribute("showduration", "");
11073      const timeRange = document.createElement("media-time-range");
11074      const playbackRate = document.createElement("media-playback-rate-button");
11075      const muteBtn = document.createElement("media-mute-button");
11076      const volumeRange = document.createElement("media-volume-range");
11077
11078      const captionsBtn = document.createElement("media-captions-button");
11079      captionsBtn.addEventListener("click", (e) => {
11080        e.stopPropagation();
11081        const provider = container.querySelector("youtube-media-provider");
11082        if (provider) provider.toggleCaptions();
11083      });
11084
11085      const qualityBtn = document.createElement("media-chrome-button");
11086      qualityBtn.textContent = "--";
11087      qualityBtn.addEventListener("click", () => {
11088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
11089      });
11090
11091      const fullscreenBtn = document.createElement("media-fullscreen-button");
11092
11093      controlBar.append(
11094        playBtn, timeDisplay, timeRange,
11095        playbackRate, muteBtn, volumeRange,
11096        captionsBtn, qualityBtn, fullscreenBtn
11097      );
11098
11099      const poster = document.createElement("media-poster-image");
11100      poster.setAttribute("slot", "poster");
11101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
11102
11103      controller.append(provider, poster, controlBar);
11104
11105      captionsBtn.setAttribute("aria-checked", "true");
11106      controller.setAttribute("mediasubtitlesshowing", "on");
11107
11108      container.appendChild(controller);
11109      details.appendChild(container);
11110
11111      provider.addEventListener("waiting", () => container.classList.add("loading"));
11112      provider.addEventListener("playing", () => container.classList.remove("loading"));
11113      provider.addEventListener("play", () => container.classList.remove("loading"));
11114
11115      provider._textTracks.addEventListener("change", () => {
11116        const enabled = provider._captionsEnabled;
11117        captionsBtn.setAttribute("aria-checked", enabled);
11118        if (enabled) {
11119          controller.setAttribute("mediasubtitlesshowing", "on");
11120        } else {
11121          controller.removeAttribute("mediasubtitlesshowing");
11122        }
11123      });
11124
11125      provider.addEventListener("qualitychange", (e) => {
11126        qualityBtn.textContent = e.detail.quality;
11127      });
11128    }
11129
11130    function initLazyPlayers() {
11131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
11132        if (details.dataset.lazyInit) return;
11133        details.dataset.lazyInit = "true";
11134        details.addEventListener("toggle", () => {
11135          if (details.open && !details.querySelector(".player-container")) {
11136            createPlayer(details);
11137          } else if (!details.open) {
11138            const provider = details.querySelector("youtube-media-provider");
11139            if (provider && typeof provider.pause === "function") {
11140              provider.pause();
11141            }
11142          }
11143        });
11144      });
11145    }
11146
11147    if (document.readyState === "loading") {
11148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
11149    } else {
11150      initLazyPlayers();
11151    }
11152  })();
11153</script>
11153<script>
11154  (() => {
11155    if (window.__courseCompletionsInitialized) return;
11156    window.__courseCompletionsInitialized = true;
11157
11158    const STORAGE_KEY = "course-completions";
11159
11160    function slugify(text) {
11161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
11162    }
11163
11164    function getCompletions() {
11165      try {
11166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
11167      } catch {
11168        return [];
11169      }
11170    }
11171
11172    function saveCompletions(completions) {
11173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
11174    }
11175
11176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
11177    function canonicalFromStored(storedId) {
11178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11179      if (!m) return slugify(storedId);
11180      const [, slug, p, mv, title] = m;
11181      return `${slug}-${p}-${mv}-${slugify(title)}`;
11182    }
11183
11184    function canonicalPlusOne(storedId) {
11185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11186      if (!m) return null;
11187      const [, slug, p, mv, title] = m;
11188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
11189    }
11190
11191    function isCompleted(canonicalId, completions) {
11192      if (completions.includes(canonicalId)) return true;
11193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
11194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
11195    }
11196
11197    function migrateCompletions() {
11198      const completions = getCompletions();
11199      let changed = false;
11200      const migrated = completions.map((c) => {
11201        const canon = canonicalFromStored(c);
11202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
11203        const plusOne = canonicalPlusOne(c);
11204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
11205          if (plusOne !== c) changed = true;
11206          return plusOne;
11207        }
11208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
11209          changed = true;
11210          return canon;
11211        }
11212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
11213        if (canon !== c) {
11214          changed = true;
11215          return canon;
11216        }
11217        return c;
11218      });
11219      // Elimina duplicados tras migración
11220      const deduped = [...new Set(migrated)];
11221      if (changed || deduped.length !== completions.length) {
11222        saveCompletions(deduped);
11223        return deduped;
11224      }
11225      return completions;
11226    }
11227
11228    function updateButtonState(btn, completions) {
11229      const id = btn.dataset.completionId;
11230      const list = completions ?? getCompletions();
11231      btn.classList.toggle("completed", isCompleted(id, list));
11232    }
11233
11234    function checkModuleCompletion(completions) {
11235      const list = completions ?? getCompletions();
11236      document.querySelectorAll(".course-video").forEach((module) => {
11237        const btns = module.querySelectorAll(".complete-btn");
11238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
11239        const check = module.querySelector(".module-check");
11240        if (check) check.classList.toggle("visible", allCompleted);
11241      });
11242    }
11243
11244    function initCompletionButtons() {
11245      const completions = migrateCompletions();
11246
11247      document.querySelectorAll(".complete-btn").forEach((btn) => {
11248        if (btn.dataset.completionInit) return;
11249        btn.dataset.completionInit = "true";
11250        updateButtonState(btn, completions);
11251
11252        btn.addEventListener("click", (e) => {
11253          e.preventDefault();
11254          e.stopPropagation();
11255
11256          const id = btn.dataset.completionId;
11257          let list = getCompletions();
11258          const completed = isCompleted(id, list);
11259
11260          if (completed) {
11261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
11262            list = list.filter((c) =>
11262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
11263          } else {
11264            list.push(id);
11265          }
11266
11267          saveCompletions(list);
11268          updateButtonState(btn, list);
11269          checkModuleCompletion(list);
11270        });
11271      });
11272
11273      checkModuleCompletion(completions);
11274    }
11275
11276    if (document.readyState === "loading") {
11277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
11278    } else {
11279      initCompletionButtons();
11280    }
11281  })();
11282</script>
11282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="12395" data-end="12600" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Dancing cursor</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>3 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-dancing-cursor" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
11282<script>
11283  (() => {
11284    if (window.__courseLazyPlayersInitialized) return;
11285    window.__courseLazyPlayersInitialized = true;
11286
11287    function createPlayer(details) {
11288      const videoId = details.dataset.videoId;
11289      const start = details.dataset.start;
11290      const end = details.dataset.end;
11291
11292      const container = document.createElement("div");
11293      container.className = "player-container";
11294
11295      const spinner = document.createElement("div");
11296      spinner.className = "spinner";
11297      container.appendChild(spinner);
11298
11299      const controller = document.createElement("media-controller");
11300      controller.setAttribute("defaultsubtitles", "");
11301
11302      const provider = document.createElement("youtube-media-provider");
11303      provider.setAttribute("slot", "media");
11304      provider.setAttribute("video-id", videoId);
11305      provider.setAttribute("start", start);
11306      provider.setAttribute("end", end);
11307
11308      const controlBar = document.createElement("media-control-bar");
11309
11310      const playBtn = document.createElement("media-play-button");
11311      const timeDisplay = document.createElement("media-time-display");
11312      timeDisplay.setAttribute("showduration", "");
11313      const timeRange = document.createElement("media-time-range");
11314      const playbackRate = document.createElement("media-playback-rate-button");
11315      const muteBtn = document.createElement("media-mute-button");
11316      const volumeRange = document.createElement("media-volume-range");
11317
11318      const captionsBtn = document.createElement("media-captions-button");
11319      captionsBtn.addEventListener("click", (e) => {
11320        e.stopPropagation();
11321        const provider = container.querySelector("youtube-media-provider");
11322        if (provider) provider.toggleCaptions();
11323      });
11324
11325      const qualityBtn = document.createElement("media-chrome-button");
11326      qualityBtn.textContent = "--";
11327      qualityBtn.addEventListener("click", () => {
11328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
11329      });
11330
11331      const fullscreenBtn = document.createElement("media-fullscreen-button");
11332
11333      controlBar.append(
11334        playBtn, timeDisplay, timeRange,
11335        playbackRate, muteBtn, volumeRange,
11336        captionsBtn, qualityBtn, fullscreenBtn
11337      );
11338
11339      const poster = document.createElement("media-poster-image");
11340      poster.setAttribute("slot", "poster");
11341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
11342
11343      controller.append(provider, poster, controlBar);
11344
11345      captionsBtn.setAttribute("aria-checked", "true");
11346      controller.setAttribute("mediasubtitlesshowing", "on");
11347
11348      container.appendChild(controller);
11349      details.appendChild(container);
11350
11351      provider.addEventListener("waiting", () => container.classList.add("loading"));
11352      provider.addEventListener("playing", () => container.classList.remove("loading"));
11353      provider.addEventListener("play", () => container.classList.remove("loading"));
11354
11355      provider._textTracks.addEventListener("change", () => {
11356        const enabled = provider._captionsEnabled;
11357        captionsBtn.setAttribute("aria-checked", enabled);
11358        if (enabled) {
11359          controller.setAttribute("mediasubtitlesshowing", "on");
11360        } else {
11361          controller.removeAttribute("mediasubtitlesshowing");
11362        }
11363      });
11364
11365      provider.addEventListener("qualitychange", (e) => {
11366        qualityBtn.textContent = e.detail.quality;
11367      });
11368    }
11369
11370    function initLazyPlayers() {
11371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
11372        if (details.dataset.lazyInit) return;
11373        details.dataset.lazyInit = "true";
11374        details.addEventListener("toggle", () => {
11375          if (details.open && !details.querySelector(".player-container")) {
11376            createPlayer(details);
11377          } else if (!details.open) {
11378            const provider = details.querySelector("youtube-media-provider");
11379            if (provider && typeof provider.pause === "function") {
11380              provider.pause();
11381            }
11382          }
11383        });
11384      });
11385    }
11386
11387    if (document.readyState === "loading") {
11388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
11389    } else {
11390      initLazyPlayers();
11391    }
11392  })();
11393</script>
11393<script>
11394  (() => {
11395    if (window.__courseCompletionsInitialized) return;
11396    window.__courseCompletionsInitialized = true;
11397
11398    const STORAGE_KEY = "course-completions";
11399
11400    function slugify(text) {
11401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
11402    }
11403
11404    function getCompletions() {
11405      try {
11406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
11407      } catch {
11408        return [];
11409      }
11410    }
11411
11412    function saveCompletions(completions) {
11413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
11414    }
11415
11416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
11417    function canonicalFromStored(storedId) {
11418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11419      if (!m) return slugify(storedId);
11420      const [, slug, p, mv, title] = m;
11421      return `${slug}-${p}-${mv}-${slugify(title)}`;
11422    }
11423
11424    function canonicalPlusOne(storedId) {
11425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11426      if (!m) return null;
11427      const [, slug, p, mv, title] = m;
11428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
11429    }
11430
11431    function isCompleted(canonicalId, completions) {
11432      if (completions.includes(canonicalId)) return true;
11433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
11434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
11435    }
11436
11437    function migrateCompletions() {
11438      const completions = getCompletions();
11439      let changed = false;
11440      const migrated = completions.map((c) => {
11441        const canon = canonicalFromStored(c);
11442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
11443        const plusOne = canonicalPlusOne(c);
11444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
11445          if (plusOne !== c) changed = true;
11446          return plusOne;
11447        }
11448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
11449          changed = true;
11450          return canon;
11451        }
11452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
11453        if (canon !== c) {
11454          changed = true;
11455          return canon;
11456        }
11457        return c;
11458      });
11459      // Elimina duplicados tras migración
11460      const deduped = [...new Set(migrated)];
11461      if (changed || deduped.length !== completions.length) {
11462        saveCompletions(deduped);
11463        return deduped;
11464      }
11465      return completions;
11466    }
11467
11468    function updateButtonState(btn, completions) {
11469      const id = btn.dataset.completionId;
11470      const list = completions ?? getCompletions();
11471      btn.classList.toggle("completed", isCompleted(id, list));
11472    }
11473
11474    function checkModuleCompletion(completions) {
11475      const list = completions ?? getCompletions();
11476      document.querySelectorAll(".course-video").forEach((module) => {
11477        const btns = module.querySelectorAll(".complete-btn");
11478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
11479        const check = module.querySelector(".module-check");
11480        if (check) check.classList.toggle("visible", allCompleted);
11481      });
11482    }
11483
11484    function initCompletionButtons() {
11485      const completions = migrateCompletions();
11486
11487      document.querySelectorAll(".complete-btn").forEach((btn) => {
11488        if (btn.dataset.completionInit) return;
11489        btn.dataset.completionInit = "true";
11490        updateButtonState(btn, completions);
11491
11492        btn.addEventListener("click", (e) => {
11493          e.preventDefault();
11494          e.stopPropagation();
11495
11496          const id = btn.dataset.completionId;
11497          let list = getCompletions();
11498          const completed = isCompleted(id, list);
11499
11500          if (completed) {
11501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
11502            list = list.filter((c) =>
11502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
11503          } else {
11504            list.push(id);
11505          }
11506
11507          saveCompletions(list);
11508          updateButtonState(btn, list);
11509          checkModuleCompletion(list);
11510        });
11511      });
11512
11513      checkModuleCompletion(completions);
11514    }
11515
11516    if (document.readyState === "loading") {
11517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
11518    } else {
11519      initCompletionButtons();
11520    }
11521  })();
11522</script>
11522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="12600" data-end="13020" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Modelos locales</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-modelos-locales" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
11522<script>
11523  (() => {
11524    if (window.__courseLazyPlayersInitialized) return;
11525    window.__courseLazyPlayersInitialized = true;
11526
11527    function createPlayer(details) {
11528      const videoId = details.dataset.videoId;
11529      const start = details.dataset.start;
11530      const end = details.dataset.end;
11531
11532      const container = document.createElement("div");
11533      container.className = "player-container";
11534
11535      const spinner = document.createElement("div");
11536      spinner.className = "spinner";
11537      container.appendChild(spinner);
11538
11539      const controller = document.createElement("media-controller");
11540      controller.setAttribute("defaultsubtitles", "");
11541
11542      const provider = document.createElement("youtube-media-provider");
11543      provider.setAttribute("slot", "media");
11544      provider.setAttribute("video-id", videoId);
11545      provider.setAttribute("start", start);
11546      provider.setAttribute("end", end);
11547
11548      const controlBar = document.createElement("media-control-bar");
11549
11550      const playBtn = document.createElement("media-play-button");
11551      const timeDisplay = document.createElement("media-time-display");
11552      timeDisplay.setAttribute("showduration", "");
11553      const timeRange = document.createElement("media-time-range");
11554      const playbackRate = document.createElement("media-playback-rate-button");
11555      const muteBtn = document.createElement("media-mute-button");
11556      const volumeRange = document.createElement("media-volume-range");
11557
11558      const captionsBtn = document.createElement("media-captions-button");
11559      captionsBtn.addEventListener("click", (e) => {
11560        e.stopPropagation();
11561        const provider = container.querySelector("youtube-media-provider");
11562        if (provider) provider.toggleCaptions();
11563      });
11564
11565      const qualityBtn = document.createElement("media-chrome-button");
11566      qualityBtn.textContent = "--";
11567      qualityBtn.addEventListener("click", () => {
11568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
11569      });
11570
11571      const fullscreenBtn = document.createElement("media-fullscreen-button");
11572
11573      controlBar.append(
11574        playBtn, timeDisplay, timeRange,
11575        playbackRate, muteBtn, volumeRange,
11576        captionsBtn, qualityBtn, fullscreenBtn
11577      );
11578
11579      const poster = document.createElement("media-poster-image");
11580      poster.setAttribute("slot", "poster");
11581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
11582
11583      controller.append(provider, poster, controlBar);
11584
11585      captionsBtn.setAttribute("aria-checked", "true");
11586      controller.setAttribute("mediasubtitlesshowing", "on");
11587
11588      container.appendChild(controller);
11589      details.appendChild(container);
11590
11591      provider.addEventListener("waiting", () => container.classList.add("loading"));
11592      provider.addEventListener("playing", () => container.classList.remove("loading"));
11593      provider.addEventListener("play", () => container.classList.remove("loading"));
11594
11595      provider._textTracks.addEventListener("change", () => {
11596        const enabled = provider._captionsEnabled;
11597        captionsBtn.setAttribute("aria-checked", enabled);
11598        if (enabled) {
11599          controller.setAttribute("mediasubtitlesshowing", "on");
11600        } else {
11601          controller.removeAttribute("mediasubtitlesshowing");
11602        }
11603      });
11604
11605      provider.addEventListener("qualitychange", (e) => {
11606        qualityBtn.textContent = e.detail.quality;
11607      });
11608    }
11609
11610    function initLazyPlayers() {
11611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
11612        if (details.dataset.lazyInit) return;
11613        details.dataset.lazyInit = "true";
11614        details.addEventListener("toggle", () => {
11615          if (details.open && !details.querySelector(".player-container")) {
11616            createPlayer(details);
11617          } else if (!details.open) {
11618            const provider = details.querySelector("youtube-media-provider");
11619            if (provider && typeof provider.pause === "function") {
11620              provider.pause();
11621            }
11622          }
11623        });
11624      });
11625    }
11626
11627    if (document.readyState === "loading") {
11628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
11629    } else {
11630      initLazyPlayers();
11631    }
11632  })();
11633</script>
11633<script>
11634  (() => {
11635    if (window.__courseCompletionsInitialized) return;
11636    window.__courseCompletionsInitialized = true;
11637
11638    const STORAGE_KEY = "course-completions";
11639
11640    function slugify(text) {
11641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
11642    }
11643
11644    function getCompletions() {
11645      try {
11646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
11647      } catch {
11648        return [];
11649      }
11650    }
11651
11652    function saveCompletions(completions) {
11653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
11654    }
11655
11656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
11657    function canonicalFromStored(storedId) {
11658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11659      if (!m) return slugify(storedId);
11660      const [, slug, p, mv, title] = m;
11661      return `${slug}-${p}-${mv}-${slugify(title)}`;
11662    }
11663
11664    function canonicalPlusOne(storedId) {
11665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11666      if (!m) return null;
11667      const [, slug, p, mv, title] = m;
11668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
11669    }
11670
11671    function isCompleted(canonicalId, completions) {
11672      if (completions.includes(canonicalId)) return true;
11673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
11674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
11675    }
11676
11677    function migrateCompletions() {
11678      const completions = getCompletions();
11679      let changed = false;
11680      const migrated = completions.map((c) => {
11681        const canon = canonicalFromStored(c);
11682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
11683        const plusOne = canonicalPlusOne(c);
11684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
11685          if (plusOne !== c) changed = true;
11686          return plusOne;
11687        }
11688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
11689          changed = true;
11690          return canon;
11691        }
11692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
11693        if (canon !== c) {
11694          changed = true;
11695          return canon;
11696        }
11697        return c;
11698      });
11699      // Elimina duplicados tras migración
11700      const deduped = [...new Set(migrated)];
11701      if (changed || deduped.length !== completions.length) {
11702        saveCompletions(deduped);
11703        return deduped;
11704      }
11705      return completions;
11706    }
11707
11708    function updateButtonState(btn, completions) {
11709      const id = btn.dataset.completionId;
11710      const list = completions ?? getCompletions();
11711      btn.classList.toggle("completed", isCompleted(id, list));
11712    }
11713
11714    function checkModuleCompletion(completions) {
11715      const list = completions ?? getCompletions();
11716      document.querySelectorAll(".course-video").forEach((module) => {
11717        const btns = module.querySelectorAll(".complete-btn");
11718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
11719        const check = module.querySelector(".module-check");
11720        if (check) check.classList.toggle("visible", allCompleted);
11721      });
11722    }
11723
11724    function initCompletionButtons() {
11725      const completions = migrateCompletions();
11726
11727      document.querySelectorAll(".complete-btn").forEach((btn) => {
11728        if (btn.dataset.completionInit) return;
11729        btn.dataset.completionInit = "true";
11730        updateButtonState(btn, completions);
11731
11732        btn.addEventListener("click", (e) => {
11733          e.preventDefault();
11734          e.stopPropagation();
11735
11736          const id = btn.dataset.completionId;
11737          let list = getCompletions();
11738          const completed = isCompleted(id, list);
11739
11740          if (completed) {
11741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
11742            list = list.filter((c) =>
11742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
11743          } else {
11744            list.push(id);
11745          }
11746
11747          saveCompletions(list);
11748          updateButtonState(btn, list);
11749          checkModuleCompletion(list);
11750        });
11751      });
11752
11753      checkModuleCompletion(completions);
11754    }
11755
11756    if (document.readyState === "loading") {
11757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
11758    } else {
11759      initCompletionButtons();
11760    }
11761  })();
11762</script>
11762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="13020" data-end="15375" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Ollama (LLM locales)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>39 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-ollama-llm-locales" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
11762<script>
11763  (() => {
11764    if (window.__courseLazyPlayersInitialized) return;
11765    window.__courseLazyPlayersInitialized = true;
11766
11767    function createPlayer(details) {
11768      const videoId = details.dataset.videoId;
11769      const start = details.dataset.start;
11770      const end = details.dataset.end;
11771
11772      const container = document.createElement("div");
11773      container.className = "player-container";
11774
11775      const spinner = document.createElement("div");
11776      spinner.className = "spinner";
11777      container.appendChild(spinner);
11778
11779      const controller = document.createElement("media-controller");
11780      controller.setAttribute("defaultsubtitles", "");
11781
11782      const provider = document.createElement("youtube-media-provider");
11783      provider.setAttribute("slot", "media");
11784      provider.setAttribute("video-id", videoId);
11785      provider.setAttribute("start", start);
11786      provider.setAttribute("end", end);
11787
11788      const controlBar = document.createElement("media-control-bar");
11789
11790      const playBtn = document.createElement("media-play-button");
11791      const timeDisplay = document.createElement("media-time-display");
11792      timeDisplay.setAttribute("showduration", "");
11793      const timeRange = document.createElement("media-time-range");
11794      const playbackRate = document.createElement("media-playback-rate-button");
11795      const muteBtn = document.createElement("media-mute-button");
11796      const volumeRange = document.createElement("media-volume-range");
11797
11798      const captionsBtn = document.createElement("media-captions-button");
11799      captionsBtn.addEventListener("click", (e) => {
11800        e.stopPropagation();
11801        const provider = container.querySelector("youtube-media-provider");
11802        if (provider) provider.toggleCaptions();
11803      });
11804
11805      const qualityBtn = document.createElement("media-chrome-button");
11806      qualityBtn.textContent = "--";
11807      qualityBtn.addEventListener("click", () => {
11808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
11809      });
11810
11811      const fullscreenBtn = document.createElement("media-fullscreen-button");
11812
11813      controlBar.append(
11814        playBtn, timeDisplay, timeRange,
11815        playbackRate, muteBtn, volumeRange,
11816        captionsBtn, qualityBtn, fullscreenBtn
11817      );
11818
11819      const poster = document.createElement("media-poster-image");
11820      poster.setAttribute("slot", "poster");
11821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
11822
11823      controller.append(provider, poster, controlBar);
11824
11825      captionsBtn.setAttribute("aria-checked", "true");
11826      controller.setAttribute("mediasubtitlesshowing", "on");
11827
11828      container.appendChild(controller);
11829      details.appendChild(container);
11830
11831      provider.addEventListener("waiting", () => container.classList.add("loading"));
11832      provider.addEventListener("playing", () => container.classList.remove("loading"));
11833      provider.addEventListener("play", () => container.classList.remove("loading"));
11834
11835      provider._textTracks.addEventListener("change", () => {
11836        const enabled = provider._captionsEnabled;
11837        captionsBtn.setAttribute("aria-checked", enabled);
11838        if (enabled) {
11839          controller.setAttribute("mediasubtitlesshowing", "on");
11840        } else {
11841          controller.removeAttribute("mediasubtitlesshowing");
11842        }
11843      });
11844
11845      provider.addEventListener("qualitychange", (e) => {
11846        qualityBtn.textContent = e.detail.quality;
11847      });
11848    }
11849
11850    function initLazyPlayers() {
11851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
11852        if (details.dataset.lazyInit) return;
11853        details.dataset.lazyInit = "true";
11854        details.addEventListener("toggle", () => {
11855          if (details.open && !details.querySelector(".player-container")) {
11856            createPlayer(details);
11857          } else if (!details.open) {
11858            const provider = details.querySelector("youtube-media-provider");
11859            if (provider && typeof provider.pause === "function") {
11860              provider.pause();
11861            }
11862          }
11863        });
11864      });
11865    }
11866
11867    if (document.readyState === "loading") {
11868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
11869    } else {
11870      initLazyPlayers();
11871    }
11872  })();
11873</script>
11873<script>
11874  (() => {
11875    if (window.__courseCompletionsInitialized) return;
11876    window.__courseCompletionsInitialized = true;
11877
11878    const STORAGE_KEY = "course-completions";
11879
11880    function slugify(text) {
11881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
11882    }
11883
11884    function getCompletions() {
11885      try {
11886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
11887      } catch {
11888        return [];
11889      }
11890    }
11891
11892    function saveCompletions(completions) {
11893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
11894    }
11895
11896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
11897    function canonicalFromStored(storedId) {
11898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11899      if (!m) return slugify(storedId);
11900      const [, slug, p, mv, title] = m;
11901      return `${slug}-${p}-${mv}-${slugify(title)}`;
11902    }
11903
11904    function canonicalPlusOne(storedId) {
11905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
11906      if (!m) return null;
11907      const [, slug, p, mv, title] = m;
11908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
11909    }
11910
11911    function isCompleted(canonicalId, completions) {
11912      if (completions.includes(canonicalId)) return true;
11913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
11914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
11915    }
11916
11917    function migrateCompletions() {
11918      const completions = getCompletions();
11919      let changed = false;
11920      const migrated = completions.map((c) => {
11921        const canon = canonicalFromStored(c);
11922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
11923        const plusOne = canonicalPlusOne(c);
11924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
11925          if (plusOne !== c) changed = true;
11926          return plusOne;
11927        }
11928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
11929          changed = true;
11930          return canon;
11931        }
11932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
11933        if (canon !== c) {
11934          changed = true;
11935          return canon;
11936        }
11937        return c;
11938      });
11939      // Elimina duplicados tras migración
11940      const deduped = [...new Set(migrated)];
11941      if (changed || deduped.length !== completions.length) {
11942        saveCompletions(deduped);
11943        return deduped;
11944      }
11945      return completions;
11946    }
11947
11948    function updateButtonState(btn, completions) {
11949      const id = btn.dataset.completionId;
11950      const list = completions ?? getCompletions();
11951      btn.classList.toggle("completed", isCompleted(id, list));
11952    }
11953
11954    function checkModuleCompletion(completions) {
11955      const list = completions ?? getCompletions();
11956      document.querySelectorAll(".course-video").forEach((module) => {
11957        const btns = module.querySelectorAll(".complete-btn");
11958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
11959        const check = module.querySelector(".module-check");
11960        if (check) check.classList.toggle("visible", allCompleted);
11961      });
11962    }
11963
11964    function initCompletionButtons() {
11965      const completions = migrateCompletions();
11966
11967      document.querySelectorAll(".complete-btn").forEach((btn) => {
11968        if (btn.dataset.completionInit) return;
11969        btn.dataset.completionInit = "true";
11970        updateButtonState(btn, completions);
11971
11972        btn.addEventListener("click", (e) => {
11973          e.preventDefault();
11974          e.stopPropagation();
11975
11976          const id = btn.dataset.completionId;
11977          let list = getCompletions();
11978          const completed = isCompleted(id, list);
11979
11980          if (completed) {
11981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
11982            list = list.filter((c) =>
11982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
11983          } else {
11984            list.push(id);
11985          }
11986
11987          saveCompletions(list);
11988          updateButtonState(btn, list);
11989          checkModuleCompletion(list);
11990        });
11991      });
11992
11993      checkModuleCompletion(completions);
11994    }
11995
11996    if (document.readyState === "loading") {
11997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
11998    } else {
11999      initCompletionButtons();
12000    }
12001  })();
12002</script>
12002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="15375" data-end="16060" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. OpenCode con Ollama</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-opencode-con-ollama" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
12002<script>
12003  (() => {
12004    if (window.__courseLazyPlayersInitialized) return;
12005    window.__courseLazyPlayersInitialized = true;
12006
12007    function createPlayer(details) {
12008      const videoId = details.dataset.videoId;
12009      const start = details.dataset.start;
12010      const end = details.dataset.end;
12011
12012      const container = document.createElement("div");
12013      container.className = "player-container";
12014
12015      const spinner = document.createElement("div");
12016      spinner.className = "spinner";
12017      container.appendChild(spinner);
12018
12019      const controller = document.createElement("media-controller");
12020      controller.setAttribute("defaultsubtitles", "");
12021
12022      const provider = document.createElement("youtube-media-provider");
12023      provider.setAttribute("slot", "media");
12024      provider.setAttribute("video-id", videoId);
12025      provider.setAttribute("start", start);
12026      provider.setAttribute("end", end);
12027
12028      const controlBar = document.createElement("media-control-bar");
12029
12030      const playBtn = document.createElement("media-play-button");
12031      const timeDisplay = document.createElement("media-time-display");
12032      timeDisplay.setAttribute("showduration", "");
12033      const timeRange = document.createElement("media-time-range");
12034      const playbackRate = document.createElement("media-playback-rate-button");
12035      const muteBtn = document.createElement("media-mute-button");
12036      const volumeRange = document.createElement("media-volume-range");
12037
12038      const captionsBtn = document.createElement("media-captions-button");
12039      captionsBtn.addEventListener("click", (e) => {
12040        e.stopPropagation();
12041        const provider = container.querySelector("youtube-media-provider");
12042        if (provider) provider.toggleCaptions();
12043      });
12044
12045      const qualityBtn = document.createElement("media-chrome-button");
12046      qualityBtn.textContent = "--";
12047      qualityBtn.addEventListener("click", () => {
12048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
12049      });
12050
12051      const fullscreenBtn = document.createElement("media-fullscreen-button");
12052
12053      controlBar.append(
12054        playBtn, timeDisplay, timeRange,
12055        playbackRate, muteBtn, volumeRange,
12056        captionsBtn, qualityBtn, fullscreenBtn
12057      );
12058
12059      const poster = document.createElement("media-poster-image");
12060      poster.setAttribute("slot", "poster");
12061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
12062
12063      controller.append(provider, poster, controlBar);
12064
12065      captionsBtn.setAttribute("aria-checked", "true");
12066      controller.setAttribute("mediasubtitlesshowing", "on");
12067
12068      container.appendChild(controller);
12069      details.appendChild(container);
12070
12071      provider.addEventListener("waiting", () => container.classList.add("loading"));
12072      provider.addEventListener("playing", () => container.classList.remove("loading"));
12073      provider.addEventListener("play", () => container.classList.remove("loading"));
12074
12075      provider._textTracks.addEventListener("change", () => {
12076        const enabled = provider._captionsEnabled;
12077        captionsBtn.setAttribute("aria-checked", enabled);
12078        if (enabled) {
12079          controller.setAttribute("mediasubtitlesshowing", "on");
12080        } else {
12081          controller.removeAttribute("mediasubtitlesshowing");
12082        }
12083      });
12084
12085      provider.addEventListener("qualitychange", (e) => {
12086        qualityBtn.textContent = e.detail.quality;
12087      });
12088    }
12089
12090    function initLazyPlayers() {
12091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
12092        if (details.dataset.lazyInit) return;
12093        details.dataset.lazyInit = "true";
12094        details.addEventListener("toggle", () => {
12095          if (details.open && !details.querySelector(".player-container")) {
12096            createPlayer(details);
12097          } else if (!details.open) {
12098            const provider = details.querySelector("youtube-media-provider");
12099            if (provider && typeof provider.pause === "function") {
12100              provider.pause();
12101            }
12102          }
12103        });
12104      });
12105    }
12106
12107    if (document.readyState === "loading") {
12108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
12109    } else {
12110      initLazyPlayers();
12111    }
12112  })();
12113</script>
12113<script>
12114  (() => {
12115    if (window.__courseCompletionsInitialized) return;
12116    window.__courseCompletionsInitialized = true;
12117
12118    const STORAGE_KEY = "course-completions";
12119
12120    function slugify(text) {
12121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
12122    }
12123
12124    function getCompletions() {
12125      try {
12126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
12127      } catch {
12128        return [];
12129      }
12130    }
12131
12132    function saveCompletions(completions) {
12133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
12134    }
12135
12136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
12137    function canonicalFromStored(storedId) {
12138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12139      if (!m) return slugify(storedId);
12140      const [, slug, p, mv, title] = m;
12141      return `${slug}-${p}-${mv}-${slugify(title)}`;
12142    }
12143
12144    function canonicalPlusOne(storedId) {
12145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12146      if (!m) return null;
12147      const [, slug, p, mv, title] = m;
12148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
12149    }
12150
12151    function isCompleted(canonicalId, completions) {
12152      if (completions.includes(canonicalId)) return true;
12153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
12154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
12155    }
12156
12157    function migrateCompletions() {
12158      const completions = getCompletions();
12159      let changed = false;
12160      const migrated = completions.map((c) => {
12161        const canon = canonicalFromStored(c);
12162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
12163        const plusOne = canonicalPlusOne(c);
12164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
12165          if (plusOne !== c) changed = true;
12166          return plusOne;
12167        }
12168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
12169          changed = true;
12170          return canon;
12171        }
12172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
12173        if (canon !== c) {
12174          changed = true;
12175          return canon;
12176        }
12177        return c;
12178      });
12179      // Elimina duplicados tras migración
12180      const deduped = [...new Set(migrated)];
12181      if (changed || deduped.length !== completions.length) {
12182        saveCompletions(deduped);
12183        return deduped;
12184      }
12185      return completions;
12186    }
12187
12188    function updateButtonState(btn, completions) {
12189      const id = btn.dataset.completionId;
12190      const list = completions ?? getCompletions();
12191      btn.classList.toggle("completed", isCompleted(id, list));
12192    }
12193
12194    function checkModuleCompletion(completions) {
12195      const list = completions ?? getCompletions();
12196      document.querySelectorAll(".course-video").forEach((module) => {
12197        const btns = module.querySelectorAll(".complete-btn");
12198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
12199        const check = module.querySelector(".module-check");
12200        if (check) check.classList.toggle("visible", allCompleted);
12201      });
12202    }
12203
12204    function initCompletionButtons() {
12205      const completions = migrateCompletions();
12206
12207      document.querySelectorAll(".complete-btn").forEach((btn) => {
12208        if (btn.dataset.completionInit) return;
12209        btn.dataset.completionInit = "true";
12210        updateButtonState(btn, completions);
12211
12212        btn.addEventListener("click", (e) => {
12213          e.preventDefault();
12214          e.stopPropagation();
12215
12216          const id = btn.dataset.completionId;
12217          let list = getCompletions();
12218          const completed = isCompleted(id, list);
12219
12220          if (completed) {
12221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
12222            list = list.filter((c) =>
12222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
12223          } else {
12224            list.push(id);
12225          }
12226
12227          saveCompletions(list);
12228          updateButtonState(btn, list);
12229          checkModuleCompletion(list);
12230        });
12231      });
12232
12233      checkModuleCompletion(completions);
12234    }
12235
12236    if (document.readyState === "loading") {
12237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
12238    } else {
12239      initCompletionButtons();
12240    }
12241  })();
12242</script>
12242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="16060" data-end="16802" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. IA Generativa</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>12 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-ia-generativa" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
12242<script>
12243  (() => {
12244    if (window.__courseLazyPlayersInitialized) return;
12245    window.__courseLazyPlayersInitialized = true;
12246
12247    function createPlayer(details) {
12248      const videoId = details.dataset.videoId;
12249      const start = details.dataset.start;
12250      const end = details.dataset.end;
12251
12252      const container = document.createElement("div");
12253      container.className = "player-container";
12254
12255      const spinner = document.createElement("div");
12256      spinner.className = "spinner";
12257      container.appendChild(spinner);
12258
12259      const controller = document.createElement("media-controller");
12260      controller.setAttribute("defaultsubtitles", "");
12261
12262      const provider = document.createElement("youtube-media-provider");
12263      provider.setAttribute("slot", "media");
12264      provider.setAttribute("video-id", videoId);
12265      provider.setAttribute("start", start);
12266      provider.setAttribute("end", end);
12267
12268      const controlBar = document.createElement("media-control-bar");
12269
12270      const playBtn = document.createElement("media-play-button");
12271      const timeDisplay = document.createElement("media-time-display");
12272      timeDisplay.setAttribute("showduration", "");
12273      const timeRange = document.createElement("media-time-range");
12274      const playbackRate = document.createElement("media-playback-rate-button");
12275      const muteBtn = document.createElement("media-mute-button");
12276      const volumeRange = document.createElement("media-volume-range");
12277
12278      const captionsBtn = document.createElement("media-captions-button");
12279      captionsBtn.addEventListener("click", (e) => {
12280        e.stopPropagation();
12281        const provider = container.querySelector("youtube-media-provider");
12282        if (provider) provider.toggleCaptions();
12283      });
12284
12285      const qualityBtn = document.createElement("media-chrome-button");
12286      qualityBtn.textContent = "--";
12287      qualityBtn.addEventListener("click", () => {
12288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
12289      });
12290
12291      const fullscreenBtn = document.createElement("media-fullscreen-button");
12292
12293      controlBar.append(
12294        playBtn, timeDisplay, timeRange,
12295        playbackRate, muteBtn, volumeRange,
12296        captionsBtn, qualityBtn, fullscreenBtn
12297      );
12298
12299      const poster = document.createElement("media-poster-image");
12300      poster.setAttribute("slot", "poster");
12301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
12302
12303      controller.append(provider, poster, controlBar);
12304
12305      captionsBtn.setAttribute("aria-checked", "true");
12306      controller.setAttribute("mediasubtitlesshowing", "on");
12307
12308      container.appendChild(controller);
12309      details.appendChild(container);
12310
12311      provider.addEventListener("waiting", () => container.classList.add("loading"));
12312      provider.addEventListener("playing", () => container.classList.remove("loading"));
12313      provider.addEventListener("play", () => container.classList.remove("loading"));
12314
12315      provider._textTracks.addEventListener("change", () => {
12316        const enabled = provider._captionsEnabled;
12317        captionsBtn.setAttribute("aria-checked", enabled);
12318        if (enabled) {
12319          controller.setAttribute("mediasubtitlesshowing", "on");
12320        } else {
12321          controller.removeAttribute("mediasubtitlesshowing");
12322        }
12323      });
12324
12325      provider.addEventListener("qualitychange", (e) => {
12326        qualityBtn.textContent = e.detail.quality;
12327      });
12328    }
12329
12330    function initLazyPlayers() {
12331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
12332        if (details.dataset.lazyInit) return;
12333        details.dataset.lazyInit = "true";
12334        details.addEventListener("toggle", () => {
12335          if (details.open && !details.querySelector(".player-container")) {
12336            createPlayer(details);
12337          } else if (!details.open) {
12338            const provider = details.querySelector("youtube-media-provider");
12339            if (provider && typeof provider.pause === "function") {
12340              provider.pause();
12341            }
12342          }
12343        });
12344      });
12345    }
12346
12347    if (document.readyState === "loading") {
12348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
12349    } else {
12350      initLazyPlayers();
12351    }
12352  })();
12353</script>
12353<script>
12354  (() => {
12355    if (window.__courseCompletionsInitialized) return;
12356    window.__courseCompletionsInitialized = true;
12357
12358    const STORAGE_KEY = "course-completions";
12359
12360    function slugify(text) {
12361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
12362    }
12363
12364    function getCompletions() {
12365      try {
12366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
12367      } catch {
12368        return [];
12369      }
12370    }
12371
12372    function saveCompletions(completions) {
12373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
12374    }
12375
12376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
12377    function canonicalFromStored(storedId) {
12378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12379      if (!m) return slugify(storedId);
12380      const [, slug, p, mv, title] = m;
12381      return `${slug}-${p}-${mv}-${slugify(title)}`;
12382    }
12383
12384    function canonicalPlusOne(storedId) {
12385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12386      if (!m) return null;
12387      const [, slug, p, mv, title] = m;
12388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
12389    }
12390
12391    function isCompleted(canonicalId, completions) {
12392      if (completions.includes(canonicalId)) return true;
12393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
12394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
12395    }
12396
12397    function migrateCompletions() {
12398      const completions = getCompletions();
12399      let changed = false;
12400      const migrated = completions.map((c) => {
12401        const canon = canonicalFromStored(c);
12402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
12403        const plusOne = canonicalPlusOne(c);
12404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
12405          if (plusOne !== c) changed = true;
12406          return plusOne;
12407        }
12408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
12409          changed = true;
12410          return canon;
12411        }
12412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
12413        if (canon !== c) {
12414          changed = true;
12415          return canon;
12416        }
12417        return c;
12418      });
12419      // Elimina duplicados tras migración
12420      const deduped = [...new Set(migrated)];
12421      if (changed || deduped.length !== completions.length) {
12422        saveCompletions(deduped);
12423        return deduped;
12424      }
12425      return completions;
12426    }
12427
12428    function updateButtonState(btn, completions) {
12429      const id = btn.dataset.completionId;
12430      const list = completions ?? getCompletions();
12431      btn.classList.toggle("completed", isCompleted(id, list));
12432    }
12433
12434    function checkModuleCompletion(completions) {
12435      const list = completions ?? getCompletions();
12436      document.querySelectorAll(".course-video").forEach((module) => {
12437        const btns = module.querySelectorAll(".complete-btn");
12438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
12439        const check = module.querySelector(".module-check");
12440        if (check) check.classList.toggle("visible", allCompleted);
12441      });
12442    }
12443
12444    function initCompletionButtons() {
12445      const completions = migrateCompletions();
12446
12447      document.querySelectorAll(".complete-btn").forEach((btn) => {
12448        if (btn.dataset.completionInit) return;
12449        btn.dataset.completionInit = "true";
12450        updateButtonState(btn, completions);
12451
12452        btn.addEventListener("click", (e) => {
12453          e.preventDefault();
12454          e.stopPropagation();
12455
12456          const id = btn.dataset.completionId;
12457          let list = getCompletions();
12458          const completed = isCompleted(id, list);
12459
12460          if (completed) {
12461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
12462            list = list.filter((c) =>
12462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
12463          } else {
12464            list.push(id);
12465          }
12466
12467          saveCompletions(list);
12468          updateButtonState(btn, list);
12469          checkModuleCompletion(list);
12470        });
12471      });
12472
12473      checkModuleCompletion(completions);
12474    }
12475
12476    if (document.readyState === "loading") {
12477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
12478    } else {
12479      initCompletionButtons();
12480    }
12481  })();
12482</script>
12482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="16802" data-end="17340" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Instalación de ComfyUI</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-instalacion-de-comfyui" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
12482<script>
12483  (() => {
12484    if (window.__courseLazyPlayersInitialized) return;
12485    window.__courseLazyPlayersInitialized = true;
12486
12487    function createPlayer(details) {
12488      const videoId = details.dataset.videoId;
12489      const start = details.dataset.start;
12490      const end = details.dataset.end;
12491
12492      const container = document.createElement("div");
12493      container.className = "player-container";
12494
12495      const spinner = document.createElement("div");
12496      spinner.className = "spinner";
12497      container.appendChild(spinner);
12498
12499      const controller = document.createElement("media-controller");
12500      controller.setAttribute("defaultsubtitles", "");
12501
12502      const provider = document.createElement("youtube-media-provider");
12503      provider.setAttribute("slot", "media");
12504      provider.setAttribute("video-id", videoId);
12505      provider.setAttribute("start", start);
12506      provider.setAttribute("end", end);
12507
12508      const controlBar = document.createElement("media-control-bar");
12509
12510      const playBtn = document.createElement("media-play-button");
12511      const timeDisplay = document.createElement("media-time-display");
12512      timeDisplay.setAttribute("showduration", "");
12513      const timeRange = document.createElement("media-time-range");
12514      const playbackRate = document.createElement("media-playback-rate-button");
12515      const muteBtn = document.createElement("media-mute-button");
12516      const volumeRange = document.createElement("media-volume-range");
12517
12518      const captionsBtn = document.createElement("media-captions-button");
12519      captionsBtn.addEventListener("click", (e) => {
12520        e.stopPropagation();
12521        const provider = container.querySelector("youtube-media-provider");
12522        if (provider) provider.toggleCaptions();
12523      });
12524
12525      const qualityBtn = document.createElement("media-chrome-button");
12526      qualityBtn.textContent = "--";
12527      qualityBtn.addEventListener("click", () => {
12528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
12529      });
12530
12531      const fullscreenBtn = document.createElement("media-fullscreen-button");
12532
12533      controlBar.append(
12534        playBtn, timeDisplay, timeRange,
12535        playbackRate, muteBtn, volumeRange,
12536        captionsBtn, qualityBtn, fullscreenBtn
12537      );
12538
12539      const poster = document.createElement("media-poster-image");
12540      poster.setAttribute("slot", "poster");
12541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
12542
12543      controller.append(provider, poster, controlBar);
12544
12545      captionsBtn.setAttribute("aria-checked", "true");
12546      controller.setAttribute("mediasubtitlesshowing", "on");
12547
12548      container.appendChild(controller);
12549      details.appendChild(container);
12550
12551      provider.addEventListener("waiting", () => container.classList.add("loading"));
12552      provider.addEventListener("playing", () => container.classList.remove("loading"));
12553      provider.addEventListener("play", () => container.classList.remove("loading"));
12554
12555      provider._textTracks.addEventListener("change", () => {
12556        const enabled = provider._captionsEnabled;
12557        captionsBtn.setAttribute("aria-checked", enabled);
12558        if (enabled) {
12559          controller.setAttribute("mediasubtitlesshowing", "on");
12560        } else {
12561          controller.removeAttribute("mediasubtitlesshowing");
12562        }
12563      });
12564
12565      provider.addEventListener("qualitychange", (e) => {
12566        qualityBtn.textContent = e.detail.quality;
12567      });
12568    }
12569
12570    function initLazyPlayers() {
12571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
12572        if (details.dataset.lazyInit) return;
12573        details.dataset.lazyInit = "true";
12574        details.addEventListener("toggle", () => {
12575          if (details.open && !details.querySelector(".player-container")) {
12576            createPlayer(details);
12577          } else if (!details.open) {
12578            const provider = details.querySelector("youtube-media-provider");
12579            if (provider && typeof provider.pause === "function") {
12580              provider.pause();
12581            }
12582          }
12583        });
12584      });
12585    }
12586
12587    if (document.readyState === "loading") {
12588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
12589    } else {
12590      initLazyPlayers();
12591    }
12592  })();
12593</script>
12593<script>
12594  (() => {
12595    if (window.__courseCompletionsInitialized) return;
12596    window.__courseCompletionsInitialized = true;
12597
12598    const STORAGE_KEY = "course-completions";
12599
12600    function slugify(text) {
12601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
12602    }
12603
12604    function getCompletions() {
12605      try {
12606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
12607      } catch {
12608        return [];
12609      }
12610    }
12611
12612    function saveCompletions(completions) {
12613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
12614    }
12615
12616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
12617    function canonicalFromStored(storedId) {
12618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12619      if (!m) return slugify(storedId);
12620      const [, slug, p, mv, title] = m;
12621      return `${slug}-${p}-${mv}-${slugify(title)}`;
12622    }
12623
12624    function canonicalPlusOne(storedId) {
12625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12626      if (!m) return null;
12627      const [, slug, p, mv, title] = m;
12628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
12629    }
12630
12631    function isCompleted(canonicalId, completions) {
12632      if (completions.includes(canonicalId)) return true;
12633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
12634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
12635    }
12636
12637    function migrateCompletions() {
12638      const completions = getCompletions();
12639      let changed = false;
12640      const migrated = completions.map((c) => {
12641        const canon = canonicalFromStored(c);
12642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
12643        const plusOne = canonicalPlusOne(c);
12644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
12645          if (plusOne !== c) changed = true;
12646          return plusOne;
12647        }
12648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
12649          changed = true;
12650          return canon;
12651        }
12652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
12653        if (canon !== c) {
12654          changed = true;
12655          return canon;
12656        }
12657        return c;
12658      });
12659      // Elimina duplicados tras migración
12660      const deduped = [...new Set(migrated)];
12661      if (changed || deduped.length !== completions.length) {
12662        saveCompletions(deduped);
12663        return deduped;
12664      }
12665      return completions;
12666    }
12667
12668    function updateButtonState(btn, completions) {
12669      const id = btn.dataset.completionId;
12670      const list = completions ?? getCompletions();
12671      btn.classList.toggle("completed", isCompleted(id, list));
12672    }
12673
12674    function checkModuleCompletion(completions) {
12675      const list = completions ?? getCompletions();
12676      document.querySelectorAll(".course-video").forEach((module) => {
12677        const btns = module.querySelectorAll(".complete-btn");
12678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
12679        const check = module.querySelector(".module-check");
12680        if (check) check.classList.toggle("visible", allCompleted);
12681      });
12682    }
12683
12684    function initCompletionButtons() {
12685      const completions = migrateCompletions();
12686
12687      document.querySelectorAll(".complete-btn").forEach((btn) => {
12688        if (btn.dataset.completionInit) return;
12689        btn.dataset.completionInit = "true";
12690        updateButtonState(btn, completions);
12691
12692        btn.addEventListener("click", (e) => {
12693          e.preventDefault();
12694          e.stopPropagation();
12695
12696          const id = btn.dataset.completionId;
12697          let list = getCompletions();
12698          const completed = isCompleted(id, list);
12699
12700          if (completed) {
12701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
12702            list = list.filter((c) =>
12702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
12703          } else {
12704            list.push(id);
12705          }
12706
12707          saveCompletions(list);
12708          updateButtonState(btn, list);
12709          checkModuleCompletion(list);
12710        });
12711      });
12712
12713      checkModuleCompletion(completions);
12714    }
12715
12716    if (document.readyState === "loading") {
12717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
12718    } else {
12719      initCompletionButtons();
12720    }
12721  })();
12722</script>
12722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="17340" data-end="19861" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>14. ComfyUI</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>42 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-comfyui" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
12722<script>
12723  (() => {
12724    if (window.__courseLazyPlayersInitialized) return;
12725    window.__courseLazyPlayersInitialized = true;
12726
12727    function createPlayer(details) {
12728      const videoId = details.dataset.videoId;
12729      const start = details.dataset.start;
12730      const end = details.dataset.end;
12731
12732      const container = document.createElement("div");
12733      container.className = "player-container";
12734
12735      const spinner = document.createElement("div");
12736      spinner.className = "spinner";
12737      container.appendChild(spinner);
12738
12739      const controller = document.createElement("media-controller");
12740      controller.setAttribute("defaultsubtitles", "");
12741
12742      const provider = document.createElement("youtube-media-provider");
12743      provider.setAttribute("slot", "media");
12744      provider.setAttribute("video-id", videoId);
12745      provider.setAttribute("start", start);
12746      provider.setAttribute("end", end);
12747
12748      const controlBar = document.createElement("media-control-bar");
12749
12750      const playBtn = document.createElement("media-play-button");
12751      const timeDisplay = document.createElement("media-time-display");
12752      timeDisplay.setAttribute("showduration", "");
12753      const timeRange = document.createElement("media-time-range");
12754      const playbackRate = document.createElement("media-playback-rate-button");
12755      const muteBtn = document.createElement("media-mute-button");
12756      const volumeRange = document.createElement("media-volume-range");
12757
12758      const captionsBtn = document.createElement("media-captions-button");
12759      captionsBtn.addEventListener("click", (e) => {
12760        e.stopPropagation();
12761        const provider = container.querySelector("youtube-media-provider");
12762        if (provider) provider.toggleCaptions();
12763      });
12764
12765      const qualityBtn = document.createElement("media-chrome-button");
12766      qualityBtn.textContent = "--";
12767      qualityBtn.addEventListener("click", () => {
12768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
12769      });
12770
12771      const fullscreenBtn = document.createElement("media-fullscreen-button");
12772
12773      controlBar.append(
12774        playBtn, timeDisplay, timeRange,
12775        playbackRate, muteBtn, volumeRange,
12776        captionsBtn, qualityBtn, fullscreenBtn
12777      );
12778
12779      const poster = document.createElement("media-poster-image");
12780      poster.setAttribute("slot", "poster");
12781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
12782
12783      controller.append(provider, poster, controlBar);
12784
12785      captionsBtn.setAttribute("aria-checked", "true");
12786      controller.setAttribute("mediasubtitlesshowing", "on");
12787
12788      container.appendChild(controller);
12789      details.appendChild(container);
12790
12791      provider.addEventListener("waiting", () => container.classList.add("loading"));
12792      provider.addEventListener("playing", () => container.classList.remove("loading"));
12793      provider.addEventListener("play", () => container.classList.remove("loading"));
12794
12795      provider._textTracks.addEventListener("change", () => {
12796        const enabled = provider._captionsEnabled;
12797        captionsBtn.setAttribute("aria-checked", enabled);
12798        if (enabled) {
12799          controller.setAttribute("mediasubtitlesshowing", "on");
12800        } else {
12801          controller.removeAttribute("mediasubtitlesshowing");
12802        }
12803      });
12804
12805      provider.addEventListener("qualitychange", (e) => {
12806        qualityBtn.textContent = e.detail.quality;
12807      });
12808    }
12809
12810    function initLazyPlayers() {
12811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
12812        if (details.dataset.lazyInit) return;
12813        details.dataset.lazyInit = "true";
12814        details.addEventListener("toggle", () => {
12815          if (details.open && !details.querySelector(".player-container")) {
12816            createPlayer(details);
12817          } else if (!details.open) {
12818            const provider = details.querySelector("youtube-media-provider");
12819            if (provider && typeof provider.pause === "function") {
12820              provider.pause();
12821            }
12822          }
12823        });
12824      });
12825    }
12826
12827    if (document.readyState === "loading") {
12828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
12829    } else {
12830      initLazyPlayers();
12831    }
12832  })();
12833</script>
12833<script>
12834  (() => {
12835    if (window.__courseCompletionsInitialized) return;
12836    window.__courseCompletionsInitialized = true;
12837
12838    const STORAGE_KEY = "course-completions";
12839
12840    function slugify(text) {
12841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
12842    }
12843
12844    function getCompletions() {
12845      try {
12846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
12847      } catch {
12848        return [];
12849      }
12850    }
12851
12852    function saveCompletions(completions) {
12853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
12854    }
12855
12856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
12857    function canonicalFromStored(storedId) {
12858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12859      if (!m) return slugify(storedId);
12860      const [, slug, p, mv, title] = m;
12861      return `${slug}-${p}-${mv}-${slugify(title)}`;
12862    }
12863
12864    function canonicalPlusOne(storedId) {
12865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
12866      if (!m) return null;
12867      const [, slug, p, mv, title] = m;
12868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
12869    }
12870
12871    function isCompleted(canonicalId, completions) {
12872      if (completions.includes(canonicalId)) return true;
12873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
12874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
12875    }
12876
12877    function migrateCompletions() {
12878      const completions = getCompletions();
12879      let changed = false;
12880      const migrated = completions.map((c) => {
12881        const canon = canonicalFromStored(c);
12882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
12883        const plusOne = canonicalPlusOne(c);
12884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
12885          if (plusOne !== c) changed = true;
12886          return plusOne;
12887        }
12888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
12889          changed = true;
12890          return canon;
12891        }
12892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
12893        if (canon !== c) {
12894          changed = true;
12895          return canon;
12896        }
12897        return c;
12898      });
12899      // Elimina duplicados tras migración
12900      const deduped = [...new Set(migrated)];
12901      if (changed || deduped.length !== completions.length) {
12902        saveCompletions(deduped);
12903        return deduped;
12904      }
12905      return completions;
12906    }
12907
12908    function updateButtonState(btn, completions) {
12909      const id = btn.dataset.completionId;
12910      const list = completions ?? getCompletions();
12911      btn.classList.toggle("completed", isCompleted(id, list));
12912    }
12913
12914    function checkModuleCompletion(completions) {
12915      const list = completions ?? getCompletions();
12916      document.querySelectorAll(".course-video").forEach((module) => {
12917        const btns = module.querySelectorAll(".complete-btn");
12918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
12919        const check = module.querySelector(".module-check");
12920        if (check) check.classList.toggle("visible", allCompleted);
12921      });
12922    }
12923
12924    function initCompletionButtons() {
12925      const completions = migrateCompletions();
12926
12927      document.querySelectorAll(".complete-btn").forEach((btn) => {
12928        if (btn.dataset.completionInit) return;
12929        btn.dataset.completionInit = "true";
12930        updateButtonState(btn, completions);
12931
12932        btn.addEventListener("click", (e) => {
12933          e.preventDefault();
12934          e.stopPropagation();
12935
12936          const id = btn.dataset.completionId;
12937          let list = getCompletions();
12938          const completed = isCompleted(id, list);
12939
12940          if (completed) {
12941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
12942            list = list.filter((c) =>
12942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
12943          } else {
12944            list.push(id);
12945          }
12946
12947          saveCompletions(list);
12948          updateButtonState(btn, list);
12949          checkModuleCompletion(list);
12950        });
12951      });
12952
12953      checkModuleCompletion(completions);
12954    }
12955
12956    if (document.readyState === "loading") {
12957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
12958    } else {
12959      initCompletionButtons();
12960    }
12961  })();
12962</script>
12962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="19861" data-end="20357" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>15. Qwen3 Image Edit</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-qwen3-image-edit" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
12962<script>
12963  (() => {
12964    if (window.__courseLazyPlayersInitialized) return;
12965    window.__courseLazyPlayersInitialized = true;
12966
12967    function createPlayer(details) {
12968      const videoId = details.dataset.videoId;
12969      const start = details.dataset.start;
12970      const end = details.dataset.end;
12971
12972      const container = document.createElement("div");
12973      container.className = "player-container";
12974
12975      const spinner = document.createElement("div");
12976      spinner.className = "spinner";
12977      container.appendChild(spinner);
12978
12979      const controller = document.createElement("media-controller");
12980      controller.setAttribute("defaultsubtitles", "");
12981
12982      const provider = document.createElement("youtube-media-provider");
12983      provider.setAttribute("slot", "media");
12984      provider.setAttribute("video-id", videoId);
12985      provider.setAttribute("start", start);
12986      provider.setAttribute("end", end);
12987
12988      const controlBar = document.createElement("media-control-bar");
12989
12990      const playBtn = document.createElement("media-play-button");
12991      const timeDisplay = document.createElement("media-time-display");
12992      timeDisplay.setAttribute("showduration", "");
12993      const timeRange = document.createElement("media-time-range");
12994      const playbackRate = document.createElement("media-playback-rate-button");
12995      const muteBtn = document.createElement("media-mute-button");
12996      const volumeRange = document.createElement("media-volume-range");
12997
12998      const captionsBtn = document.createElement("media-captions-button");
12999      captionsBtn.addEventListener("click", (e) => {
13000        e.stopPropagation();
13001        const provider = container.querySelector("youtube-media-provider");
13002        if (provider) provider.toggleCaptions();
13003      });
13004
13005      const qualityBtn = document.createElement("media-chrome-button");
13006      qualityBtn.textContent = "--";
13007      qualityBtn.addEventListener("click", () => {
13008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
13009      });
13010
13011      const fullscreenBtn = document.createElement("media-fullscreen-button");
13012
13013      controlBar.append(
13014        playBtn, timeDisplay, timeRange,
13015        playbackRate, muteBtn, volumeRange,
13016        captionsBtn, qualityBtn, fullscreenBtn
13017      );
13018
13019      const poster = document.createElement("media-poster-image");
13020      poster.setAttribute("slot", "poster");
13021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
13022
13023      controller.append(provider, poster, controlBar);
13024
13025      captionsBtn.setAttribute("aria-checked", "true");
13026      controller.setAttribute("mediasubtitlesshowing", "on");
13027
13028      container.appendChild(controller);
13029      details.appendChild(container);
13030
13031      provider.addEventListener("waiting", () => container.classList.add("loading"));
13032      provider.addEventListener("playing", () => container.classList.remove("loading"));
13033      provider.addEventListener("play", () => container.classList.remove("loading"));
13034
13035      provider._textTracks.addEventListener("change", () => {
13036        const enabled = provider._captionsEnabled;
13037        captionsBtn.setAttribute("aria-checked", enabled);
13038        if (enabled) {
13039          controller.setAttribute("mediasubtitlesshowing", "on");
13040        } else {
13041          controller.removeAttribute("mediasubtitlesshowing");
13042        }
13043      });
13044
13045      provider.addEventListener("qualitychange", (e) => {
13046        qualityBtn.textContent = e.detail.quality;
13047      });
13048    }
13049
13050    function initLazyPlayers() {
13051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
13052        if (details.dataset.lazyInit) return;
13053        details.dataset.lazyInit = "true";
13054        details.addEventListener("toggle", () => {
13055          if (details.open && !details.querySelector(".player-container")) {
13056            createPlayer(details);
13057          } else if (!details.open) {
13058            const provider = details.querySelector("youtube-media-provider");
13059            if (provider && typeof provider.pause === "function") {
13060              provider.pause();
13061            }
13062          }
13063        });
13064      });
13065    }
13066
13067    if (document.readyState === "loading") {
13068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
13069    } else {
13070      initLazyPlayers();
13071    }
13072  })();
13073</script>
13073<script>
13074  (() => {
13075    if (window.__courseCompletionsInitialized) return;
13076    window.__courseCompletionsInitialized = true;
13077
13078    const STORAGE_KEY = "course-completions";
13079
13080    function slugify(text) {
13081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13082    }
13083
13084    function getCompletions() {
13085      try {
13086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
13087      } catch {
13088        return [];
13089      }
13090    }
13091
13092    function saveCompletions(completions) {
13093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
13094    }
13095
13096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
13097    function canonicalFromStored(storedId) {
13098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13099      if (!m) return slugify(storedId);
13100      const [, slug, p, mv, title] = m;
13101      return `${slug}-${p}-${mv}-${slugify(title)}`;
13102    }
13103
13104    function canonicalPlusOne(storedId) {
13105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13106      if (!m) return null;
13107      const [, slug, p, mv, title] = m;
13108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
13109    }
13110
13111    function isCompleted(canonicalId, completions) {
13112      if (completions.includes(canonicalId)) return true;
13113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
13114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
13115    }
13116
13117    function migrateCompletions() {
13118      const completions = getCompletions();
13119      let changed = false;
13120      const migrated = completions.map((c) => {
13121        const canon = canonicalFromStored(c);
13122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
13123        const plusOne = canonicalPlusOne(c);
13124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
13125          if (plusOne !== c) changed = true;
13126          return plusOne;
13127        }
13128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
13129          changed = true;
13130          return canon;
13131        }
13132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
13133        if (canon !== c) {
13134          changed = true;
13135          return canon;
13136        }
13137        return c;
13138      });
13139      // Elimina duplicados tras migración
13140      const deduped = [...new Set(migrated)];
13141      if (changed || deduped.length !== completions.length) {
13142        saveCompletions(deduped);
13143        return deduped;
13144      }
13145      return completions;
13146    }
13147
13148    function updateButtonState(btn, completions) {
13149      const id = btn.dataset.completionId;
13150      const list = completions ?? getCompletions();
13151      btn.classList.toggle("completed", isCompleted(id, list));
13152    }
13153
13154    function checkModuleCompletion(completions) {
13155      const list = completions ?? getCompletions();
13156      document.querySelectorAll(".course-video").forEach((module) => {
13157        const btns = module.querySelectorAll(".complete-btn");
13158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
13159        const check = module.querySelector(".module-check");
13160        if (check) check.classList.toggle("visible", allCompleted);
13161      });
13162    }
13163
13164    function initCompletionButtons() {
13165      const completions = migrateCompletions();
13166
13167      document.querySelectorAll(".complete-btn").forEach((btn) => {
13168        if (btn.dataset.completionInit) return;
13169        btn.dataset.completionInit = "true";
13170        updateButtonState(btn, completions);
13171
13172        btn.addEventListener("click", (e) => {
13173          e.preventDefault();
13174          e.stopPropagation();
13175
13176          const id = btn.dataset.completionId;
13177          let list = getCompletions();
13178          const completed = isCompleted(id, list);
13179
13180          if (completed) {
13181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
13182            list = list.filter((c) =>
13182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
13183          } else {
13184            list.push(id);
13185          }
13186
13187          saveCompletions(list);
13188          updateButtonState(btn, list);
13189          checkModuleCompletion(list);
13190        });
13191      });
13192
13193      checkModuleCompletion(completions);
13194    }
13195
13196    if (document.readyState === "loading") {
13197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
13198    } else {
13199      initCompletionButtons();
13200    }
13201  })();
13202</script>
13202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="lUzWlSngWXE" data-start="20357" data-end="21612" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>16. Preguntas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-1-4-preguntas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
13202<script>
13203  (() => {
13204    if (window.__courseLazyPlayersInitialized) return;
13205    window.__courseLazyPlayersInitialized = true;
13206
13207    function createPlayer(details) {
13208      const videoId = details.dataset.videoId;
13209      const start = details.dataset.start;
13210      const end = details.dataset.end;
13211
13212      const container = document.createElement("div");
13213      container.className = "player-container";
13214
13215      const spinner = document.createElement("div");
13216      spinner.className = "spinner";
13217      container.appendChild(spinner);
13218
13219      const controller = document.createElement("media-controller");
13220      controller.setAttribute("defaultsubtitles", "");
13221
13222      const provider = document.createElement("youtube-media-provider");
13223      provider.setAttribute("slot", "media");
13224      provider.setAttribute("video-id", videoId);
13225      provider.setAttribute("start", start);
13226      provider.setAttribute("end", end);
13227
13228      const controlBar = document.createElement("media-control-bar");
13229
13230      const playBtn = document.createElement("media-play-button");
13231      const timeDisplay = document.createElement("media-time-display");
13232      timeDisplay.setAttribute("showduration", "");
13233      const timeRange = document.createElement("media-time-range");
13234      const playbackRate = document.createElement("media-playback-rate-button");
13235      const muteBtn = document.createElement("media-mute-button");
13236      const volumeRange = document.createElement("media-volume-range");
13237
13238      const captionsBtn = document.createElement("media-captions-button");
13239      captionsBtn.addEventListener("click", (e) => {
13240        e.stopPropagation();
13241        const provider = container.querySelector("youtube-media-provider");
13242        if (provider) provider.toggleCaptions();
13243      });
13244
13245      const qualityBtn = document.createElement("media-chrome-button");
13246      qualityBtn.textContent = "--";
13247      qualityBtn.addEventListener("click", () => {
13248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
13249      });
13250
13251      const fullscreenBtn = document.createElement("media-fullscreen-button");
13252
13253      controlBar.append(
13254        playBtn, timeDisplay, timeRange,
13255        playbackRate, muteBtn, volumeRange,
13256        captionsBtn, qualityBtn, fullscreenBtn
13257      );
13258
13259      const poster = document.createElement("media-poster-image");
13260      poster.setAttribute("slot", "poster");
13261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
13262
13263      controller.append(provider, poster, controlBar);
13264
13265      captionsBtn.setAttribute("aria-checked", "true");
13266      controller.setAttribute("mediasubtitlesshowing", "on");
13267
13268      container.appendChild(controller);
13269      details.appendChild(container);
13270
13271      provider.addEventListener("waiting", () => container.classList.add("loading"));
13272      provider.addEventListener("playing", () => container.classList.remove("loading"));
13273      provider.addEventListener("play", () => container.classList.remove("loading"));
13274
13275      provider._textTracks.addEventListener("change", () => {
13276        const enabled = provider._captionsEnabled;
13277        captionsBtn.setAttribute("aria-checked", enabled);
13278        if (enabled) {
13279          controller.setAttribute("mediasubtitlesshowing", "on");
13280        } else {
13281          controller.removeAttribute("mediasubtitlesshowing");
13282        }
13283      });
13284
13285      provider.addEventListener("qualitychange", (e) => {
13286        qualityBtn.textContent = e.detail.quality;
13287      });
13288    }
13289
13290    function initLazyPlayers() {
13291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
13292        if (details.dataset.lazyInit) return;
13293        details.dataset.lazyInit = "true";
13294        details.addEventListener("toggle", () => {
13295          if (details.open && !details.querySelector(".player-container")) {
13296            createPlayer(details);
13297          } else if (!details.open) {
13298            const provider = details.querySelector("youtube-media-provider");
13299            if (provider && typeof provider.pause === "function") {
13300              provider.pause();
13301            }
13302          }
13303        });
13304      });
13305    }
13306
13307    if (document.readyState === "loading") {
13308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
13309    } else {
13310      initLazyPlayers();
13311    }
13312  })();
13313</script>
13313<script>
13314  (() => {
13315    if (window.__courseCompletionsInitialized) return;
13316    window.__courseCompletionsInitialized = true;
13317
13318    const STORAGE_KEY = "course-completions";
13319
13320    function slugify(text) {
13321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13322    }
13323
13324    function getCompletions() {
13325      try {
13326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
13327      } catch {
13328        return [];
13329      }
13330    }
13331
13332    function saveCompletions(completions) {
13333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
13334    }
13335
13336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
13337    function canonicalFromStored(storedId) {
13338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13339      if (!m) return slugify(storedId);
13340      const [, slug, p, mv, title] = m;
13341      return `${slug}-${p}-${mv}-${slugify(title)}`;
13342    }
13343
13344    function canonicalPlusOne(storedId) {
13345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13346      if (!m) return null;
13347      const [, slug, p, mv, title] = m;
13348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
13349    }
13350
13351    function isCompleted(canonicalId, completions) {
13352      if (completions.includes(canonicalId)) return true;
13353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
13354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
13355    }
13356
13357    function migrateCompletions() {
13358      const completions = getCompletions();
13359      let changed = false;
13360      const migrated = completions.map((c) => {
13361        const canon = canonicalFromStored(c);
13362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
13363        const plusOne = canonicalPlusOne(c);
13364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
13365          if (plusOne !== c) changed = true;
13366          return plusOne;
13367        }
13368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
13369          changed = true;
13370          return canon;
13371        }
13372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
13373        if (canon !== c) {
13374          changed = true;
13375          return canon;
13376        }
13377        return c;
13378      });
13379      // Elimina duplicados tras migración
13380      const deduped = [...new Set(migrated)];
13381      if (changed || deduped.length !== completions.length) {
13382        saveCompletions(deduped);
13383        return deduped;
13384      }
13385      return completions;
13386    }
13387
13388    function updateButtonState(btn, completions) {
13389      const id = btn.dataset.completionId;
13390      const list = completions ?? getCompletions();
13391      btn.classList.toggle("completed", isCompleted(id, list));
13392    }
13393
13394    function checkModuleCompletion(completions) {
13395      const list = completions ?? getCompletions();
13396      document.querySelectorAll(".course-video").forEach((module) => {
13397        const btns = module.querySelectorAll(".complete-btn");
13398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
13399        const check = module.querySelector(".module-check");
13400        if (check) check.classList.toggle("visible", allCompleted);
13401      });
13402    }
13403
13404    function initCompletionButtons() {
13405      const completions = migrateCompletions();
13406
13407      document.querySelectorAll(".complete-btn").forEach((btn) => {
13408        if (btn.dataset.completionInit) return;
13409        btn.dataset.completionInit = "true";
13410        updateButtonState(btn, completions);
13411
13412        btn.addEventListener("click", (e) => {
13413          e.preventDefault();
13414          e.stopPropagation();
13415
13416          const id = btn.dataset.completionId;
13417          let list = getCompletions();
13418          const completed = isCompleted(id, list);
13419
13420          if (completed) {
13421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
13422            list = list.filter((c) =>
13422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
13423          } else {
13424            list.push(id);
13425          }
13426
13427          saveCompletions(list);
13428          updateButtonState(btn, list);
13429          checkModuleCompletion(list);
13430        });
13431      });
13432
13433      checkModuleCompletion(completions);
13434    }
13435
13436    if (document.readyState === "loading") {
13437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
13438    } else {
13439      initCompletionButtons();
13440    }
13441  })();
13442</script>
13442</div></details></div></details><details class="phase" open data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>FASE 2: Front-end</summary><div class="phase-content" data-astro-cid-3yfbwi5j><div class="description" data-astro-cid-3yfbwi5j><p data-astro-cid-3yfbwi5j>En esta fase nos centraremos en los pilares del front-end. Como estructurar bien una página con HTML y todo lo que tiene alrededor, como utilizar CSS correctamente, aprender mecanismos para crear layouts e interfaces de usuario y CSS moderno y avanzado.</p></div><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 5 📙 HTML ¿Lenguaje de programación?<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="920" data-end="1380" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. ¿Qué es HTML?</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-que-es-html" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
13442<script>
13443  (() => {
13444    if (window.__courseLazyPlayersInitialized) return;
13445    window.__courseLazyPlayersInitialized = true;
13446
13447    function createPlayer(details) {
13448      const videoId = details.dataset.videoId;
13449      const start = details.dataset.start;
13450      const end = details.dataset.end;
13451
13452      const container = document.createElement("div");
13453      container.className = "player-container";
13454
13455      const spinner = document.createElement("div");
13456      spinner.className = "spinner";
13457      container.appendChild(spinner);
13458
13459      const controller = document.createElement("media-controller");
13460      controller.setAttribute("defaultsubtitles", "");
13461
13462      const provider = document.createElement("youtube-media-provider");
13463      provider.setAttribute("slot", "media");
13464      provider.setAttribute("video-id", videoId);
13465      provider.setAttribute("start", start);
13466      provider.setAttribute("end", end);
13467
13468      const controlBar = document.createElement("media-control-bar");
13469
13470      const playBtn = document.createElement("media-play-button");
13471      const timeDisplay = document.createElement("media-time-display");
13472      timeDisplay.setAttribute("showduration", "");
13473      const timeRange = document.createElement("media-time-range");
13474      const playbackRate = document.createElement("media-playback-rate-button");
13475      const muteBtn = document.createElement("media-mute-button");
13476      const volumeRange = document.createElement("media-volume-range");
13477
13478      const captionsBtn = document.createElement("media-captions-button");
13479      captionsBtn.addEventListener("click", (e) => {
13480        e.stopPropagation();
13481        const provider = container.querySelector("youtube-media-provider");
13482        if (provider) provider.toggleCaptions();
13483      });
13484
13485      const qualityBtn = document.createElement("media-chrome-button");
13486      qualityBtn.textContent = "--";
13487      qualityBtn.addEventListener("click", () => {
13488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
13489      });
13490
13491      const fullscreenBtn = document.createElement("media-fullscreen-button");
13492
13493      controlBar.append(
13494        playBtn, timeDisplay, timeRange,
13495        playbackRate, muteBtn, volumeRange,
13496        captionsBtn, qualityBtn, fullscreenBtn
13497      );
13498
13499      const poster = document.createElement("media-poster-image");
13500      poster.setAttribute("slot", "poster");
13501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
13502
13503      controller.append(provider, poster, controlBar);
13504
13505      captionsBtn.setAttribute("aria-checked", "true");
13506      controller.setAttribute("mediasubtitlesshowing", "on");
13507
13508      container.appendChild(controller);
13509      details.appendChild(container);
13510
13511      provider.addEventListener("waiting", () => container.classList.add("loading"));
13512      provider.addEventListener("playing", () => container.classList.remove("loading"));
13513      provider.addEventListener("play", () => container.classList.remove("loading"));
13514
13515      provider._textTracks.addEventListener("change", () => {
13516        const enabled = provider._captionsEnabled;
13517        captionsBtn.setAttribute("aria-checked", enabled);
13518        if (enabled) {
13519          controller.setAttribute("mediasubtitlesshowing", "on");
13520        } else {
13521          controller.removeAttribute("mediasubtitlesshowing");
13522        }
13523      });
13524
13525      provider.addEventListener("qualitychange", (e) => {
13526        qualityBtn.textContent = e.detail.quality;
13527      });
13528    }
13529
13530    function initLazyPlayers() {
13531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
13532        if (details.dataset.lazyInit) return;
13533        details.dataset.lazyInit = "true";
13534        details.addEventListener("toggle", () => {
13535          if (details.open && !details.querySelector(".player-container")) {
13536            createPlayer(details);
13537          } else if (!details.open) {
13538            const provider = details.querySelector("youtube-media-provider");
13539            if (provider && typeof provider.pause === "function") {
13540              provider.pause();
13541            }
13542          }
13543        });
13544      });
13545    }
13546
13547    if (document.readyState === "loading") {
13548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
13549    } else {
13550      initLazyPlayers();
13551    }
13552  })();
13553</script>
13553<script>
13554  (() => {
13555    if (window.__courseCompletionsInitialized) return;
13556    window.__courseCompletionsInitialized = true;
13557
13558    const STORAGE_KEY = "course-completions";
13559
13560    function slugify(text) {
13561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13562    }
13563
13564    function getCompletions() {
13565      try {
13566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
13567      } catch {
13568        return [];
13569      }
13570    }
13571
13572    function saveCompletions(completions) {
13573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
13574    }
13575
13576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
13577    function canonicalFromStored(storedId) {
13578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13579      if (!m) return slugify(storedId);
13580      const [, slug, p, mv, title] = m;
13581      return `${slug}-${p}-${mv}-${slugify(title)}`;
13582    }
13583
13584    function canonicalPlusOne(storedId) {
13585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13586      if (!m) return null;
13587      const [, slug, p, mv, title] = m;
13588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
13589    }
13590
13591    function isCompleted(canonicalId, completions) {
13592      if (completions.includes(canonicalId)) return true;
13593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
13594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
13595    }
13596
13597    function migrateCompletions() {
13598      const completions = getCompletions();
13599      let changed = false;
13600      const migrated = completions.map((c) => {
13601        const canon = canonicalFromStored(c);
13602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
13603        const plusOne = canonicalPlusOne(c);
13604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
13605          if (plusOne !== c) changed = true;
13606          return plusOne;
13607        }
13608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
13609          changed = true;
13610          return canon;
13611        }
13612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
13613        if (canon !== c) {
13614          changed = true;
13615          return canon;
13616        }
13617        return c;
13618      });
13619      // Elimina duplicados tras migración
13620      const deduped = [...new Set(migrated)];
13621      if (changed || deduped.length !== completions.length) {
13622        saveCompletions(deduped);
13623        return deduped;
13624      }
13625      return completions;
13626    }
13627
13628    function updateButtonState(btn, completions) {
13629      const id = btn.dataset.completionId;
13630      const list = completions ?? getCompletions();
13631      btn.classList.toggle("completed", isCompleted(id, list));
13632    }
13633
13634    function checkModuleCompletion(completions) {
13635      const list = completions ?? getCompletions();
13636      document.querySelectorAll(".course-video").forEach((module) => {
13637        const btns = module.querySelectorAll(".complete-btn");
13638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
13639        const check = module.querySelector(".module-check");
13640        if (check) check.classList.toggle("visible", allCompleted);
13641      });
13642    }
13643
13644    function initCompletionButtons() {
13645      const completions = migrateCompletions();
13646
13647      document.querySelectorAll(".complete-btn").forEach((btn) => {
13648        if (btn.dataset.completionInit) return;
13649        btn.dataset.completionInit = "true";
13650        updateButtonState(btn, completions);
13651
13652        btn.addEventListener("click", (e) => {
13653          e.preventDefault();
13654          e.stopPropagation();
13655
13656          const id = btn.dataset.completionId;
13657          let list = getCompletions();
13658          const completed = isCompleted(id, list);
13659
13660          if (completed) {
13661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
13662            list = list.filter((c) =>
13662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
13663          } else {
13664            list.push(id);
13665          }
13666
13667          saveCompletions(list);
13668          updateButtonState(btn, list);
13669          checkModuleCompletion(list);
13670        });
13671      });
13672
13673      checkModuleCompletion(completions);
13674    }
13675
13676    if (document.readyState === "loading") {
13677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
13678    } else {
13679      initCompletionButtons();
13680    }
13681  })();
13682</script>
13682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="1380" data-end="1815" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Sintaxis de HTML</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-sintaxis-de-html" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
13682<script>
13683  (() => {
13684    if (window.__courseLazyPlayersInitialized) return;
13685    window.__courseLazyPlayersInitialized = true;
13686
13687    function createPlayer(details) {
13688      const videoId = details.dataset.videoId;
13689      const start = details.dataset.start;
13690      const end = details.dataset.end;
13691
13692      const container = document.createElement("div");
13693      container.className = "player-container";
13694
13695      const spinner = document.createElement("div");
13696      spinner.className = "spinner";
13697      container.appendChild(spinner);
13698
13699      const controller = document.createElement("media-controller");
13700      controller.setAttribute("defaultsubtitles", "");
13701
13702      const provider = document.createElement("youtube-media-provider");
13703      provider.setAttribute("slot", "media");
13704      provider.setAttribute("video-id", videoId);
13705      provider.setAttribute("start", start);
13706      provider.setAttribute("end", end);
13707
13708      const controlBar = document.createElement("media-control-bar");
13709
13710      const playBtn = document.createElement("media-play-button");
13711      const timeDisplay = document.createElement("media-time-display");
13712      timeDisplay.setAttribute("showduration", "");
13713      const timeRange = document.createElement("media-time-range");
13714      const playbackRate = document.createElement("media-playback-rate-button");
13715      const muteBtn = document.createElement("media-mute-button");
13716      const volumeRange = document.createElement("media-volume-range");
13717
13718      const captionsBtn = document.createElement("media-captions-button");
13719      captionsBtn.addEventListener("click", (e) => {
13720        e.stopPropagation();
13721        const provider = container.querySelector("youtube-media-provider");
13722        if (provider) provider.toggleCaptions();
13723      });
13724
13725      const qualityBtn = document.createElement("media-chrome-button");
13726      qualityBtn.textContent = "--";
13727      qualityBtn.addEventListener("click", () => {
13728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
13729      });
13730
13731      const fullscreenBtn = document.createElement("media-fullscreen-button");
13732
13733      controlBar.append(
13734        playBtn, timeDisplay, timeRange,
13735        playbackRate, muteBtn, volumeRange,
13736        captionsBtn, qualityBtn, fullscreenBtn
13737      );
13738
13739      const poster = document.createElement("media-poster-image");
13740      poster.setAttribute("slot", "poster");
13741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
13742
13743      controller.append(provider, poster, controlBar);
13744
13745      captionsBtn.setAttribute("aria-checked", "true");
13746      controller.setAttribute("mediasubtitlesshowing", "on");
13747
13748      container.appendChild(controller);
13749      details.appendChild(container);
13750
13751      provider.addEventListener("waiting", () => container.classList.add("loading"));
13752      provider.addEventListener("playing", () => container.classList.remove("loading"));
13753      provider.addEventListener("play", () => container.classList.remove("loading"));
13754
13755      provider._textTracks.addEventListener("change", () => {
13756        const enabled = provider._captionsEnabled;
13757        captionsBtn.setAttribute("aria-checked", enabled);
13758        if (enabled) {
13759          controller.setAttribute("mediasubtitlesshowing", "on");
13760        } else {
13761          controller.removeAttribute("mediasubtitlesshowing");
13762        }
13763      });
13764
13765      provider.addEventListener("qualitychange", (e) => {
13766        qualityBtn.textContent = e.detail.quality;
13767      });
13768    }
13769
13770    function initLazyPlayers() {
13771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
13772        if (details.dataset.lazyInit) return;
13773        details.dataset.lazyInit = "true";
13774        details.addEventListener("toggle", () => {
13775          if (details.open && !details.querySelector(".player-container")) {
13776            createPlayer(details);
13777          } else if (!details.open) {
13778            const provider = details.querySelector("youtube-media-provider");
13779            if (provider && typeof provider.pause === "function") {
13780              provider.pause();
13781            }
13782          }
13783        });
13784      });
13785    }
13786
13787    if (document.readyState === "loading") {
13788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
13789    } else {
13790      initLazyPlayers();
13791    }
13792  })();
13793</script>
13793<script>
13794  (() => {
13795    if (window.__courseCompletionsInitialized) return;
13796    window.__courseCompletionsInitialized = true;
13797
13798    const STORAGE_KEY = "course-completions";
13799
13800    function slugify(text) {
13801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13802    }
13803
13804    function getCompletions() {
13805      try {
13806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
13807      } catch {
13808        return [];
13809      }
13810    }
13811
13812    function saveCompletions(completions) {
13813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
13814    }
13815
13816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
13817    function canonicalFromStored(storedId) {
13818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13819      if (!m) return slugify(storedId);
13820      const [, slug, p, mv, title] = m;
13821      return `${slug}-${p}-${mv}-${slugify(title)}`;
13822    }
13823
13824    function canonicalPlusOne(storedId) {
13825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
13826      if (!m) return null;
13827      const [, slug, p, mv, title] = m;
13828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
13829    }
13830
13831    function isCompleted(canonicalId, completions) {
13832      if (completions.includes(canonicalId)) return true;
13833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
13834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
13835    }
13836
13837    function migrateCompletions() {
13838      const completions = getCompletions();
13839      let changed = false;
13840      const migrated = completions.map((c) => {
13841        const canon = canonicalFromStored(c);
13842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
13843        const plusOne = canonicalPlusOne(c);
13844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
13845          if (plusOne !== c) changed = true;
13846          return plusOne;
13847        }
13848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
13849          changed = true;
13850          return canon;
13851        }
13852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
13853        if (canon !== c) {
13854          changed = true;
13855          return canon;
13856        }
13857        return c;
13858      });
13859      // Elimina duplicados tras migración
13860      const deduped = [...new Set(migrated)];
13861      if (changed || deduped.length !== completions.length) {
13862        saveCompletions(deduped);
13863        return deduped;
13864      }
13865      return completions;
13866    }
13867
13868    function updateButtonState(btn, completions) {
13869      const id = btn.dataset.completionId;
13870      const list = completions ?? getCompletions();
13871      btn.classList.toggle("completed", isCompleted(id, list));
13872    }
13873
13874    function checkModuleCompletion(completions) {
13875      const list = completions ?? getCompletions();
13876      document.querySelectorAll(".course-video").forEach((module) => {
13877        const btns = module.querySelectorAll(".complete-btn");
13878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
13879        const check = module.querySelector(".module-check");
13880        if (check) check.classList.toggle("visible", allCompleted);
13881      });
13882    }
13883
13884    function initCompletionButtons() {
13885      const completions = migrateCompletions();
13886
13887      document.querySelectorAll(".complete-btn").forEach((btn) => {
13888        if (btn.dataset.completionInit) return;
13889        btn.dataset.completionInit = "true";
13890        updateButtonState(btn, completions);
13891
13892        btn.addEventListener("click", (e) => {
13893          e.preventDefault();
13894          e.stopPropagation();
13895
13896          const id = btn.dataset.completionId;
13897          let list = getCompletions();
13898          const completed = isCompleted(id, list);
13899
13900          if (completed) {
13901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
13902            list = list.filter((c) =>
13902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
13903          } else {
13904            list.push(id);
13905          }
13906
13907          saveCompletions(list);
13908          updateButtonState(btn, list);
13909          checkModuleCompletion(list);
13910        });
13911      });
13912
13913      checkModuleCompletion(completions);
13914    }
13915
13916    if (document.readyState === "loading") {
13917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
13918    } else {
13919      initCompletionButtons();
13920    }
13921  })();
13922</script>
13922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="1815" data-end="3565" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Categorías de etiquetas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>29 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-categorias-de-etiquetas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
13922<script>
13923  (() => {
13924    if (window.__courseLazyPlayersInitialized) return;
13925    window.__courseLazyPlayersInitialized = true;
13926
13927    function createPlayer(details) {
13928      const videoId = details.dataset.videoId;
13929      const start = details.dataset.start;
13930      const end = details.dataset.end;
13931
13932      const container = document.createElement("div");
13933      container.className = "player-container";
13934
13935      const spinner = document.createElement("div");
13936      spinner.className = "spinner";
13937      container.appendChild(spinner);
13938
13939      const controller = document.createElement("media-controller");
13940      controller.setAttribute("defaultsubtitles", "");
13941
13942      const provider = document.createElement("youtube-media-provider");
13943      provider.setAttribute("slot", "media");
13944      provider.setAttribute("video-id", videoId);
13945      provider.setAttribute("start", start);
13946      provider.setAttribute("end", end);
13947
13948      const controlBar = document.createElement("media-control-bar");
13949
13950      const playBtn = document.createElement("media-play-button");
13951      const timeDisplay = document.createElement("media-time-display");
13952      timeDisplay.setAttribute("showduration", "");
13953      const timeRange = document.createElement("media-time-range");
13954      const playbackRate = document.createElement("media-playback-rate-button");
13955      const muteBtn = document.createElement("media-mute-button");
13956      const volumeRange = document.createElement("media-volume-range");
13957
13958      const captionsBtn = document.createElement("media-captions-button");
13959      captionsBtn.addEventListener("click", (e) => {
13960        e.stopPropagation();
13961        const provider = container.querySelector("youtube-media-provider");
13962        if (provider) provider.toggleCaptions();
13963      });
13964
13965      const qualityBtn = document.createElement("media-chrome-button");
13966      qualityBtn.textContent = "--";
13967      qualityBtn.addEventListener("click", () => {
13968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
13969      });
13970
13971      const fullscreenBtn = document.createElement("media-fullscreen-button");
13972
13973      controlBar.append(
13974        playBtn, timeDisplay, timeRange,
13975        playbackRate, muteBtn, volumeRange,
13976        captionsBtn, qualityBtn, fullscreenBtn
13977      );
13978
13979      const poster = document.createElement("media-poster-image");
13980      poster.setAttribute("slot", "poster");
13981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
13982
13983      controller.append(provider, poster, controlBar);
13984
13985      captionsBtn.setAttribute("aria-checked", "true");
13986      controller.setAttribute("mediasubtitlesshowing", "on");
13987
13988      container.appendChild(controller);
13989      details.appendChild(container);
13990
13991      provider.addEventListener("waiting", () => container.classList.add("loading"));
13992      provider.addEventListener("playing", () => container.classList.remove("loading"));
13993      provider.addEventListener("play", () => container.classList.remove("loading"));
13994
13995      provider._textTracks.addEventListener("change", () => {
13996        const enabled = provider._captionsEnabled;
13997        captionsBtn.setAttribute("aria-checked", enabled);
13998        if (enabled) {
13999          controller.setAttribute("mediasubtitlesshowing", "on");
14000        } else {
14001          controller.removeAttribute("mediasubtitlesshowing");
14002        }
14003      });
14004
14005      provider.addEventListener("qualitychange", (e) => {
14006        qualityBtn.textContent = e.detail.quality;
14007      });
14008    }
14009
14010    function initLazyPlayers() {
14011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
14012        if (details.dataset.lazyInit) return;
14013        details.dataset.lazyInit = "true";
14014        details.addEventListener("toggle", () => {
14015          if (details.open && !details.querySelector(".player-container")) {
14016            createPlayer(details);
14017          } else if (!details.open) {
14018            const provider = details.querySelector("youtube-media-provider");
14019            if (provider && typeof provider.pause === "function") {
14020              provider.pause();
14021            }
14022          }
14023        });
14024      });
14025    }
14026
14027    if (document.readyState === "loading") {
14028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
14029    } else {
14030      initLazyPlayers();
14031    }
14032  })();
14033</script>
14033<script>
14034  (() => {
14035    if (window.__courseCompletionsInitialized) return;
14036    window.__courseCompletionsInitialized = true;
14037
14038    const STORAGE_KEY = "course-completions";
14039
14040    function slugify(text) {
14041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
14042    }
14043
14044    function getCompletions() {
14045      try {
14046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
14047      } catch {
14048        return [];
14049      }
14050    }
14051
14052    function saveCompletions(completions) {
14053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
14054    }
14055
14056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
14057    function canonicalFromStored(storedId) {
14058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14059      if (!m) return slugify(storedId);
14060      const [, slug, p, mv, title] = m;
14061      return `${slug}-${p}-${mv}-${slugify(title)}`;
14062    }
14063
14064    function canonicalPlusOne(storedId) {
14065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14066      if (!m) return null;
14067      const [, slug, p, mv, title] = m;
14068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
14069    }
14070
14071    function isCompleted(canonicalId, completions) {
14072      if (completions.includes(canonicalId)) return true;
14073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
14074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
14075    }
14076
14077    function migrateCompletions() {
14078      const completions = getCompletions();
14079      let changed = false;
14080      const migrated = completions.map((c) => {
14081        const canon = canonicalFromStored(c);
14082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
14083        const plusOne = canonicalPlusOne(c);
14084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
14085          if (plusOne !== c) changed = true;
14086          return plusOne;
14087        }
14088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
14089          changed = true;
14090          return canon;
14091        }
14092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
14093        if (canon !== c) {
14094          changed = true;
14095          return canon;
14096        }
14097        return c;
14098      });
14099      // Elimina duplicados tras migración
14100      const deduped = [...new Set(migrated)];
14101      if (changed || deduped.length !== completions.length) {
14102        saveCompletions(deduped);
14103        return deduped;
14104      }
14105      return completions;
14106    }
14107
14108    function updateButtonState(btn, completions) {
14109      const id = btn.dataset.completionId;
14110      const list = completions ?? getCompletions();
14111      btn.classList.toggle("completed", isCompleted(id, list));
14112    }
14113
14114    function checkModuleCompletion(completions) {
14115      const list = completions ?? getCompletions();
14116      document.querySelectorAll(".course-video").forEach((module) => {
14117        const btns = module.querySelectorAll(".complete-btn");
14118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
14119        const check = module.querySelector(".module-check");
14120        if (check) check.classList.toggle("visible", allCompleted);
14121      });
14122    }
14123
14124    function initCompletionButtons() {
14125      const completions = migrateCompletions();
14126
14127      document.querySelectorAll(".complete-btn").forEach((btn) => {
14128        if (btn.dataset.completionInit) return;
14129        btn.dataset.completionInit = "true";
14130        updateButtonState(btn, completions);
14131
14132        btn.addEventListener("click", (e) => {
14133          e.preventDefault();
14134          e.stopPropagation();
14135
14136          const id = btn.dataset.completionId;
14137          let list = getCompletions();
14138          const completed = isCompleted(id, list);
14139
14140          if (completed) {
14141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
14142            list = list.filter((c) =>
14142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
14143          } else {
14144            list.push(id);
14145          }
14146
14147          saveCompletions(list);
14148          updateButtonState(btn, list);
14149          checkModuleCompletion(list);
14150        });
14151      });
14152
14153      checkModuleCompletion(completions);
14154    }
14155
14156    if (document.readyState === "loading") {
14157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
14158    } else {
14159      initCompletionButtons();
14160    }
14161  })();
14162</script>
14162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="3565" data-end="4315" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Agrupación y texto</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-agrupacion-y-texto" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
14162<script>
14163  (() => {
14164    if (window.__courseLazyPlayersInitialized) return;
14165    window.__courseLazyPlayersInitialized = true;
14166
14167    function createPlayer(details) {
14168      const videoId = details.dataset.videoId;
14169      const start = details.dataset.start;
14170      const end = details.dataset.end;
14171
14172      const container = document.createElement("div");
14173      container.className = "player-container";
14174
14175      const spinner = document.createElement("div");
14176      spinner.className = "spinner";
14177      container.appendChild(spinner);
14178
14179      const controller = document.createElement("media-controller");
14180      controller.setAttribute("defaultsubtitles", "");
14181
14182      const provider = document.createElement("youtube-media-provider");
14183      provider.setAttribute("slot", "media");
14184      provider.setAttribute("video-id", videoId);
14185      provider.setAttribute("start", start);
14186      provider.setAttribute("end", end);
14187
14188      const controlBar = document.createElement("media-control-bar");
14189
14190      const playBtn = document.createElement("media-play-button");
14191      const timeDisplay = document.createElement("media-time-display");
14192      timeDisplay.setAttribute("showduration", "");
14193      const timeRange = document.createElement("media-time-range");
14194      const playbackRate = document.createElement("media-playback-rate-button");
14195      const muteBtn = document.createElement("media-mute-button");
14196      const volumeRange = document.createElement("media-volume-range");
14197
14198      const captionsBtn = document.createElement("media-captions-button");
14199      captionsBtn.addEventListener("click", (e) => {
14200        e.stopPropagation();
14201        const provider = container.querySelector("youtube-media-provider");
14202        if (provider) provider.toggleCaptions();
14203      });
14204
14205      const qualityBtn = document.createElement("media-chrome-button");
14206      qualityBtn.textContent = "--";
14207      qualityBtn.addEventListener("click", () => {
14208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
14209      });
14210
14211      const fullscreenBtn = document.createElement("media-fullscreen-button");
14212
14213      controlBar.append(
14214        playBtn, timeDisplay, timeRange,
14215        playbackRate, muteBtn, volumeRange,
14216        captionsBtn, qualityBtn, fullscreenBtn
14217      );
14218
14219      const poster = document.createElement("media-poster-image");
14220      poster.setAttribute("slot", "poster");
14221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
14222
14223      controller.append(provider, poster, controlBar);
14224
14225      captionsBtn.setAttribute("aria-checked", "true");
14226      controller.setAttribute("mediasubtitlesshowing", "on");
14227
14228      container.appendChild(controller);
14229      details.appendChild(container);
14230
14231      provider.addEventListener("waiting", () => container.classList.add("loading"));
14232      provider.addEventListener("playing", () => container.classList.remove("loading"));
14233      provider.addEventListener("play", () => container.classList.remove("loading"));
14234
14235      provider._textTracks.addEventListener("change", () => {
14236        const enabled = provider._captionsEnabled;
14237        captionsBtn.setAttribute("aria-checked", enabled);
14238        if (enabled) {
14239          controller.setAttribute("mediasubtitlesshowing", "on");
14240        } else {
14241          controller.removeAttribute("mediasubtitlesshowing");
14242        }
14243      });
14244
14245      provider.addEventListener("qualitychange", (e) => {
14246        qualityBtn.textContent = e.detail.quality;
14247      });
14248    }
14249
14250    function initLazyPlayers() {
14251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
14252        if (details.dataset.lazyInit) return;
14253        details.dataset.lazyInit = "true";
14254        details.addEventListener("toggle", () => {
14255          if (details.open && !details.querySelector(".player-container")) {
14256            createPlayer(details);
14257          } else if (!details.open) {
14258            const provider = details.querySelector("youtube-media-provider");
14259            if (provider && typeof provider.pause === "function") {
14260              provider.pause();
14261            }
14262          }
14263        });
14264      });
14265    }
14266
14267    if (document.readyState === "loading") {
14268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
14269    } else {
14270      initLazyPlayers();
14271    }
14272  })();
14273</script>
14273<script>
14274  (() => {
14275    if (window.__courseCompletionsInitialized) return;
14276    window.__courseCompletionsInitialized = true;
14277
14278    const STORAGE_KEY = "course-completions";
14279
14280    function slugify(text) {
14281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
14282    }
14283
14284    function getCompletions() {
14285      try {
14286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
14287      } catch {
14288        return [];
14289      }
14290    }
14291
14292    function saveCompletions(completions) {
14293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
14294    }
14295
14296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
14297    function canonicalFromStored(storedId) {
14298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14299      if (!m) return slugify(storedId);
14300      const [, slug, p, mv, title] = m;
14301      return `${slug}-${p}-${mv}-${slugify(title)}`;
14302    }
14303
14304    function canonicalPlusOne(storedId) {
14305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14306      if (!m) return null;
14307      const [, slug, p, mv, title] = m;
14308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
14309    }
14310
14311    function isCompleted(canonicalId, completions) {
14312      if (completions.includes(canonicalId)) return true;
14313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
14314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
14315    }
14316
14317    function migrateCompletions() {
14318      const completions = getCompletions();
14319      let changed = false;
14320      const migrated = completions.map((c) => {
14321        const canon = canonicalFromStored(c);
14322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
14323        const plusOne = canonicalPlusOne(c);
14324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
14325          if (plusOne !== c) changed = true;
14326          return plusOne;
14327        }
14328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
14329          changed = true;
14330          return canon;
14331        }
14332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
14333        if (canon !== c) {
14334          changed = true;
14335          return canon;
14336        }
14337        return c;
14338      });
14339      // Elimina duplicados tras migración
14340      const deduped = [...new Set(migrated)];
14341      if (changed || deduped.length !== completions.length) {
14342        saveCompletions(deduped);
14343        return deduped;
14344      }
14345      return completions;
14346    }
14347
14348    function updateButtonState(btn, completions) {
14349      const id = btn.dataset.completionId;
14350      const list = completions ?? getCompletions();
14351      btn.classList.toggle("completed", isCompleted(id, list));
14352    }
14353
14354    function checkModuleCompletion(completions) {
14355      const list = completions ?? getCompletions();
14356      document.querySelectorAll(".course-video").forEach((module) => {
14357        const btns = module.querySelectorAll(".complete-btn");
14358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
14359        const check = module.querySelector(".module-check");
14360        if (check) check.classList.toggle("visible", allCompleted);
14361      });
14362    }
14363
14364    function initCompletionButtons() {
14365      const completions = migrateCompletions();
14366
14367      document.querySelectorAll(".complete-btn").forEach((btn) => {
14368        if (btn.dataset.completionInit) return;
14369        btn.dataset.completionInit = "true";
14370        updateButtonState(btn, completions);
14371
14372        btn.addEventListener("click", (e) => {
14373          e.preventDefault();
14374          e.stopPropagation();
14375
14376          const id = btn.dataset.completionId;
14377          let list = getCompletions();
14378          const completed = isCompleted(id, list);
14379
14380          if (completed) {
14381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
14382            list = list.filter((c) =>
14382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
14383          } else {
14384            list.push(id);
14385          }
14386
14387          saveCompletions(list);
14388          updateButtonState(btn, list);
14389          checkModuleCompletion(list);
14390        });
14391      });
14392
14393      checkModuleCompletion(completions);
14394    }
14395
14396    if (document.readyState === "loading") {
14397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
14398    } else {
14399      initCompletionButtons();
14400    }
14401  })();
14402</script>
14402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="4315" data-end="4963" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Enlaces</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-enlaces" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
14402<script>
14403  (() => {
14404    if (window.__courseLazyPlayersInitialized) return;
14405    window.__courseLazyPlayersInitialized = true;
14406
14407    function createPlayer(details) {
14408      const videoId = details.dataset.videoId;
14409      const start = details.dataset.start;
14410      const end = details.dataset.end;
14411
14412      const container = document.createElement("div");
14413      container.className = "player-container";
14414
14415      const spinner = document.createElement("div");
14416      spinner.className = "spinner";
14417      container.appendChild(spinner);
14418
14419      const controller = document.createElement("media-controller");
14420      controller.setAttribute("defaultsubtitles", "");
14421
14422      const provider = document.createElement("youtube-media-provider");
14423      provider.setAttribute("slot", "media");
14424      provider.setAttribute("video-id", videoId);
14425      provider.setAttribute("start", start);
14426      provider.setAttribute("end", end);
14427
14428      const controlBar = document.createElement("media-control-bar");
14429
14430      const playBtn = document.createElement("media-play-button");
14431      const timeDisplay = document.createElement("media-time-display");
14432      timeDisplay.setAttribute("showduration", "");
14433      const timeRange = document.createElement("media-time-range");
14434      const playbackRate = document.createElement("media-playback-rate-button");
14435      const muteBtn = document.createElement("media-mute-button");
14436      const volumeRange = document.createElement("media-volume-range");
14437
14438      const captionsBtn = document.createElement("media-captions-button");
14439      captionsBtn.addEventListener("click", (e) => {
14440        e.stopPropagation();
14441        const provider = container.querySelector("youtube-media-provider");
14442        if (provider) provider.toggleCaptions();
14443      });
14444
14445      const qualityBtn = document.createElement("media-chrome-button");
14446      qualityBtn.textContent = "--";
14447      qualityBtn.addEventListener("click", () => {
14448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
14449      });
14450
14451      const fullscreenBtn = document.createElement("media-fullscreen-button");
14452
14453      controlBar.append(
14454        playBtn, timeDisplay, timeRange,
14455        playbackRate, muteBtn, volumeRange,
14456        captionsBtn, qualityBtn, fullscreenBtn
14457      );
14458
14459      const poster = document.createElement("media-poster-image");
14460      poster.setAttribute("slot", "poster");
14461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
14462
14463      controller.append(provider, poster, controlBar);
14464
14465      captionsBtn.setAttribute("aria-checked", "true");
14466      controller.setAttribute("mediasubtitlesshowing", "on");
14467
14468      container.appendChild(controller);
14469      details.appendChild(container);
14470
14471      provider.addEventListener("waiting", () => container.classList.add("loading"));
14472      provider.addEventListener("playing", () => container.classList.remove("loading"));
14473      provider.addEventListener("play", () => container.classList.remove("loading"));
14474
14475      provider._textTracks.addEventListener("change", () => {
14476        const enabled = provider._captionsEnabled;
14477        captionsBtn.setAttribute("aria-checked", enabled);
14478        if (enabled) {
14479          controller.setAttribute("mediasubtitlesshowing", "on");
14480        } else {
14481          controller.removeAttribute("mediasubtitlesshowing");
14482        }
14483      });
14484
14485      provider.addEventListener("qualitychange", (e) => {
14486        qualityBtn.textContent = e.detail.quality;
14487      });
14488    }
14489
14490    function initLazyPlayers() {
14491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
14492        if (details.dataset.lazyInit) return;
14493        details.dataset.lazyInit = "true";
14494        details.addEventListener("toggle", () => {
14495          if (details.open && !details.querySelector(".player-container")) {
14496            createPlayer(details);
14497          } else if (!details.open) {
14498            const provider = details.querySelector("youtube-media-provider");
14499            if (provider && typeof provider.pause === "function") {
14500              provider.pause();
14501            }
14502          }
14503        });
14504      });
14505    }
14506
14507    if (document.readyState === "loading") {
14508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
14509    } else {
14510      initLazyPlayers();
14511    }
14512  })();
14513</script>
14513<script>
14514  (() => {
14515    if (window.__courseCompletionsInitialized) return;
14516    window.__courseCompletionsInitialized = true;
14517
14518    const STORAGE_KEY = "course-completions";
14519
14520    function slugify(text) {
14521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
14522    }
14523
14524    function getCompletions() {
14525      try {
14526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
14527      } catch {
14528        return [];
14529      }
14530    }
14531
14532    function saveCompletions(completions) {
14533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
14534    }
14535
14536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
14537    function canonicalFromStored(storedId) {
14538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14539      if (!m) return slugify(storedId);
14540      const [, slug, p, mv, title] = m;
14541      return `${slug}-${p}-${mv}-${slugify(title)}`;
14542    }
14543
14544    function canonicalPlusOne(storedId) {
14545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14546      if (!m) return null;
14547      const [, slug, p, mv, title] = m;
14548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
14549    }
14550
14551    function isCompleted(canonicalId, completions) {
14552      if (completions.includes(canonicalId)) return true;
14553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
14554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
14555    }
14556
14557    function migrateCompletions() {
14558      const completions = getCompletions();
14559      let changed = false;
14560      const migrated = completions.map((c) => {
14561        const canon = canonicalFromStored(c);
14562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
14563        const plusOne = canonicalPlusOne(c);
14564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
14565          if (plusOne !== c) changed = true;
14566          return plusOne;
14567        }
14568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
14569          changed = true;
14570          return canon;
14571        }
14572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
14573        if (canon !== c) {
14574          changed = true;
14575          return canon;
14576        }
14577        return c;
14578      });
14579      // Elimina duplicados tras migración
14580      const deduped = [...new Set(migrated)];
14581      if (changed || deduped.length !== completions.length) {
14582        saveCompletions(deduped);
14583        return deduped;
14584      }
14585      return completions;
14586    }
14587
14588    function updateButtonState(btn, completions) {
14589      const id = btn.dataset.completionId;
14590      const list = completions ?? getCompletions();
14591      btn.classList.toggle("completed", isCompleted(id, list));
14592    }
14593
14594    function checkModuleCompletion(completions) {
14595      const list = completions ?? getCompletions();
14596      document.querySelectorAll(".course-video").forEach((module) => {
14597        const btns = module.querySelectorAll(".complete-btn");
14598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
14599        const check = module.querySelector(".module-check");
14600        if (check) check.classList.toggle("visible", allCompleted);
14601      });
14602    }
14603
14604    function initCompletionButtons() {
14605      const completions = migrateCompletions();
14606
14607      document.querySelectorAll(".complete-btn").forEach((btn) => {
14608        if (btn.dataset.completionInit) return;
14609        btn.dataset.completionInit = "true";
14610        updateButtonState(btn, completions);
14611
14612        btn.addEventListener("click", (e) => {
14613          e.preventDefault();
14614          e.stopPropagation();
14615
14616          const id = btn.dataset.completionId;
14617          let list = getCompletions();
14618          const completed = isCompleted(id, list);
14619
14620          if (completed) {
14621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
14622            list = list.filter((c) =>
14622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
14623          } else {
14624            list.push(id);
14625          }
14626
14627          saveCompletions(list);
14628          updateButtonState(btn, list);
14629          checkModuleCompletion(list);
14630        });
14631      });
14632
14633      checkModuleCompletion(completions);
14634    }
14635
14636    if (document.readyState === "loading") {
14637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
14638    } else {
14639      initCompletionButtons();
14640    }
14641  })();
14642</script>
14642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="4963" data-end="6885" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Multimedia (Imagen)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>32 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-multimedia-imagen" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
14642<script>
14643  (() => {
14644    if (window.__courseLazyPlayersInitialized) return;
14645    window.__courseLazyPlayersInitialized = true;
14646
14647    function createPlayer(details) {
14648      const videoId = details.dataset.videoId;
14649      const start = details.dataset.start;
14650      const end = details.dataset.end;
14651
14652      const container = document.createElement("div");
14653      container.className = "player-container";
14654
14655      const spinner = document.createElement("div");
14656      spinner.className = "spinner";
14657      container.appendChild(spinner);
14658
14659      const controller = document.createElement("media-controller");
14660      controller.setAttribute("defaultsubtitles", "");
14661
14662      const provider = document.createElement("youtube-media-provider");
14663      provider.setAttribute("slot", "media");
14664      provider.setAttribute("video-id", videoId);
14665      provider.setAttribute("start", start);
14666      provider.setAttribute("end", end);
14667
14668      const controlBar = document.createElement("media-control-bar");
14669
14670      const playBtn = document.createElement("media-play-button");
14671      const timeDisplay = document.createElement("media-time-display");
14672      timeDisplay.setAttribute("showduration", "");
14673      const timeRange = document.createElement("media-time-range");
14674      const playbackRate = document.createElement("media-playback-rate-button");
14675      const muteBtn = document.createElement("media-mute-button");
14676      const volumeRange = document.createElement("media-volume-range");
14677
14678      const captionsBtn = document.createElement("media-captions-button");
14679      captionsBtn.addEventListener("click", (e) => {
14680        e.stopPropagation();
14681        const provider = container.querySelector("youtube-media-provider");
14682        if (provider) provider.toggleCaptions();
14683      });
14684
14685      const qualityBtn = document.createElement("media-chrome-button");
14686      qualityBtn.textContent = "--";
14687      qualityBtn.addEventListener("click", () => {
14688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
14689      });
14690
14691      const fullscreenBtn = document.createElement("media-fullscreen-button");
14692
14693      controlBar.append(
14694        playBtn, timeDisplay, timeRange,
14695        playbackRate, muteBtn, volumeRange,
14696        captionsBtn, qualityBtn, fullscreenBtn
14697      );
14698
14699      const poster = document.createElement("media-poster-image");
14700      poster.setAttribute("slot", "poster");
14701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
14702
14703      controller.append(provider, poster, controlBar);
14704
14705      captionsBtn.setAttribute("aria-checked", "true");
14706      controller.setAttribute("mediasubtitlesshowing", "on");
14707
14708      container.appendChild(controller);
14709      details.appendChild(container);
14710
14711      provider.addEventListener("waiting", () => container.classList.add("loading"));
14712      provider.addEventListener("playing", () => container.classList.remove("loading"));
14713      provider.addEventListener("play", () => container.classList.remove("loading"));
14714
14715      provider._textTracks.addEventListener("change", () => {
14716        const enabled = provider._captionsEnabled;
14717        captionsBtn.setAttribute("aria-checked", enabled);
14718        if (enabled) {
14719          controller.setAttribute("mediasubtitlesshowing", "on");
14720        } else {
14721          controller.removeAttribute("mediasubtitlesshowing");
14722        }
14723      });
14724
14725      provider.addEventListener("qualitychange", (e) => {
14726        qualityBtn.textContent = e.detail.quality;
14727      });
14728    }
14729
14730    function initLazyPlayers() {
14731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
14732        if (details.dataset.lazyInit) return;
14733        details.dataset.lazyInit = "true";
14734        details.addEventListener("toggle", () => {
14735          if (details.open && !details.querySelector(".player-container")) {
14736            createPlayer(details);
14737          } else if (!details.open) {
14738            const provider = details.querySelector("youtube-media-provider");
14739            if (provider && typeof provider.pause === "function") {
14740              provider.pause();
14741            }
14742          }
14743        });
14744      });
14745    }
14746
14747    if (document.readyState === "loading") {
14748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
14749    } else {
14750      initLazyPlayers();
14751    }
14752  })();
14753</script>
14753<script>
14754  (() => {
14755    if (window.__courseCompletionsInitialized) return;
14756    window.__courseCompletionsInitialized = true;
14757
14758    const STORAGE_KEY = "course-completions";
14759
14760    function slugify(text) {
14761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
14762    }
14763
14764    function getCompletions() {
14765      try {
14766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
14767      } catch {
14768        return [];
14769      }
14770    }
14771
14772    function saveCompletions(completions) {
14773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
14774    }
14775
14776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
14777    function canonicalFromStored(storedId) {
14778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14779      if (!m) return slugify(storedId);
14780      const [, slug, p, mv, title] = m;
14781      return `${slug}-${p}-${mv}-${slugify(title)}`;
14782    }
14783
14784    function canonicalPlusOne(storedId) {
14785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
14786      if (!m) return null;
14787      const [, slug, p, mv, title] = m;
14788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
14789    }
14790
14791    function isCompleted(canonicalId, completions) {
14792      if (completions.includes(canonicalId)) return true;
14793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
14794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
14795    }
14796
14797    function migrateCompletions() {
14798      const completions = getCompletions();
14799      let changed = false;
14800      const migrated = completions.map((c) => {
14801        const canon = canonicalFromStored(c);
14802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
14803        const plusOne = canonicalPlusOne(c);
14804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
14805          if (plusOne !== c) changed = true;
14806          return plusOne;
14807        }
14808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
14809          changed = true;
14810          return canon;
14811        }
14812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
14813        if (canon !== c) {
14814          changed = true;
14815          return canon;
14816        }
14817        return c;
14818      });
14819      // Elimina duplicados tras migración
14820      const deduped = [...new Set(migrated)];
14821      if (changed || deduped.length !== completions.length) {
14822        saveCompletions(deduped);
14823        return deduped;
14824      }
14825      return completions;
14826    }
14827
14828    function updateButtonState(btn, completions) {
14829      const id = btn.dataset.completionId;
14830      const list = completions ?? getCompletions();
14831      btn.classList.toggle("completed", isCompleted(id, list));
14832    }
14833
14834    function checkModuleCompletion(completions) {
14835      const list = completions ?? getCompletions();
14836      document.querySelectorAll(".course-video").forEach((module) => {
14837        const btns = module.querySelectorAll(".complete-btn");
14838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
14839        const check = module.querySelector(".module-check");
14840        if (check) check.classList.toggle("visible", allCompleted);
14841      });
14842    }
14843
14844    function initCompletionButtons() {
14845      const completions = migrateCompletions();
14846
14847      document.querySelectorAll(".complete-btn").forEach((btn) => {
14848        if (btn.dataset.completionInit) return;
14849        btn.dataset.completionInit = "true";
14850        updateButtonState(btn, completions);
14851
14852        btn.addEventListener("click", (e) => {
14853          e.preventDefault();
14854          e.stopPropagation();
14855
14856          const id = btn.dataset.completionId;
14857          let list = getCompletions();
14858          const completed = isCompleted(id, list);
14859
14860          if (completed) {
14861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
14862            list = list.filter((c) =>
14862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
14863          } else {
14864            list.push(id);
14865          }
14866
14867          saveCompletions(list);
14868          updateButtonState(btn, list);
14869          checkModuleCompletion(list);
14870        });
14871      });
14872
14873      checkModuleCompletion(completions);
14874    }
14875
14876    if (document.readyState === "loading") {
14877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
14878    } else {
14879      initCompletionButtons();
14880    }
14881  })();
14882</script>
14882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="7200" data-end="8982" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Multimedia (Video/Audio)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>30 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-multimedia-video-audio" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
14882<script>
14883  (() => {
14884    if (window.__courseLazyPlayersInitialized) return;
14885    window.__courseLazyPlayersInitialized = true;
14886
14887    function createPlayer(details) {
14888      const videoId = details.dataset.videoId;
14889      const start = details.dataset.start;
14890      const end = details.dataset.end;
14891
14892      const container = document.createElement("div");
14893      container.className = "player-container";
14894
14895      const spinner = document.createElement("div");
14896      spinner.className = "spinner";
14897      container.appendChild(spinner);
14898
14899      const controller = document.createElement("media-controller");
14900      controller.setAttribute("defaultsubtitles", "");
14901
14902      const provider = document.createElement("youtube-media-provider");
14903      provider.setAttribute("slot", "media");
14904      provider.setAttribute("video-id", videoId);
14905      provider.setAttribute("start", start);
14906      provider.setAttribute("end", end);
14907
14908      const controlBar = document.createElement("media-control-bar");
14909
14910      const playBtn = document.createElement("media-play-button");
14911      const timeDisplay = document.createElement("media-time-display");
14912      timeDisplay.setAttribute("showduration", "");
14913      const timeRange = document.createElement("media-time-range");
14914      const playbackRate = document.createElement("media-playback-rate-button");
14915      const muteBtn = document.createElement("media-mute-button");
14916      const volumeRange = document.createElement("media-volume-range");
14917
14918      const captionsBtn = document.createElement("media-captions-button");
14919      captionsBtn.addEventListener("click", (e) => {
14920        e.stopPropagation();
14921        const provider = container.querySelector("youtube-media-provider");
14922        if (provider) provider.toggleCaptions();
14923      });
14924
14925      const qualityBtn = document.createElement("media-chrome-button");
14926      qualityBtn.textContent = "--";
14927      qualityBtn.addEventListener("click", () => {
14928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
14929      });
14930
14931      const fullscreenBtn = document.createElement("media-fullscreen-button");
14932
14933      controlBar.append(
14934        playBtn, timeDisplay, timeRange,
14935        playbackRate, muteBtn, volumeRange,
14936        captionsBtn, qualityBtn, fullscreenBtn
14937      );
14938
14939      const poster = document.createElement("media-poster-image");
14940      poster.setAttribute("slot", "poster");
14941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
14942
14943      controller.append(provider, poster, controlBar);
14944
14945      captionsBtn.setAttribute("aria-checked", "true");
14946      controller.setAttribute("mediasubtitlesshowing", "on");
14947
14948      container.appendChild(controller);
14949      details.appendChild(container);
14950
14951      provider.addEventListener("waiting", () => container.classList.add("loading"));
14952      provider.addEventListener("playing", () => container.classList.remove("loading"));
14953      provider.addEventListener("play", () => container.classList.remove("loading"));
14954
14955      provider._textTracks.addEventListener("change", () => {
14956        const enabled = provider._captionsEnabled;
14957        captionsBtn.setAttribute("aria-checked", enabled);
14958        if (enabled) {
14959          controller.setAttribute("mediasubtitlesshowing", "on");
14960        } else {
14961          controller.removeAttribute("mediasubtitlesshowing");
14962        }
14963      });
14964
14965      provider.addEventListener("qualitychange", (e) => {
14966        qualityBtn.textContent = e.detail.quality;
14967      });
14968    }
14969
14970    function initLazyPlayers() {
14971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
14972        if (details.dataset.lazyInit) return;
14973        details.dataset.lazyInit = "true";
14974        details.addEventListener("toggle", () => {
14975          if (details.open && !details.querySelector(".player-container")) {
14976            createPlayer(details);
14977          } else if (!details.open) {
14978            const provider = details.querySelector("youtube-media-provider");
14979            if (provider && typeof provider.pause === "function") {
14980              provider.pause();
14981            }
14982          }
14983        });
14984      });
14985    }
14986
14987    if (document.readyState === "loading") {
14988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
14989    } else {
14990      initLazyPlayers();
14991    }
14992  })();
14993</script>
14993<script>
14994  (() => {
14995    if (window.__courseCompletionsInitialized) return;
14996    window.__courseCompletionsInitialized = true;
14997
14998    const STORAGE_KEY = "course-completions";
14999
15000    function slugify(text) {
15001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15002    }
15003
15004    function getCompletions() {
15005      try {
15006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
15007      } catch {
15008        return [];
15009      }
15010    }
15011
15012    function saveCompletions(completions) {
15013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
15014    }
15015
15016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
15017    function canonicalFromStored(storedId) {
15018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15019      if (!m) return slugify(storedId);
15020      const [, slug, p, mv, title] = m;
15021      return `${slug}-${p}-${mv}-${slugify(title)}`;
15022    }
15023
15024    function canonicalPlusOne(storedId) {
15025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15026      if (!m) return null;
15027      const [, slug, p, mv, title] = m;
15028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
15029    }
15030
15031    function isCompleted(canonicalId, completions) {
15032      if (completions.includes(canonicalId)) return true;
15033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
15034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
15035    }
15036
15037    function migrateCompletions() {
15038      const completions = getCompletions();
15039      let changed = false;
15040      const migrated = completions.map((c) => {
15041        const canon = canonicalFromStored(c);
15042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
15043        const plusOne = canonicalPlusOne(c);
15044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
15045          if (plusOne !== c) changed = true;
15046          return plusOne;
15047        }
15048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
15049          changed = true;
15050          return canon;
15051        }
15052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
15053        if (canon !== c) {
15054          changed = true;
15055          return canon;
15056        }
15057        return c;
15058      });
15059      // Elimina duplicados tras migración
15060      const deduped = [...new Set(migrated)];
15061      if (changed || deduped.length !== completions.length) {
15062        saveCompletions(deduped);
15063        return deduped;
15064      }
15065      return completions;
15066    }
15067
15068    function updateButtonState(btn, completions) {
15069      const id = btn.dataset.completionId;
15070      const list = completions ?? getCompletions();
15071      btn.classList.toggle("completed", isCompleted(id, list));
15072    }
15073
15074    function checkModuleCompletion(completions) {
15075      const list = completions ?? getCompletions();
15076      document.querySelectorAll(".course-video").forEach((module) => {
15077        const btns = module.querySelectorAll(".complete-btn");
15078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
15079        const check = module.querySelector(".module-check");
15080        if (check) check.classList.toggle("visible", allCompleted);
15081      });
15082    }
15083
15084    function initCompletionButtons() {
15085      const completions = migrateCompletions();
15086
15087      document.querySelectorAll(".complete-btn").forEach((btn) => {
15088        if (btn.dataset.completionInit) return;
15089        btn.dataset.completionInit = "true";
15090        updateButtonState(btn, completions);
15091
15092        btn.addEventListener("click", (e) => {
15093          e.preventDefault();
15094          e.stopPropagation();
15095
15096          const id = btn.dataset.completionId;
15097          let list = getCompletions();
15098          const completed = isCompleted(id, list);
15099
15100          if (completed) {
15101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
15102            list = list.filter((c) =>
15102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
15103          } else {
15104            list.push(id);
15105          }
15106
15107          saveCompletions(list);
15108          updateButtonState(btn, list);
15109          checkModuleCompletion(list);
15110        });
15111      });
15112
15113      checkModuleCompletion(completions);
15114    }
15115
15116    if (document.readyState === "loading") {
15117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
15118    } else {
15119      initCompletionButtons();
15120    }
15121  })();
15122</script>
15122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="9300" data-end="11280" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Bases de formularios</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>33 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-bases-de-formularios" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
15122<script>
15123  (() => {
15124    if (window.__courseLazyPlayersInitialized) return;
15125    window.__courseLazyPlayersInitialized = true;
15126
15127    function createPlayer(details) {
15128      const videoId = details.dataset.videoId;
15129      const start = details.dataset.start;
15130      const end = details.dataset.end;
15131
15132      const container = document.createElement("div");
15133      container.className = "player-container";
15134
15135      const spinner = document.createElement("div");
15136      spinner.className = "spinner";
15137      container.appendChild(spinner);
15138
15139      const controller = document.createElement("media-controller");
15140      controller.setAttribute("defaultsubtitles", "");
15141
15142      const provider = document.createElement("youtube-media-provider");
15143      provider.setAttribute("slot", "media");
15144      provider.setAttribute("video-id", videoId);
15145      provider.setAttribute("start", start);
15146      provider.setAttribute("end", end);
15147
15148      const controlBar = document.createElement("media-control-bar");
15149
15150      const playBtn = document.createElement("media-play-button");
15151      const timeDisplay = document.createElement("media-time-display");
15152      timeDisplay.setAttribute("showduration", "");
15153      const timeRange = document.createElement("media-time-range");
15154      const playbackRate = document.createElement("media-playback-rate-button");
15155      const muteBtn = document.createElement("media-mute-button");
15156      const volumeRange = document.createElement("media-volume-range");
15157
15158      const captionsBtn = document.createElement("media-captions-button");
15159      captionsBtn.addEventListener("click", (e) => {
15160        e.stopPropagation();
15161        const provider = container.querySelector("youtube-media-provider");
15162        if (provider) provider.toggleCaptions();
15163      });
15164
15165      const qualityBtn = document.createElement("media-chrome-button");
15166      qualityBtn.textContent = "--";
15167      qualityBtn.addEventListener("click", () => {
15168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
15169      });
15170
15171      const fullscreenBtn = document.createElement("media-fullscreen-button");
15172
15173      controlBar.append(
15174        playBtn, timeDisplay, timeRange,
15175        playbackRate, muteBtn, volumeRange,
15176        captionsBtn, qualityBtn, fullscreenBtn
15177      );
15178
15179      const poster = document.createElement("media-poster-image");
15180      poster.setAttribute("slot", "poster");
15181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
15182
15183      controller.append(provider, poster, controlBar);
15184
15185      captionsBtn.setAttribute("aria-checked", "true");
15186      controller.setAttribute("mediasubtitlesshowing", "on");
15187
15188      container.appendChild(controller);
15189      details.appendChild(container);
15190
15191      provider.addEventListener("waiting", () => container.classList.add("loading"));
15192      provider.addEventListener("playing", () => container.classList.remove("loading"));
15193      provider.addEventListener("play", () => container.classList.remove("loading"));
15194
15195      provider._textTracks.addEventListener("change", () => {
15196        const enabled = provider._captionsEnabled;
15197        captionsBtn.setAttribute("aria-checked", enabled);
15198        if (enabled) {
15199          controller.setAttribute("mediasubtitlesshowing", "on");
15200        } else {
15201          controller.removeAttribute("mediasubtitlesshowing");
15202        }
15203      });
15204
15205      provider.addEventListener("qualitychange", (e) => {
15206        qualityBtn.textContent = e.detail.quality;
15207      });
15208    }
15209
15210    function initLazyPlayers() {
15211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
15212        if (details.dataset.lazyInit) return;
15213        details.dataset.lazyInit = "true";
15214        details.addEventListener("toggle", () => {
15215          if (details.open && !details.querySelector(".player-container")) {
15216            createPlayer(details);
15217          } else if (!details.open) {
15218            const provider = details.querySelector("youtube-media-provider");
15219            if (provider && typeof provider.pause === "function") {
15220              provider.pause();
15221            }
15222          }
15223        });
15224      });
15225    }
15226
15227    if (document.readyState === "loading") {
15228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
15229    } else {
15230      initLazyPlayers();
15231    }
15232  })();
15233</script>
15233<script>
15234  (() => {
15235    if (window.__courseCompletionsInitialized) return;
15236    window.__courseCompletionsInitialized = true;
15237
15238    const STORAGE_KEY = "course-completions";
15239
15240    function slugify(text) {
15241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15242    }
15243
15244    function getCompletions() {
15245      try {
15246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
15247      } catch {
15248        return [];
15249      }
15250    }
15251
15252    function saveCompletions(completions) {
15253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
15254    }
15255
15256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
15257    function canonicalFromStored(storedId) {
15258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15259      if (!m) return slugify(storedId);
15260      const [, slug, p, mv, title] = m;
15261      return `${slug}-${p}-${mv}-${slugify(title)}`;
15262    }
15263
15264    function canonicalPlusOne(storedId) {
15265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15266      if (!m) return null;
15267      const [, slug, p, mv, title] = m;
15268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
15269    }
15270
15271    function isCompleted(canonicalId, completions) {
15272      if (completions.includes(canonicalId)) return true;
15273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
15274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
15275    }
15276
15277    function migrateCompletions() {
15278      const completions = getCompletions();
15279      let changed = false;
15280      const migrated = completions.map((c) => {
15281        const canon = canonicalFromStored(c);
15282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
15283        const plusOne = canonicalPlusOne(c);
15284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
15285          if (plusOne !== c) changed = true;
15286          return plusOne;
15287        }
15288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
15289          changed = true;
15290          return canon;
15291        }
15292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
15293        if (canon !== c) {
15294          changed = true;
15295          return canon;
15296        }
15297        return c;
15298      });
15299      // Elimina duplicados tras migración
15300      const deduped = [...new Set(migrated)];
15301      if (changed || deduped.length !== completions.length) {
15302        saveCompletions(deduped);
15303        return deduped;
15304      }
15305      return completions;
15306    }
15307
15308    function updateButtonState(btn, completions) {
15309      const id = btn.dataset.completionId;
15310      const list = completions ?? getCompletions();
15311      btn.classList.toggle("completed", isCompleted(id, list));
15312    }
15313
15314    function checkModuleCompletion(completions) {
15315      const list = completions ?? getCompletions();
15316      document.querySelectorAll(".course-video").forEach((module) => {
15317        const btns = module.querySelectorAll(".complete-btn");
15318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
15319        const check = module.querySelector(".module-check");
15320        if (check) check.classList.toggle("visible", allCompleted);
15321      });
15322    }
15323
15324    function initCompletionButtons() {
15325      const completions = migrateCompletions();
15326
15327      document.querySelectorAll(".complete-btn").forEach((btn) => {
15328        if (btn.dataset.completionInit) return;
15329        btn.dataset.completionInit = "true";
15330        updateButtonState(btn, completions);
15331
15332        btn.addEventListener("click", (e) => {
15333          e.preventDefault();
15334          e.stopPropagation();
15335
15336          const id = btn.dataset.completionId;
15337          let list = getCompletions();
15338          const completed = isCompleted(id, list);
15339
15340          if (completed) {
15341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
15342            list = list.filter((c) =>
15342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
15343          } else {
15344            list.push(id);
15345          }
15346
15347          saveCompletions(list);
15348          updateButtonState(btn, list);
15349          checkModuleCompletion(list);
15350        });
15351      });
15352
15353      checkModuleCompletion(completions);
15354    }
15355
15356    if (document.readyState === "loading") {
15357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
15358    } else {
15359      initCompletionButtons();
15360    }
15361  })();
15362</script>
15362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="11280" data-end="11703" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. ¿Qué es la semántica?</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-que-es-la-semantica" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
15362<script>
15363  (() => {
15364    if (window.__courseLazyPlayersInitialized) return;
15365    window.__courseLazyPlayersInitialized = true;
15366
15367    function createPlayer(details) {
15368      const videoId = details.dataset.videoId;
15369      const start = details.dataset.start;
15370      const end = details.dataset.end;
15371
15372      const container = document.createElement("div");
15373      container.className = "player-container";
15374
15375      const spinner = document.createElement("div");
15376      spinner.className = "spinner";
15377      container.appendChild(spinner);
15378
15379      const controller = document.createElement("media-controller");
15380      controller.setAttribute("defaultsubtitles", "");
15381
15382      const provider = document.createElement("youtube-media-provider");
15383      provider.setAttribute("slot", "media");
15384      provider.setAttribute("video-id", videoId);
15385      provider.setAttribute("start", start);
15386      provider.setAttribute("end", end);
15387
15388      const controlBar = document.createElement("media-control-bar");
15389
15390      const playBtn = document.createElement("media-play-button");
15391      const timeDisplay = document.createElement("media-time-display");
15392      timeDisplay.setAttribute("showduration", "");
15393      const timeRange = document.createElement("media-time-range");
15394      const playbackRate = document.createElement("media-playback-rate-button");
15395      const muteBtn = document.createElement("media-mute-button");
15396      const volumeRange = document.createElement("media-volume-range");
15397
15398      const captionsBtn = document.createElement("media-captions-button");
15399      captionsBtn.addEventListener("click", (e) => {
15400        e.stopPropagation();
15401        const provider = container.querySelector("youtube-media-provider");
15402        if (provider) provider.toggleCaptions();
15403      });
15404
15405      const qualityBtn = document.createElement("media-chrome-button");
15406      qualityBtn.textContent = "--";
15407      qualityBtn.addEventListener("click", () => {
15408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
15409      });
15410
15411      const fullscreenBtn = document.createElement("media-fullscreen-button");
15412
15413      controlBar.append(
15414        playBtn, timeDisplay, timeRange,
15415        playbackRate, muteBtn, volumeRange,
15416        captionsBtn, qualityBtn, fullscreenBtn
15417      );
15418
15419      const poster = document.createElement("media-poster-image");
15420      poster.setAttribute("slot", "poster");
15421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
15422
15423      controller.append(provider, poster, controlBar);
15424
15425      captionsBtn.setAttribute("aria-checked", "true");
15426      controller.setAttribute("mediasubtitlesshowing", "on");
15427
15428      container.appendChild(controller);
15429      details.appendChild(container);
15430
15431      provider.addEventListener("waiting", () => container.classList.add("loading"));
15432      provider.addEventListener("playing", () => container.classList.remove("loading"));
15433      provider.addEventListener("play", () => container.classList.remove("loading"));
15434
15435      provider._textTracks.addEventListener("change", () => {
15436        const enabled = provider._captionsEnabled;
15437        captionsBtn.setAttribute("aria-checked", enabled);
15438        if (enabled) {
15439          controller.setAttribute("mediasubtitlesshowing", "on");
15440        } else {
15441          controller.removeAttribute("mediasubtitlesshowing");
15442        }
15443      });
15444
15445      provider.addEventListener("qualitychange", (e) => {
15446        qualityBtn.textContent = e.detail.quality;
15447      });
15448    }
15449
15450    function initLazyPlayers() {
15451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
15452        if (details.dataset.lazyInit) return;
15453        details.dataset.lazyInit = "true";
15454        details.addEventListener("toggle", () => {
15455          if (details.open && !details.querySelector(".player-container")) {
15456            createPlayer(details);
15457          } else if (!details.open) {
15458            const provider = details.querySelector("youtube-media-provider");
15459            if (provider && typeof provider.pause === "function") {
15460              provider.pause();
15461            }
15462          }
15463        });
15464      });
15465    }
15466
15467    if (document.readyState === "loading") {
15468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
15469    } else {
15470      initLazyPlayers();
15471    }
15472  })();
15473</script>
15473<script>
15474  (() => {
15475    if (window.__courseCompletionsInitialized) return;
15476    window.__courseCompletionsInitialized = true;
15477
15478    const STORAGE_KEY = "course-completions";
15479
15480    function slugify(text) {
15481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15482    }
15483
15484    function getCompletions() {
15485      try {
15486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
15487      } catch {
15488        return [];
15489      }
15490    }
15491
15492    function saveCompletions(completions) {
15493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
15494    }
15495
15496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
15497    function canonicalFromStored(storedId) {
15498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15499      if (!m) return slugify(storedId);
15500      const [, slug, p, mv, title] = m;
15501      return `${slug}-${p}-${mv}-${slugify(title)}`;
15502    }
15503
15504    function canonicalPlusOne(storedId) {
15505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15506      if (!m) return null;
15507      const [, slug, p, mv, title] = m;
15508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
15509    }
15510
15511    function isCompleted(canonicalId, completions) {
15512      if (completions.includes(canonicalId)) return true;
15513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
15514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
15515    }
15516
15517    function migrateCompletions() {
15518      const completions = getCompletions();
15519      let changed = false;
15520      const migrated = completions.map((c) => {
15521        const canon = canonicalFromStored(c);
15522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
15523        const plusOne = canonicalPlusOne(c);
15524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
15525          if (plusOne !== c) changed = true;
15526          return plusOne;
15527        }
15528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
15529          changed = true;
15530          return canon;
15531        }
15532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
15533        if (canon !== c) {
15534          changed = true;
15535          return canon;
15536        }
15537        return c;
15538      });
15539      // Elimina duplicados tras migración
15540      const deduped = [...new Set(migrated)];
15541      if (changed || deduped.length !== completions.length) {
15542        saveCompletions(deduped);
15543        return deduped;
15544      }
15545      return completions;
15546    }
15547
15548    function updateButtonState(btn, completions) {
15549      const id = btn.dataset.completionId;
15550      const list = completions ?? getCompletions();
15551      btn.classList.toggle("completed", isCompleted(id, list));
15552    }
15553
15554    function checkModuleCompletion(completions) {
15555      const list = completions ?? getCompletions();
15556      document.querySelectorAll(".course-video").forEach((module) => {
15557        const btns = module.querySelectorAll(".complete-btn");
15558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
15559        const check = module.querySelector(".module-check");
15560        if (check) check.classList.toggle("visible", allCompleted);
15561      });
15562    }
15563
15564    function initCompletionButtons() {
15565      const completions = migrateCompletions();
15566
15567      document.querySelectorAll(".complete-btn").forEach((btn) => {
15568        if (btn.dataset.completionInit) return;
15569        btn.dataset.completionInit = "true";
15570        updateButtonState(btn, completions);
15571
15572        btn.addEventListener("click", (e) => {
15573          e.preventDefault();
15574          e.stopPropagation();
15575
15576          const id = btn.dataset.completionId;
15577          let list = getCompletions();
15578          const completed = isCompleted(id, list);
15579
15580          if (completed) {
15581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
15582            list = list.filter((c) =>
15582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
15583          } else {
15584            list.push(id);
15585          }
15586
15587          saveCompletions(list);
15588          updateButtonState(btn, list);
15589          checkModuleCompletion(list);
15590        });
15591      });
15592
15593      checkModuleCompletion(completions);
15594    }
15595
15596    if (document.readyState === "loading") {
15597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
15598    } else {
15599      initCompletionButtons();
15600    }
15601  })();
15602</script>
15602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="11703" data-end="12540" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Campos avanzados</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-campos-avanzados" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
15602<script>
15603  (() => {
15604    if (window.__courseLazyPlayersInitialized) return;
15605    window.__courseLazyPlayersInitialized = true;
15606
15607    function createPlayer(details) {
15608      const videoId = details.dataset.videoId;
15609      const start = details.dataset.start;
15610      const end = details.dataset.end;
15611
15612      const container = document.createElement("div");
15613      container.className = "player-container";
15614
15615      const spinner = document.createElement("div");
15616      spinner.className = "spinner";
15617      container.appendChild(spinner);
15618
15619      const controller = document.createElement("media-controller");
15620      controller.setAttribute("defaultsubtitles", "");
15621
15622      const provider = document.createElement("youtube-media-provider");
15623      provider.setAttribute("slot", "media");
15624      provider.setAttribute("video-id", videoId);
15625      provider.setAttribute("start", start);
15626      provider.setAttribute("end", end);
15627
15628      const controlBar = document.createElement("media-control-bar");
15629
15630      const playBtn = document.createElement("media-play-button");
15631      const timeDisplay = document.createElement("media-time-display");
15632      timeDisplay.setAttribute("showduration", "");
15633      const timeRange = document.createElement("media-time-range");
15634      const playbackRate = document.createElement("media-playback-rate-button");
15635      const muteBtn = document.createElement("media-mute-button");
15636      const volumeRange = document.createElement("media-volume-range");
15637
15638      const captionsBtn = document.createElement("media-captions-button");
15639      captionsBtn.addEventListener("click", (e) => {
15640        e.stopPropagation();
15641        const provider = container.querySelector("youtube-media-provider");
15642        if (provider) provider.toggleCaptions();
15643      });
15644
15645      const qualityBtn = document.createElement("media-chrome-button");
15646      qualityBtn.textContent = "--";
15647      qualityBtn.addEventListener("click", () => {
15648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
15649      });
15650
15651      const fullscreenBtn = document.createElement("media-fullscreen-button");
15652
15653      controlBar.append(
15654        playBtn, timeDisplay, timeRange,
15655        playbackRate, muteBtn, volumeRange,
15656        captionsBtn, qualityBtn, fullscreenBtn
15657      );
15658
15659      const poster = document.createElement("media-poster-image");
15660      poster.setAttribute("slot", "poster");
15661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
15662
15663      controller.append(provider, poster, controlBar);
15664
15665      captionsBtn.setAttribute("aria-checked", "true");
15666      controller.setAttribute("mediasubtitlesshowing", "on");
15667
15668      container.appendChild(controller);
15669      details.appendChild(container);
15670
15671      provider.addEventListener("waiting", () => container.classList.add("loading"));
15672      provider.addEventListener("playing", () => container.classList.remove("loading"));
15673      provider.addEventListener("play", () => container.classList.remove("loading"));
15674
15675      provider._textTracks.addEventListener("change", () => {
15676        const enabled = provider._captionsEnabled;
15677        captionsBtn.setAttribute("aria-checked", enabled);
15678        if (enabled) {
15679          controller.setAttribute("mediasubtitlesshowing", "on");
15680        } else {
15681          controller.removeAttribute("mediasubtitlesshowing");
15682        }
15683      });
15684
15685      provider.addEventListener("qualitychange", (e) => {
15686        qualityBtn.textContent = e.detail.quality;
15687      });
15688    }
15689
15690    function initLazyPlayers() {
15691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
15692        if (details.dataset.lazyInit) return;
15693        details.dataset.lazyInit = "true";
15694        details.addEventListener("toggle", () => {
15695          if (details.open && !details.querySelector(".player-container")) {
15696            createPlayer(details);
15697          } else if (!details.open) {
15698            const provider = details.querySelector("youtube-media-provider");
15699            if (provider && typeof provider.pause === "function") {
15700              provider.pause();
15701            }
15702          }
15703        });
15704      });
15705    }
15706
15707    if (document.readyState === "loading") {
15708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
15709    } else {
15710      initLazyPlayers();
15711    }
15712  })();
15713</script>
15713<script>
15714  (() => {
15715    if (window.__courseCompletionsInitialized) return;
15716    window.__courseCompletionsInitialized = true;
15717
15718    const STORAGE_KEY = "course-completions";
15719
15720    function slugify(text) {
15721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15722    }
15723
15724    function getCompletions() {
15725      try {
15726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
15727      } catch {
15728        return [];
15729      }
15730    }
15731
15732    function saveCompletions(completions) {
15733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
15734    }
15735
15736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
15737    function canonicalFromStored(storedId) {
15738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15739      if (!m) return slugify(storedId);
15740      const [, slug, p, mv, title] = m;
15741      return `${slug}-${p}-${mv}-${slugify(title)}`;
15742    }
15743
15744    function canonicalPlusOne(storedId) {
15745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15746      if (!m) return null;
15747      const [, slug, p, mv, title] = m;
15748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
15749    }
15750
15751    function isCompleted(canonicalId, completions) {
15752      if (completions.includes(canonicalId)) return true;
15753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
15754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
15755    }
15756
15757    function migrateCompletions() {
15758      const completions = getCompletions();
15759      let changed = false;
15760      const migrated = completions.map((c) => {
15761        const canon = canonicalFromStored(c);
15762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
15763        const plusOne = canonicalPlusOne(c);
15764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
15765          if (plusOne !== c) changed = true;
15766          return plusOne;
15767        }
15768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
15769          changed = true;
15770          return canon;
15771        }
15772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
15773        if (canon !== c) {
15774          changed = true;
15775          return canon;
15776        }
15777        return c;
15778      });
15779      // Elimina duplicados tras migración
15780      const deduped = [...new Set(migrated)];
15781      if (changed || deduped.length !== completions.length) {
15782        saveCompletions(deduped);
15783        return deduped;
15784      }
15785      return completions;
15786    }
15787
15788    function updateButtonState(btn, completions) {
15789      const id = btn.dataset.completionId;
15790      const list = completions ?? getCompletions();
15791      btn.classList.toggle("completed", isCompleted(id, list));
15792    }
15793
15794    function checkModuleCompletion(completions) {
15795      const list = completions ?? getCompletions();
15796      document.querySelectorAll(".course-video").forEach((module) => {
15797        const btns = module.querySelectorAll(".complete-btn");
15798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
15799        const check = module.querySelector(".module-check");
15800        if (check) check.classList.toggle("visible", allCompleted);
15801      });
15802    }
15803
15804    function initCompletionButtons() {
15805      const completions = migrateCompletions();
15806
15807      document.querySelectorAll(".complete-btn").forEach((btn) => {
15808        if (btn.dataset.completionInit) return;
15809        btn.dataset.completionInit = "true";
15810        updateButtonState(btn, completions);
15811
15812        btn.addEventListener("click", (e) => {
15813          e.preventDefault();
15814          e.stopPropagation();
15815
15816          const id = btn.dataset.completionId;
15817          let list = getCompletions();
15818          const completed = isCompleted(id, list);
15819
15820          if (completed) {
15821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
15822            list = list.filter((c) =>
15822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
15823          } else {
15824            list.push(id);
15825          }
15826
15827          saveCompletions(list);
15828          updateButtonState(btn, list);
15829          checkModuleCompletion(list);
15830        });
15831      });
15832
15833      checkModuleCompletion(completions);
15834    }
15835
15836    if (document.readyState === "loading") {
15837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
15838    } else {
15839      initCompletionButtons();
15840    }
15841  })();
15842</script>
15842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="12955" data-end="13970" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Semántica</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-semantica" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
15842<script>
15843  (() => {
15844    if (window.__courseLazyPlayersInitialized) return;
15845    window.__courseLazyPlayersInitialized = true;
15846
15847    function createPlayer(details) {
15848      const videoId = details.dataset.videoId;
15849      const start = details.dataset.start;
15850      const end = details.dataset.end;
15851
15852      const container = document.createElement("div");
15853      container.className = "player-container";
15854
15855      const spinner = document.createElement("div");
15856      spinner.className = "spinner";
15857      container.appendChild(spinner);
15858
15859      const controller = document.createElement("media-controller");
15860      controller.setAttribute("defaultsubtitles", "");
15861
15862      const provider = document.createElement("youtube-media-provider");
15863      provider.setAttribute("slot", "media");
15864      provider.setAttribute("video-id", videoId);
15865      provider.setAttribute("start", start);
15866      provider.setAttribute("end", end);
15867
15868      const controlBar = document.createElement("media-control-bar");
15869
15870      const playBtn = document.createElement("media-play-button");
15871      const timeDisplay = document.createElement("media-time-display");
15872      timeDisplay.setAttribute("showduration", "");
15873      const timeRange = document.createElement("media-time-range");
15874      const playbackRate = document.createElement("media-playback-rate-button");
15875      const muteBtn = document.createElement("media-mute-button");
15876      const volumeRange = document.createElement("media-volume-range");
15877
15878      const captionsBtn = document.createElement("media-captions-button");
15879      captionsBtn.addEventListener("click", (e) => {
15880        e.stopPropagation();
15881        const provider = container.querySelector("youtube-media-provider");
15882        if (provider) provider.toggleCaptions();
15883      });
15884
15885      const qualityBtn = document.createElement("media-chrome-button");
15886      qualityBtn.textContent = "--";
15887      qualityBtn.addEventListener("click", () => {
15888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
15889      });
15890
15891      const fullscreenBtn = document.createElement("media-fullscreen-button");
15892
15893      controlBar.append(
15894        playBtn, timeDisplay, timeRange,
15895        playbackRate, muteBtn, volumeRange,
15896        captionsBtn, qualityBtn, fullscreenBtn
15897      );
15898
15899      const poster = document.createElement("media-poster-image");
15900      poster.setAttribute("slot", "poster");
15901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
15902
15903      controller.append(provider, poster, controlBar);
15904
15905      captionsBtn.setAttribute("aria-checked", "true");
15906      controller.setAttribute("mediasubtitlesshowing", "on");
15907
15908      container.appendChild(controller);
15909      details.appendChild(container);
15910
15911      provider.addEventListener("waiting", () => container.classList.add("loading"));
15912      provider.addEventListener("playing", () => container.classList.remove("loading"));
15913      provider.addEventListener("play", () => container.classList.remove("loading"));
15914
15915      provider._textTracks.addEventListener("change", () => {
15916        const enabled = provider._captionsEnabled;
15917        captionsBtn.setAttribute("aria-checked", enabled);
15918        if (enabled) {
15919          controller.setAttribute("mediasubtitlesshowing", "on");
15920        } else {
15921          controller.removeAttribute("mediasubtitlesshowing");
15922        }
15923      });
15924
15925      provider.addEventListener("qualitychange", (e) => {
15926        qualityBtn.textContent = e.detail.quality;
15927      });
15928    }
15929
15930    function initLazyPlayers() {
15931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
15932        if (details.dataset.lazyInit) return;
15933        details.dataset.lazyInit = "true";
15934        details.addEventListener("toggle", () => {
15935          if (details.open && !details.querySelector(".player-container")) {
15936            createPlayer(details);
15937          } else if (!details.open) {
15938            const provider = details.querySelector("youtube-media-provider");
15939            if (provider && typeof provider.pause === "function") {
15940              provider.pause();
15941            }
15942          }
15943        });
15944      });
15945    }
15946
15947    if (document.readyState === "loading") {
15948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
15949    } else {
15950      initLazyPlayers();
15951    }
15952  })();
15953</script>
15953<script>
15954  (() => {
15955    if (window.__courseCompletionsInitialized) return;
15956    window.__courseCompletionsInitialized = true;
15957
15958    const STORAGE_KEY = "course-completions";
15959
15960    function slugify(text) {
15961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15962    }
15963
15964    function getCompletions() {
15965      try {
15966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
15967      } catch {
15968        return [];
15969      }
15970    }
15971
15972    function saveCompletions(completions) {
15973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
15974    }
15975
15976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
15977    function canonicalFromStored(storedId) {
15978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15979      if (!m) return slugify(storedId);
15980      const [, slug, p, mv, title] = m;
15981      return `${slug}-${p}-${mv}-${slugify(title)}`;
15982    }
15983
15984    function canonicalPlusOne(storedId) {
15985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
15986      if (!m) return null;
15987      const [, slug, p, mv, title] = m;
15988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
15989    }
15990
15991    function isCompleted(canonicalId, completions) {
15992      if (completions.includes(canonicalId)) return true;
15993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
15994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
15995    }
15996
15997    function migrateCompletions() {
15998      const completions = getCompletions();
15999      let changed = false;
16000      const migrated = completions.map((c) => {
16001        const canon = canonicalFromStored(c);
16002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
16003        const plusOne = canonicalPlusOne(c);
16004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
16005          if (plusOne !== c) changed = true;
16006          return plusOne;
16007        }
16008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
16009          changed = true;
16010          return canon;
16011        }
16012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
16013        if (canon !== c) {
16014          changed = true;
16015          return canon;
16016        }
16017        return c;
16018      });
16019      // Elimina duplicados tras migración
16020      const deduped = [...new Set(migrated)];
16021      if (changed || deduped.length !== completions.length) {
16022        saveCompletions(deduped);
16023        return deduped;
16024      }
16025      return completions;
16026    }
16027
16028    function updateButtonState(btn, completions) {
16029      const id = btn.dataset.completionId;
16030      const list = completions ?? getCompletions();
16031      btn.classList.toggle("completed", isCompleted(id, list));
16032    }
16033
16034    function checkModuleCompletion(completions) {
16035      const list = completions ?? getCompletions();
16036      document.querySelectorAll(".course-video").forEach((module) => {
16037        const btns = module.querySelectorAll(".complete-btn");
16038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
16039        const check = module.querySelector(".module-check");
16040        if (check) check.classList.toggle("visible", allCompleted);
16041      });
16042    }
16043
16044    function initCompletionButtons() {
16045      const completions = migrateCompletions();
16046
16047      document.querySelectorAll(".complete-btn").forEach((btn) => {
16048        if (btn.dataset.completionInit) return;
16049        btn.dataset.completionInit = "true";
16050        updateButtonState(btn, completions);
16051
16052        btn.addEventListener("click", (e) => {
16053          e.preventDefault();
16054          e.stopPropagation();
16055
16056          const id = btn.dataset.completionId;
16057          let list = getCompletions();
16058          const completed = isCompleted(id, list);
16059
16060          if (completed) {
16061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
16062            list = list.filter((c) =>
16062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
16063          } else {
16064            list.push(id);
16065          }
16066
16067          saveCompletions(list);
16068          updateButtonState(btn, list);
16069          checkModuleCompletion(list);
16070        });
16071      });
16072
16073      checkModuleCompletion(completions);
16074    }
16075
16076    if (document.readyState === "loading") {
16077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
16078    } else {
16079      initCompletionButtons();
16080    }
16081  })();
16082</script>
16082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="13970" data-end="14640" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Custom Elements</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-custom-elements" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
16082<script>
16083  (() => {
16084    if (window.__courseLazyPlayersInitialized) return;
16085    window.__courseLazyPlayersInitialized = true;
16086
16087    function createPlayer(details) {
16088      const videoId = details.dataset.videoId;
16089      const start = details.dataset.start;
16090      const end = details.dataset.end;
16091
16092      const container = document.createElement("div");
16093      container.className = "player-container";
16094
16095      const spinner = document.createElement("div");
16096      spinner.className = "spinner";
16097      container.appendChild(spinner);
16098
16099      const controller = document.createElement("media-controller");
16100      controller.setAttribute("defaultsubtitles", "");
16101
16102      const provider = document.createElement("youtube-media-provider");
16103      provider.setAttribute("slot", "media");
16104      provider.setAttribute("video-id", videoId);
16105      provider.setAttribute("start", start);
16106      provider.setAttribute("end", end);
16107
16108      const controlBar = document.createElement("media-control-bar");
16109
16110      const playBtn = document.createElement("media-play-button");
16111      const timeDisplay = document.createElement("media-time-display");
16112      timeDisplay.setAttribute("showduration", "");
16113      const timeRange = document.createElement("media-time-range");
16114      const playbackRate = document.createElement("media-playback-rate-button");
16115      const muteBtn = document.createElement("media-mute-button");
16116      const volumeRange = document.createElement("media-volume-range");
16117
16118      const captionsBtn = document.createElement("media-captions-button");
16119      captionsBtn.addEventListener("click", (e) => {
16120        e.stopPropagation();
16121        const provider = container.querySelector("youtube-media-provider");
16122        if (provider) provider.toggleCaptions();
16123      });
16124
16125      const qualityBtn = document.createElement("media-chrome-button");
16126      qualityBtn.textContent = "--";
16127      qualityBtn.addEventListener("click", () => {
16128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
16129      });
16130
16131      const fullscreenBtn = document.createElement("media-fullscreen-button");
16132
16133      controlBar.append(
16134        playBtn, timeDisplay, timeRange,
16135        playbackRate, muteBtn, volumeRange,
16136        captionsBtn, qualityBtn, fullscreenBtn
16137      );
16138
16139      const poster = document.createElement("media-poster-image");
16140      poster.setAttribute("slot", "poster");
16141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
16142
16143      controller.append(provider, poster, controlBar);
16144
16145      captionsBtn.setAttribute("aria-checked", "true");
16146      controller.setAttribute("mediasubtitlesshowing", "on");
16147
16148      container.appendChild(controller);
16149      details.appendChild(container);
16150
16151      provider.addEventListener("waiting", () => container.classList.add("loading"));
16152      provider.addEventListener("playing", () => container.classList.remove("loading"));
16153      provider.addEventListener("play", () => container.classList.remove("loading"));
16154
16155      provider._textTracks.addEventListener("change", () => {
16156        const enabled = provider._captionsEnabled;
16157        captionsBtn.setAttribute("aria-checked", enabled);
16158        if (enabled) {
16159          controller.setAttribute("mediasubtitlesshowing", "on");
16160        } else {
16161          controller.removeAttribute("mediasubtitlesshowing");
16162        }
16163      });
16164
16165      provider.addEventListener("qualitychange", (e) => {
16166        qualityBtn.textContent = e.detail.quality;
16167      });
16168    }
16169
16170    function initLazyPlayers() {
16171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
16172        if (details.dataset.lazyInit) return;
16173        details.dataset.lazyInit = "true";
16174        details.addEventListener("toggle", () => {
16175          if (details.open && !details.querySelector(".player-container")) {
16176            createPlayer(details);
16177          } else if (!details.open) {
16178            const provider = details.querySelector("youtube-media-provider");
16179            if (provider && typeof provider.pause === "function") {
16180              provider.pause();
16181            }
16182          }
16183        });
16184      });
16185    }
16186
16187    if (document.readyState === "loading") {
16188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
16189    } else {
16190      initLazyPlayers();
16191    }
16192  })();
16193</script>
16193<script>
16194  (() => {
16195    if (window.__courseCompletionsInitialized) return;
16196    window.__courseCompletionsInitialized = true;
16197
16198    const STORAGE_KEY = "course-completions";
16199
16200    function slugify(text) {
16201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16202    }
16203
16204    function getCompletions() {
16205      try {
16206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
16207      } catch {
16208        return [];
16209      }
16210    }
16211
16212    function saveCompletions(completions) {
16213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
16214    }
16215
16216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
16217    function canonicalFromStored(storedId) {
16218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16219      if (!m) return slugify(storedId);
16220      const [, slug, p, mv, title] = m;
16221      return `${slug}-${p}-${mv}-${slugify(title)}`;
16222    }
16223
16224    function canonicalPlusOne(storedId) {
16225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16226      if (!m) return null;
16227      const [, slug, p, mv, title] = m;
16228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
16229    }
16230
16231    function isCompleted(canonicalId, completions) {
16232      if (completions.includes(canonicalId)) return true;
16233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
16234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
16235    }
16236
16237    function migrateCompletions() {
16238      const completions = getCompletions();
16239      let changed = false;
16240      const migrated = completions.map((c) => {
16241        const canon = canonicalFromStored(c);
16242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
16243        const plusOne = canonicalPlusOne(c);
16244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
16245          if (plusOne !== c) changed = true;
16246          return plusOne;
16247        }
16248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
16249          changed = true;
16250          return canon;
16251        }
16252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
16253        if (canon !== c) {
16254          changed = true;
16255          return canon;
16256        }
16257        return c;
16258      });
16259      // Elimina duplicados tras migración
16260      const deduped = [...new Set(migrated)];
16261      if (changed || deduped.length !== completions.length) {
16262        saveCompletions(deduped);
16263        return deduped;
16264      }
16265      return completions;
16266    }
16267
16268    function updateButtonState(btn, completions) {
16269      const id = btn.dataset.completionId;
16270      const list = completions ?? getCompletions();
16271      btn.classList.toggle("completed", isCompleted(id, list));
16272    }
16273
16274    function checkModuleCompletion(completions) {
16275      const list = completions ?? getCompletions();
16276      document.querySelectorAll(".course-video").forEach((module) => {
16277        const btns = module.querySelectorAll(".complete-btn");
16278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
16279        const check = module.querySelector(".module-check");
16280        if (check) check.classList.toggle("visible", allCompleted);
16281      });
16282    }
16283
16284    function initCompletionButtons() {
16285      const completions = migrateCompletions();
16286
16287      document.querySelectorAll(".complete-btn").forEach((btn) => {
16288        if (btn.dataset.completionInit) return;
16289        btn.dataset.completionInit = "true";
16290        updateButtonState(btn, completions);
16291
16292        btn.addEventListener("click", (e) => {
16293          e.preventDefault();
16294          e.stopPropagation();
16295
16296          const id = btn.dataset.completionId;
16297          let list = getCompletions();
16298          const completed = isCompleted(id, list);
16299
16300          if (completed) {
16301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
16302            list = list.filter((c) =>
16302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
16303          } else {
16304            list.push(id);
16305          }
16306
16307          saveCompletions(list);
16308          updateButtonState(btn, list);
16309          checkModuleCompletion(list);
16310        });
16311      });
16312
16313      checkModuleCompletion(completions);
16314    }
16315
16316    if (document.readyState === "loading") {
16317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
16318    } else {
16319      initCompletionButtons();
16320    }
16321  })();
16322</script>
16322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="14640" data-end="14935" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Markdown</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>5 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-markdown" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
16322<script>
16323  (() => {
16324    if (window.__courseLazyPlayersInitialized) return;
16325    window.__courseLazyPlayersInitialized = true;
16326
16327    function createPlayer(details) {
16328      const videoId = details.dataset.videoId;
16329      const start = details.dataset.start;
16330      const end = details.dataset.end;
16331
16332      const container = document.createElement("div");
16333      container.className = "player-container";
16334
16335      const spinner = document.createElement("div");
16336      spinner.className = "spinner";
16337      container.appendChild(spinner);
16338
16339      const controller = document.createElement("media-controller");
16340      controller.setAttribute("defaultsubtitles", "");
16341
16342      const provider = document.createElement("youtube-media-provider");
16343      provider.setAttribute("slot", "media");
16344      provider.setAttribute("video-id", videoId);
16345      provider.setAttribute("start", start);
16346      provider.setAttribute("end", end);
16347
16348      const controlBar = document.createElement("media-control-bar");
16349
16350      const playBtn = document.createElement("media-play-button");
16351      const timeDisplay = document.createElement("media-time-display");
16352      timeDisplay.setAttribute("showduration", "");
16353      const timeRange = document.createElement("media-time-range");
16354      const playbackRate = document.createElement("media-playback-rate-button");
16355      const muteBtn = document.createElement("media-mute-button");
16356      const volumeRange = document.createElement("media-volume-range");
16357
16358      const captionsBtn = document.createElement("media-captions-button");
16359      captionsBtn.addEventListener("click", (e) => {
16360        e.stopPropagation();
16361        const provider = container.querySelector("youtube-media-provider");
16362        if (provider) provider.toggleCaptions();
16363      });
16364
16365      const qualityBtn = document.createElement("media-chrome-button");
16366      qualityBtn.textContent = "--";
16367      qualityBtn.addEventListener("click", () => {
16368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
16369      });
16370
16371      const fullscreenBtn = document.createElement("media-fullscreen-button");
16372
16373      controlBar.append(
16374        playBtn, timeDisplay, timeRange,
16375        playbackRate, muteBtn, volumeRange,
16376        captionsBtn, qualityBtn, fullscreenBtn
16377      );
16378
16379      const poster = document.createElement("media-poster-image");
16380      poster.setAttribute("slot", "poster");
16381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
16382
16383      controller.append(provider, poster, controlBar);
16384
16385      captionsBtn.setAttribute("aria-checked", "true");
16386      controller.setAttribute("mediasubtitlesshowing", "on");
16387
16388      container.appendChild(controller);
16389      details.appendChild(container);
16390
16391      provider.addEventListener("waiting", () => container.classList.add("loading"));
16392      provider.addEventListener("playing", () => container.classList.remove("loading"));
16393      provider.addEventListener("play", () => container.classList.remove("loading"));
16394
16395      provider._textTracks.addEventListener("change", () => {
16396        const enabled = provider._captionsEnabled;
16397        captionsBtn.setAttribute("aria-checked", enabled);
16398        if (enabled) {
16399          controller.setAttribute("mediasubtitlesshowing", "on");
16400        } else {
16401          controller.removeAttribute("mediasubtitlesshowing");
16402        }
16403      });
16404
16405      provider.addEventListener("qualitychange", (e) => {
16406        qualityBtn.textContent = e.detail.quality;
16407      });
16408    }
16409
16410    function initLazyPlayers() {
16411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
16412        if (details.dataset.lazyInit) return;
16413        details.dataset.lazyInit = "true";
16414        details.addEventListener("toggle", () => {
16415          if (details.open && !details.querySelector(".player-container")) {
16416            createPlayer(details);
16417          } else if (!details.open) {
16418            const provider = details.querySelector("youtube-media-provider");
16419            if (provider && typeof provider.pause === "function") {
16420              provider.pause();
16421            }
16422          }
16423        });
16424      });
16425    }
16426
16427    if (document.readyState === "loading") {
16428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
16429    } else {
16430      initLazyPlayers();
16431    }
16432  })();
16433</script>
16433<script>
16434  (() => {
16435    if (window.__courseCompletionsInitialized) return;
16436    window.__courseCompletionsInitialized = true;
16437
16438    const STORAGE_KEY = "course-completions";
16439
16440    function slugify(text) {
16441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16442    }
16443
16444    function getCompletions() {
16445      try {
16446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
16447      } catch {
16448        return [];
16449      }
16450    }
16451
16452    function saveCompletions(completions) {
16453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
16454    }
16455
16456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
16457    function canonicalFromStored(storedId) {
16458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16459      if (!m) return slugify(storedId);
16460      const [, slug, p, mv, title] = m;
16461      return `${slug}-${p}-${mv}-${slugify(title)}`;
16462    }
16463
16464    function canonicalPlusOne(storedId) {
16465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16466      if (!m) return null;
16467      const [, slug, p, mv, title] = m;
16468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
16469    }
16470
16471    function isCompleted(canonicalId, completions) {
16472      if (completions.includes(canonicalId)) return true;
16473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
16474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
16475    }
16476
16477    function migrateCompletions() {
16478      const completions = getCompletions();
16479      let changed = false;
16480      const migrated = completions.map((c) => {
16481        const canon = canonicalFromStored(c);
16482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
16483        const plusOne = canonicalPlusOne(c);
16484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
16485          if (plusOne !== c) changed = true;
16486          return plusOne;
16487        }
16488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
16489          changed = true;
16490          return canon;
16491        }
16492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
16493        if (canon !== c) {
16494          changed = true;
16495          return canon;
16496        }
16497        return c;
16498      });
16499      // Elimina duplicados tras migración
16500      const deduped = [...new Set(migrated)];
16501      if (changed || deduped.length !== completions.length) {
16502        saveCompletions(deduped);
16503        return deduped;
16504      }
16505      return completions;
16506    }
16507
16508    function updateButtonState(btn, completions) {
16509      const id = btn.dataset.completionId;
16510      const list = completions ?? getCompletions();
16511      btn.classList.toggle("completed", isCompleted(id, list));
16512    }
16513
16514    function checkModuleCompletion(completions) {
16515      const list = completions ?? getCompletions();
16516      document.querySelectorAll(".course-video").forEach((module) => {
16517        const btns = module.querySelectorAll(".complete-btn");
16518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
16519        const check = module.querySelector(".module-check");
16520        if (check) check.classList.toggle("visible", allCompleted);
16521      });
16522    }
16523
16524    function initCompletionButtons() {
16525      const completions = migrateCompletions();
16526
16527      document.querySelectorAll(".complete-btn").forEach((btn) => {
16528        if (btn.dataset.completionInit) return;
16529        btn.dataset.completionInit = "true";
16530        updateButtonState(btn, completions);
16531
16532        btn.addEventListener("click", (e) => {
16533          e.preventDefault();
16534          e.stopPropagation();
16535
16536          const id = btn.dataset.completionId;
16537          let list = getCompletions();
16538          const completed = isCompleted(id, list);
16539
16540          if (completed) {
16541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
16542            list = list.filter((c) =>
16542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
16543          } else {
16544            list.push(id);
16545          }
16546
16547          saveCompletions(list);
16548          updateButtonState(btn, list);
16549          checkModuleCompletion(list);
16550        });
16551      });
16552
16553      checkModuleCompletion(completions);
16554    }
16555
16556    if (document.readyState === "loading") {
16557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
16558    } else {
16559      initCompletionButtons();
16560    }
16561  })();
16562</script>
16562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="14935" data-end="16420" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>14. HTML-first y mejora progresiva</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>25 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-html-first-y-mejora-progresiva" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
16562<script>
16563  (() => {
16564    if (window.__courseLazyPlayersInitialized) return;
16565    window.__courseLazyPlayersInitialized = true;
16566
16567    function createPlayer(details) {
16568      const videoId = details.dataset.videoId;
16569      const start = details.dataset.start;
16570      const end = details.dataset.end;
16571
16572      const container = document.createElement("div");
16573      container.className = "player-container";
16574
16575      const spinner = document.createElement("div");
16576      spinner.className = "spinner";
16577      container.appendChild(spinner);
16578
16579      const controller = document.createElement("media-controller");
16580      controller.setAttribute("defaultsubtitles", "");
16581
16582      const provider = document.createElement("youtube-media-provider");
16583      provider.setAttribute("slot", "media");
16584      provider.setAttribute("video-id", videoId);
16585      provider.setAttribute("start", start);
16586      provider.setAttribute("end", end);
16587
16588      const controlBar = document.createElement("media-control-bar");
16589
16590      const playBtn = document.createElement("media-play-button");
16591      const timeDisplay = document.createElement("media-time-display");
16592      timeDisplay.setAttribute("showduration", "");
16593      const timeRange = document.createElement("media-time-range");
16594      const playbackRate = document.createElement("media-playback-rate-button");
16595      const muteBtn = document.createElement("media-mute-button");
16596      const volumeRange = document.createElement("media-volume-range");
16597
16598      const captionsBtn = document.createElement("media-captions-button");
16599      captionsBtn.addEventListener("click", (e) => {
16600        e.stopPropagation();
16601        const provider = container.querySelector("youtube-media-provider");
16602        if (provider) provider.toggleCaptions();
16603      });
16604
16605      const qualityBtn = document.createElement("media-chrome-button");
16606      qualityBtn.textContent = "--";
16607      qualityBtn.addEventListener("click", () => {
16608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
16609      });
16610
16611      const fullscreenBtn = document.createElement("media-fullscreen-button");
16612
16613      controlBar.append(
16614        playBtn, timeDisplay, timeRange,
16615        playbackRate, muteBtn, volumeRange,
16616        captionsBtn, qualityBtn, fullscreenBtn
16617      );
16618
16619      const poster = document.createElement("media-poster-image");
16620      poster.setAttribute("slot", "poster");
16621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
16622
16623      controller.append(provider, poster, controlBar);
16624
16625      captionsBtn.setAttribute("aria-checked", "true");
16626      controller.setAttribute("mediasubtitlesshowing", "on");
16627
16628      container.appendChild(controller);
16629      details.appendChild(container);
16630
16631      provider.addEventListener("waiting", () => container.classList.add("loading"));
16632      provider.addEventListener("playing", () => container.classList.remove("loading"));
16633      provider.addEventListener("play", () => container.classList.remove("loading"));
16634
16635      provider._textTracks.addEventListener("change", () => {
16636        const enabled = provider._captionsEnabled;
16637        captionsBtn.setAttribute("aria-checked", enabled);
16638        if (enabled) {
16639          controller.setAttribute("mediasubtitlesshowing", "on");
16640        } else {
16641          controller.removeAttribute("mediasubtitlesshowing");
16642        }
16643      });
16644
16645      provider.addEventListener("qualitychange", (e) => {
16646        qualityBtn.textContent = e.detail.quality;
16647      });
16648    }
16649
16650    function initLazyPlayers() {
16651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
16652        if (details.dataset.lazyInit) return;
16653        details.dataset.lazyInit = "true";
16654        details.addEventListener("toggle", () => {
16655          if (details.open && !details.querySelector(".player-container")) {
16656            createPlayer(details);
16657          } else if (!details.open) {
16658            const provider = details.querySelector("youtube-media-provider");
16659            if (provider && typeof provider.pause === "function") {
16660              provider.pause();
16661            }
16662          }
16663        });
16664      });
16665    }
16666
16667    if (document.readyState === "loading") {
16668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
16669    } else {
16670      initLazyPlayers();
16671    }
16672  })();
16673</script>
16673<script>
16674  (() => {
16675    if (window.__courseCompletionsInitialized) return;
16676    window.__courseCompletionsInitialized = true;
16677
16678    const STORAGE_KEY = "course-completions";
16679
16680    function slugify(text) {
16681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16682    }
16683
16684    function getCompletions() {
16685      try {
16686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
16687      } catch {
16688        return [];
16689      }
16690    }
16691
16692    function saveCompletions(completions) {
16693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
16694    }
16695
16696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
16697    function canonicalFromStored(storedId) {
16698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16699      if (!m) return slugify(storedId);
16700      const [, slug, p, mv, title] = m;
16701      return `${slug}-${p}-${mv}-${slugify(title)}`;
16702    }
16703
16704    function canonicalPlusOne(storedId) {
16705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16706      if (!m) return null;
16707      const [, slug, p, mv, title] = m;
16708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
16709    }
16710
16711    function isCompleted(canonicalId, completions) {
16712      if (completions.includes(canonicalId)) return true;
16713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
16714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
16715    }
16716
16717    function migrateCompletions() {
16718      const completions = getCompletions();
16719      let changed = false;
16720      const migrated = completions.map((c) => {
16721        const canon = canonicalFromStored(c);
16722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
16723        const plusOne = canonicalPlusOne(c);
16724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
16725          if (plusOne !== c) changed = true;
16726          return plusOne;
16727        }
16728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
16729          changed = true;
16730          return canon;
16731        }
16732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
16733        if (canon !== c) {
16734          changed = true;
16735          return canon;
16736        }
16737        return c;
16738      });
16739      // Elimina duplicados tras migración
16740      const deduped = [...new Set(migrated)];
16741      if (changed || deduped.length !== completions.length) {
16742        saveCompletions(deduped);
16743        return deduped;
16744      }
16745      return completions;
16746    }
16747
16748    function updateButtonState(btn, completions) {
16749      const id = btn.dataset.completionId;
16750      const list = completions ?? getCompletions();
16751      btn.classList.toggle("completed", isCompleted(id, list));
16752    }
16753
16754    function checkModuleCompletion(completions) {
16755      const list = completions ?? getCompletions();
16756      document.querySelectorAll(".course-video").forEach((module) => {
16757        const btns = module.querySelectorAll(".complete-btn");
16758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
16759        const check = module.querySelector(".module-check");
16760        if (check) check.classList.toggle("visible", allCompleted);
16761      });
16762    }
16763
16764    function initCompletionButtons() {
16765      const completions = migrateCompletions();
16766
16767      document.querySelectorAll(".complete-btn").forEach((btn) => {
16768        if (btn.dataset.completionInit) return;
16769        btn.dataset.completionInit = "true";
16770        updateButtonState(btn, completions);
16771
16772        btn.addEventListener("click", (e) => {
16773          e.preventDefault();
16774          e.stopPropagation();
16775
16776          const id = btn.dataset.completionId;
16777          let list = getCompletions();
16778          const completed = isCompleted(id, list);
16779
16780          if (completed) {
16781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
16782            list = list.filter((c) =>
16782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
16783          } else {
16784            list.push(id);
16785          }
16786
16787          saveCompletions(list);
16788          updateButtonState(btn, list);
16789          checkModuleCompletion(list);
16790        });
16791      });
16792
16793      checkModuleCompletion(completions);
16794    }
16795
16796    if (document.readyState === "loading") {
16797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
16798    } else {
16799      initCompletionButtons();
16800    }
16801  })();
16802</script>
16802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="oSAB0Iug-Zo" data-start="16420" data-end="18202" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>15. Preguntas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>30 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-1-preguntas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
16802<script>
16803  (() => {
16804    if (window.__courseLazyPlayersInitialized) return;
16805    window.__courseLazyPlayersInitialized = true;
16806
16807    function createPlayer(details) {
16808      const videoId = details.dataset.videoId;
16809      const start = details.dataset.start;
16810      const end = details.dataset.end;
16811
16812      const container = document.createElement("div");
16813      container.className = "player-container";
16814
16815      const spinner = document.createElement("div");
16816      spinner.className = "spinner";
16817      container.appendChild(spinner);
16818
16819      const controller = document.createElement("media-controller");
16820      controller.setAttribute("defaultsubtitles", "");
16821
16822      const provider = document.createElement("youtube-media-provider");
16823      provider.setAttribute("slot", "media");
16824      provider.setAttribute("video-id", videoId);
16825      provider.setAttribute("start", start);
16826      provider.setAttribute("end", end);
16827
16828      const controlBar = document.createElement("media-control-bar");
16829
16830      const playBtn = document.createElement("media-play-button");
16831      const timeDisplay = document.createElement("media-time-display");
16832      timeDisplay.setAttribute("showduration", "");
16833      const timeRange = document.createElement("media-time-range");
16834      const playbackRate = document.createElement("media-playback-rate-button");
16835      const muteBtn = document.createElement("media-mute-button");
16836      const volumeRange = document.createElement("media-volume-range");
16837
16838      const captionsBtn = document.createElement("media-captions-button");
16839      captionsBtn.addEventListener("click", (e) => {
16840        e.stopPropagation();
16841        const provider = container.querySelector("youtube-media-provider");
16842        if (provider) provider.toggleCaptions();
16843      });
16844
16845      const qualityBtn = document.createElement("media-chrome-button");
16846      qualityBtn.textContent = "--";
16847      qualityBtn.addEventListener("click", () => {
16848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
16849      });
16850
16851      const fullscreenBtn = document.createElement("media-fullscreen-button");
16852
16853      controlBar.append(
16854        playBtn, timeDisplay, timeRange,
16855        playbackRate, muteBtn, volumeRange,
16856        captionsBtn, qualityBtn, fullscreenBtn
16857      );
16858
16859      const poster = document.createElement("media-poster-image");
16860      poster.setAttribute("slot", "poster");
16861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
16862
16863      controller.append(provider, poster, controlBar);
16864
16865      captionsBtn.setAttribute("aria-checked", "true");
16866      controller.setAttribute("mediasubtitlesshowing", "on");
16867
16868      container.appendChild(controller);
16869      details.appendChild(container);
16870
16871      provider.addEventListener("waiting", () => container.classList.add("loading"));
16872      provider.addEventListener("playing", () => container.classList.remove("loading"));
16873      provider.addEventListener("play", () => container.classList.remove("loading"));
16874
16875      provider._textTracks.addEventListener("change", () => {
16876        const enabled = provider._captionsEnabled;
16877        captionsBtn.setAttribute("aria-checked", enabled);
16878        if (enabled) {
16879          controller.setAttribute("mediasubtitlesshowing", "on");
16880        } else {
16881          controller.removeAttribute("mediasubtitlesshowing");
16882        }
16883      });
16884
16885      provider.addEventListener("qualitychange", (e) => {
16886        qualityBtn.textContent = e.detail.quality;
16887      });
16888    }
16889
16890    function initLazyPlayers() {
16891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
16892        if (details.dataset.lazyInit) return;
16893        details.dataset.lazyInit = "true";
16894        details.addEventListener("toggle", () => {
16895          if (details.open && !details.querySelector(".player-container")) {
16896            createPlayer(details);
16897          } else if (!details.open) {
16898            const provider = details.querySelector("youtube-media-provider");
16899            if (provider && typeof provider.pause === "function") {
16900              provider.pause();
16901            }
16902          }
16903        });
16904      });
16905    }
16906
16907    if (document.readyState === "loading") {
16908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
16909    } else {
16910      initLazyPlayers();
16911    }
16912  })();
16913</script>
16913<script>
16914  (() => {
16915    if (window.__courseCompletionsInitialized) return;
16916    window.__courseCompletionsInitialized = true;
16917
16918    const STORAGE_KEY = "course-completions";
16919
16920    function slugify(text) {
16921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16922    }
16923
16924    function getCompletions() {
16925      try {
16926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
16927      } catch {
16928        return [];
16929      }
16930    }
16931
16932    function saveCompletions(completions) {
16933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
16934    }
16935
16936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
16937    function canonicalFromStored(storedId) {
16938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16939      if (!m) return slugify(storedId);
16940      const [, slug, p, mv, title] = m;
16941      return `${slug}-${p}-${mv}-${slugify(title)}`;
16942    }
16943
16944    function canonicalPlusOne(storedId) {
16945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
16946      if (!m) return null;
16947      const [, slug, p, mv, title] = m;
16948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
16949    }
16950
16951    function isCompleted(canonicalId, completions) {
16952      if (completions.includes(canonicalId)) return true;
16953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
16954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
16955    }
16956
16957    function migrateCompletions() {
16958      const completions = getCompletions();
16959      let changed = false;
16960      const migrated = completions.map((c) => {
16961        const canon = canonicalFromStored(c);
16962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
16963        const plusOne = canonicalPlusOne(c);
16964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
16965          if (plusOne !== c) changed = true;
16966          return plusOne;
16967        }
16968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
16969          changed = true;
16970          return canon;
16971        }
16972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
16973        if (canon !== c) {
16974          changed = true;
16975          return canon;
16976        }
16977        return c;
16978      });
16979      // Elimina duplicados tras migración
16980      const deduped = [...new Set(migrated)];
16981      if (changed || deduped.length !== completions.length) {
16982        saveCompletions(deduped);
16983        return deduped;
16984      }
16985      return completions;
16986    }
16987
16988    function updateButtonState(btn, completions) {
16989      const id = btn.dataset.completionId;
16990      const list = completions ?? getCompletions();
16991      btn.classList.toggle("completed", isCompleted(id, list));
16992    }
16993
16994    function checkModuleCompletion(completions) {
16995      const list = completions ?? getCompletions();
16996      document.querySelectorAll(".course-video").forEach((module) => {
16997        const btns = module.querySelectorAll(".complete-btn");
16998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
16999        const check = module.querySelector(".module-check");
17000        if (check) check.classList.toggle("visible", allCompleted);
17001      });
17002    }
17003
17004    function initCompletionButtons() {
17005      const completions = migrateCompletions();
17006
17007      document.querySelectorAll(".complete-btn").forEach((btn) => {
17008        if (btn.dataset.completionInit) return;
17009        btn.dataset.completionInit = "true";
17010        updateButtonState(btn, completions);
17011
17012        btn.addEventListener("click", (e) => {
17013          e.preventDefault();
17014          e.stopPropagation();
17015
17016          const id = btn.dataset.completionId;
17017          let list = getCompletions();
17018          const completed = isCompleted(id, list);
17019
17020          if (completed) {
17021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
17022            list = list.filter((c) =>
17022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
17023          } else {
17024            list.push(id);
17025          }
17026
17027          saveCompletions(list);
17028          updateButtonState(btn, list);
17029          checkModuleCompletion(list);
17030        });
17031      });
17032
17033      checkModuleCompletion(completions);
17034    }
17035
17036    if (document.readyState === "loading") {
17037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
17038    } else {
17039      initCompletionButtons();
17040    }
17041  })();
17042</script>
17042</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 6 📙 Bases de CSS<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="807" data-end="1260" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-introduccion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
17042<script>
17043  (() => {
17044    if (window.__courseLazyPlayersInitialized) return;
17045    window.__courseLazyPlayersInitialized = true;
17046
17047    function createPlayer(details) {
17048      const videoId = details.dataset.videoId;
17049      const start = details.dataset.start;
17050      const end = details.dataset.end;
17051
17052      const container = document.createElement("div");
17053      container.className = "player-container";
17054
17055      const spinner = document.createElement("div");
17056      spinner.className = "spinner";
17057      container.appendChild(spinner);
17058
17059      const controller = document.createElement("media-controller");
17060      controller.setAttribute("defaultsubtitles", "");
17061
17062      const provider = document.createElement("youtube-media-provider");
17063      provider.setAttribute("slot", "media");
17064      provider.setAttribute("video-id", videoId);
17065      provider.setAttribute("start", start);
17066      provider.setAttribute("end", end);
17067
17068      const controlBar = document.createElement("media-control-bar");
17069
17070      const playBtn = document.createElement("media-play-button");
17071      const timeDisplay = document.createElement("media-time-display");
17072      timeDisplay.setAttribute("showduration", "");
17073      const timeRange = document.createElement("media-time-range");
17074      const playbackRate = document.createElement("media-playback-rate-button");
17075      const muteBtn = document.createElement("media-mute-button");
17076      const volumeRange = document.createElement("media-volume-range");
17077
17078      const captionsBtn = document.createElement("media-captions-button");
17079      captionsBtn.addEventListener("click", (e) => {
17080        e.stopPropagation();
17081        const provider = container.querySelector("youtube-media-provider");
17082        if (provider) provider.toggleCaptions();
17083      });
17084
17085      const qualityBtn = document.createElement("media-chrome-button");
17086      qualityBtn.textContent = "--";
17087      qualityBtn.addEventListener("click", () => {
17088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
17089      });
17090
17091      const fullscreenBtn = document.createElement("media-fullscreen-button");
17092
17093      controlBar.append(
17094        playBtn, timeDisplay, timeRange,
17095        playbackRate, muteBtn, volumeRange,
17096        captionsBtn, qualityBtn, fullscreenBtn
17097      );
17098
17099      const poster = document.createElement("media-poster-image");
17100      poster.setAttribute("slot", "poster");
17101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
17102
17103      controller.append(provider, poster, controlBar);
17104
17105      captionsBtn.setAttribute("aria-checked", "true");
17106      controller.setAttribute("mediasubtitlesshowing", "on");
17107
17108      container.appendChild(controller);
17109      details.appendChild(container);
17110
17111      provider.addEventListener("waiting", () => container.classList.add("loading"));
17112      provider.addEventListener("playing", () => container.classList.remove("loading"));
17113      provider.addEventListener("play", () => container.classList.remove("loading"));
17114
17115      provider._textTracks.addEventListener("change", () => {
17116        const enabled = provider._captionsEnabled;
17117        captionsBtn.setAttribute("aria-checked", enabled);
17118        if (enabled) {
17119          controller.setAttribute("mediasubtitlesshowing", "on");
17120        } else {
17121          controller.removeAttribute("mediasubtitlesshowing");
17122        }
17123      });
17124
17125      provider.addEventListener("qualitychange", (e) => {
17126        qualityBtn.textContent = e.detail.quality;
17127      });
17128    }
17129
17130    function initLazyPlayers() {
17131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
17132        if (details.dataset.lazyInit) return;
17133        details.dataset.lazyInit = "true";
17134        details.addEventListener("toggle", () => {
17135          if (details.open && !details.querySelector(".player-container")) {
17136            createPlayer(details);
17137          } else if (!details.open) {
17138            const provider = details.querySelector("youtube-media-provider");
17139            if (provider && typeof provider.pause === "function") {
17140              provider.pause();
17141            }
17142          }
17143        });
17144      });
17145    }
17146
17147    if (document.readyState === "loading") {
17148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
17149    } else {
17150      initLazyPlayers();
17151    }
17152  })();
17153</script>
17153<script>
17154  (() => {
17155    if (window.__courseCompletionsInitialized) return;
17156    window.__courseCompletionsInitialized = true;
17157
17158    const STORAGE_KEY = "course-completions";
17159
17160    function slugify(text) {
17161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17162    }
17163
17164    function getCompletions() {
17165      try {
17166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
17167      } catch {
17168        return [];
17169      }
17170    }
17171
17172    function saveCompletions(completions) {
17173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
17174    }
17175
17176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
17177    function canonicalFromStored(storedId) {
17178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17179      if (!m) return slugify(storedId);
17180      const [, slug, p, mv, title] = m;
17181      return `${slug}-${p}-${mv}-${slugify(title)}`;
17182    }
17183
17184    function canonicalPlusOne(storedId) {
17185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17186      if (!m) return null;
17187      const [, slug, p, mv, title] = m;
17188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
17189    }
17190
17191    function isCompleted(canonicalId, completions) {
17192      if (completions.includes(canonicalId)) return true;
17193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
17194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
17195    }
17196
17197    function migrateCompletions() {
17198      const completions = getCompletions();
17199      let changed = false;
17200      const migrated = completions.map((c) => {
17201        const canon = canonicalFromStored(c);
17202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
17203        const plusOne = canonicalPlusOne(c);
17204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
17205          if (plusOne !== c) changed = true;
17206          return plusOne;
17207        }
17208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
17209          changed = true;
17210          return canon;
17211        }
17212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
17213        if (canon !== c) {
17214          changed = true;
17215          return canon;
17216        }
17217        return c;
17218      });
17219      // Elimina duplicados tras migración
17220      const deduped = [...new Set(migrated)];
17221      if (changed || deduped.length !== completions.length) {
17222        saveCompletions(deduped);
17223        return deduped;
17224      }
17225      return completions;
17226    }
17227
17228    function updateButtonState(btn, completions) {
17229      const id = btn.dataset.completionId;
17230      const list = completions ?? getCompletions();
17231      btn.classList.toggle("completed", isCompleted(id, list));
17232    }
17233
17234    function checkModuleCompletion(completions) {
17235      const list = completions ?? getCompletions();
17236      document.querySelectorAll(".course-video").forEach((module) => {
17237        const btns = module.querySelectorAll(".complete-btn");
17238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
17239        const check = module.querySelector(".module-check");
17240        if (check) check.classList.toggle("visible", allCompleted);
17241      });
17242    }
17243
17244    function initCompletionButtons() {
17245      const completions = migrateCompletions();
17246
17247      document.querySelectorAll(".complete-btn").forEach((btn) => {
17248        if (btn.dataset.completionInit) return;
17249        btn.dataset.completionInit = "true";
17250        updateButtonState(btn, completions);
17251
17252        btn.addEventListener("click", (e) => {
17253          e.preventDefault();
17254          e.stopPropagation();
17255
17256          const id = btn.dataset.completionId;
17257          let list = getCompletions();
17258          const completed = isCompleted(id, list);
17259
17260          if (completed) {
17261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
17262            list = list.filter((c) =>
17262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
17263          } else {
17264            list.push(id);
17265          }
17266
17267          saveCompletions(list);
17268          updateButtonState(btn, list);
17269          checkModuleCompletion(list);
17270        });
17271      });
17272
17273      checkModuleCompletion(completions);
17274    }
17275
17276    if (document.readyState === "loading") {
17277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
17278    } else {
17279      initCompletionButtons();
17280    }
17281  })();
17282</script>
17282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="1260" data-end="1860" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Introducción a CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>10 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-introduccion-a-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
17282<script>
17283  (() => {
17284    if (window.__courseLazyPlayersInitialized) return;
17285    window.__courseLazyPlayersInitialized = true;
17286
17287    function createPlayer(details) {
17288      const videoId = details.dataset.videoId;
17289      const start = details.dataset.start;
17290      const end = details.dataset.end;
17291
17292      const container = document.createElement("div");
17293      container.className = "player-container";
17294
17295      const spinner = document.createElement("div");
17296      spinner.className = "spinner";
17297      container.appendChild(spinner);
17298
17299      const controller = document.createElement("media-controller");
17300      controller.setAttribute("defaultsubtitles", "");
17301
17302      const provider = document.createElement("youtube-media-provider");
17303      provider.setAttribute("slot", "media");
17304      provider.setAttribute("video-id", videoId);
17305      provider.setAttribute("start", start);
17306      provider.setAttribute("end", end);
17307
17308      const controlBar = document.createElement("media-control-bar");
17309
17310      const playBtn = document.createElement("media-play-button");
17311      const timeDisplay = document.createElement("media-time-display");
17312      timeDisplay.setAttribute("showduration", "");
17313      const timeRange = document.createElement("media-time-range");
17314      const playbackRate = document.createElement("media-playback-rate-button");
17315      const muteBtn = document.createElement("media-mute-button");
17316      const volumeRange = document.createElement("media-volume-range");
17317
17318      const captionsBtn = document.createElement("media-captions-button");
17319      captionsBtn.addEventListener("click", (e) => {
17320        e.stopPropagation();
17321        const provider = container.querySelector("youtube-media-provider");
17322        if (provider) provider.toggleCaptions();
17323      });
17324
17325      const qualityBtn = document.createElement("media-chrome-button");
17326      qualityBtn.textContent = "--";
17327      qualityBtn.addEventListener("click", () => {
17328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
17329      });
17330
17331      const fullscreenBtn = document.createElement("media-fullscreen-button");
17332
17333      controlBar.append(
17334        playBtn, timeDisplay, timeRange,
17335        playbackRate, muteBtn, volumeRange,
17336        captionsBtn, qualityBtn, fullscreenBtn
17337      );
17338
17339      const poster = document.createElement("media-poster-image");
17340      poster.setAttribute("slot", "poster");
17341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
17342
17343      controller.append(provider, poster, controlBar);
17344
17345      captionsBtn.setAttribute("aria-checked", "true");
17346      controller.setAttribute("mediasubtitlesshowing", "on");
17347
17348      container.appendChild(controller);
17349      details.appendChild(container);
17350
17351      provider.addEventListener("waiting", () => container.classList.add("loading"));
17352      provider.addEventListener("playing", () => container.classList.remove("loading"));
17353      provider.addEventListener("play", () => container.classList.remove("loading"));
17354
17355      provider._textTracks.addEventListener("change", () => {
17356        const enabled = provider._captionsEnabled;
17357        captionsBtn.setAttribute("aria-checked", enabled);
17358        if (enabled) {
17359          controller.setAttribute("mediasubtitlesshowing", "on");
17360        } else {
17361          controller.removeAttribute("mediasubtitlesshowing");
17362        }
17363      });
17364
17365      provider.addEventListener("qualitychange", (e) => {
17366        qualityBtn.textContent = e.detail.quality;
17367      });
17368    }
17369
17370    function initLazyPlayers() {
17371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
17372        if (details.dataset.lazyInit) return;
17373        details.dataset.lazyInit = "true";
17374        details.addEventListener("toggle", () => {
17375          if (details.open && !details.querySelector(".player-container")) {
17376            createPlayer(details);
17377          } else if (!details.open) {
17378            const provider = details.querySelector("youtube-media-provider");
17379            if (provider && typeof provider.pause === "function") {
17380              provider.pause();
17381            }
17382          }
17383        });
17384      });
17385    }
17386
17387    if (document.readyState === "loading") {
17388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
17389    } else {
17390      initLazyPlayers();
17391    }
17392  })();
17393</script>
17393<script>
17394  (() => {
17395    if (window.__courseCompletionsInitialized) return;
17396    window.__courseCompletionsInitialized = true;
17397
17398    const STORAGE_KEY = "course-completions";
17399
17400    function slugify(text) {
17401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17402    }
17403
17404    function getCompletions() {
17405      try {
17406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
17407      } catch {
17408        return [];
17409      }
17410    }
17411
17412    function saveCompletions(completions) {
17413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
17414    }
17415
17416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
17417    function canonicalFromStored(storedId) {
17418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17419      if (!m) return slugify(storedId);
17420      const [, slug, p, mv, title] = m;
17421      return `${slug}-${p}-${mv}-${slugify(title)}`;
17422    }
17423
17424    function canonicalPlusOne(storedId) {
17425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17426      if (!m) return null;
17427      const [, slug, p, mv, title] = m;
17428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
17429    }
17430
17431    function isCompleted(canonicalId, completions) {
17432      if (completions.includes(canonicalId)) return true;
17433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
17434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
17435    }
17436
17437    function migrateCompletions() {
17438      const completions = getCompletions();
17439      let changed = false;
17440      const migrated = completions.map((c) => {
17441        const canon = canonicalFromStored(c);
17442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
17443        const plusOne = canonicalPlusOne(c);
17444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
17445          if (plusOne !== c) changed = true;
17446          return plusOne;
17447        }
17448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
17449          changed = true;
17450          return canon;
17451        }
17452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
17453        if (canon !== c) {
17454          changed = true;
17455          return canon;
17456        }
17457        return c;
17458      });
17459      // Elimina duplicados tras migración
17460      const deduped = [...new Set(migrated)];
17461      if (changed || deduped.length !== completions.length) {
17462        saveCompletions(deduped);
17463        return deduped;
17464      }
17465      return completions;
17466    }
17467
17468    function updateButtonState(btn, completions) {
17469      const id = btn.dataset.completionId;
17470      const list = completions ?? getCompletions();
17471      btn.classList.toggle("completed", isCompleted(id, list));
17472    }
17473
17474    function checkModuleCompletion(completions) {
17475      const list = completions ?? getCompletions();
17476      document.querySelectorAll(".course-video").forEach((module) => {
17477        const btns = module.querySelectorAll(".complete-btn");
17478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
17479        const check = module.querySelector(".module-check");
17480        if (check) check.classList.toggle("visible", allCompleted);
17481      });
17482    }
17483
17484    function initCompletionButtons() {
17485      const completions = migrateCompletions();
17486
17487      document.querySelectorAll(".complete-btn").forEach((btn) => {
17488        if (btn.dataset.completionInit) return;
17489        btn.dataset.completionInit = "true";
17490        updateButtonState(btn, completions);
17491
17492        btn.addEventListener("click", (e) => {
17493          e.preventDefault();
17494          e.stopPropagation();
17495
17496          const id = btn.dataset.completionId;
17497          let list = getCompletions();
17498          const completed = isCompleted(id, list);
17499
17500          if (completed) {
17501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
17502            list = list.filter((c) =>
17502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
17503          } else {
17504            list.push(id);
17505          }
17506
17507          saveCompletions(list);
17508          updateButtonState(btn, list);
17509          checkModuleCompletion(list);
17510        });
17511      });
17512
17513      checkModuleCompletion(completions);
17514    }
17515
17516    if (document.readyState === "loading") {
17517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
17518    } else {
17519      initCompletionButtons();
17520    }
17521  })();
17522</script>
17522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="1860" data-end="1980" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Cómo aprender CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>2 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-como-aprender-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
17522<script>
17523  (() => {
17524    if (window.__courseLazyPlayersInitialized) return;
17525    window.__courseLazyPlayersInitialized = true;
17526
17527    function createPlayer(details) {
17528      const videoId = details.dataset.videoId;
17529      const start = details.dataset.start;
17530      const end = details.dataset.end;
17531
17532      const container = document.createElement("div");
17533      container.className = "player-container";
17534
17535      const spinner = document.createElement("div");
17536      spinner.className = "spinner";
17537      container.appendChild(spinner);
17538
17539      const controller = document.createElement("media-controller");
17540      controller.setAttribute("defaultsubtitles", "");
17541
17542      const provider = document.createElement("youtube-media-provider");
17543      provider.setAttribute("slot", "media");
17544      provider.setAttribute("video-id", videoId);
17545      provider.setAttribute("start", start);
17546      provider.setAttribute("end", end);
17547
17548      const controlBar = document.createElement("media-control-bar");
17549
17550      const playBtn = document.createElement("media-play-button");
17551      const timeDisplay = document.createElement("media-time-display");
17552      timeDisplay.setAttribute("showduration", "");
17553      const timeRange = document.createElement("media-time-range");
17554      const playbackRate = document.createElement("media-playback-rate-button");
17555      const muteBtn = document.createElement("media-mute-button");
17556      const volumeRange = document.createElement("media-volume-range");
17557
17558      const captionsBtn = document.createElement("media-captions-button");
17559      captionsBtn.addEventListener("click", (e) => {
17560        e.stopPropagation();
17561        const provider = container.querySelector("youtube-media-provider");
17562        if (provider) provider.toggleCaptions();
17563      });
17564
17565      const qualityBtn = document.createElement("media-chrome-button");
17566      qualityBtn.textContent = "--";
17567      qualityBtn.addEventListener("click", () => {
17568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
17569      });
17570
17571      const fullscreenBtn = document.createElement("media-fullscreen-button");
17572
17573      controlBar.append(
17574        playBtn, timeDisplay, timeRange,
17575        playbackRate, muteBtn, volumeRange,
17576        captionsBtn, qualityBtn, fullscreenBtn
17577      );
17578
17579      const poster = document.createElement("media-poster-image");
17580      poster.setAttribute("slot", "poster");
17581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
17582
17583      controller.append(provider, poster, controlBar);
17584
17585      captionsBtn.setAttribute("aria-checked", "true");
17586      controller.setAttribute("mediasubtitlesshowing", "on");
17587
17588      container.appendChild(controller);
17589      details.appendChild(container);
17590
17591      provider.addEventListener("waiting", () => container.classList.add("loading"));
17592      provider.addEventListener("playing", () => container.classList.remove("loading"));
17593      provider.addEventListener("play", () => container.classList.remove("loading"));
17594
17595      provider._textTracks.addEventListener("change", () => {
17596        const enabled = provider._captionsEnabled;
17597        captionsBtn.setAttribute("aria-checked", enabled);
17598        if (enabled) {
17599          controller.setAttribute("mediasubtitlesshowing", "on");
17600        } else {
17601          controller.removeAttribute("mediasubtitlesshowing");
17602        }
17603      });
17604
17605      provider.addEventListener("qualitychange", (e) => {
17606        qualityBtn.textContent = e.detail.quality;
17607      });
17608    }
17609
17610    function initLazyPlayers() {
17611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
17612        if (details.dataset.lazyInit) return;
17613        details.dataset.lazyInit = "true";
17614        details.addEventListener("toggle", () => {
17615          if (details.open && !details.querySelector(".player-container")) {
17616            createPlayer(details);
17617          } else if (!details.open) {
17618            const provider = details.querySelector("youtube-media-provider");
17619            if (provider && typeof provider.pause === "function") {
17620              provider.pause();
17621            }
17622          }
17623        });
17624      });
17625    }
17626
17627    if (document.readyState === "loading") {
17628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
17629    } else {
17630      initLazyPlayers();
17631    }
17632  })();
17633</script>
17633<script>
17634  (() => {
17635    if (window.__courseCompletionsInitialized) return;
17636    window.__courseCompletionsInitialized = true;
17637
17638    const STORAGE_KEY = "course-completions";
17639
17640    function slugify(text) {
17641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17642    }
17643
17644    function getCompletions() {
17645      try {
17646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
17647      } catch {
17648        return [];
17649      }
17650    }
17651
17652    function saveCompletions(completions) {
17653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
17654    }
17655
17656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
17657    function canonicalFromStored(storedId) {
17658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17659      if (!m) return slugify(storedId);
17660      const [, slug, p, mv, title] = m;
17661      return `${slug}-${p}-${mv}-${slugify(title)}`;
17662    }
17663
17664    function canonicalPlusOne(storedId) {
17665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17666      if (!m) return null;
17667      const [, slug, p, mv, title] = m;
17668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
17669    }
17670
17671    function isCompleted(canonicalId, completions) {
17672      if (completions.includes(canonicalId)) return true;
17673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
17674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
17675    }
17676
17677    function migrateCompletions() {
17678      const completions = getCompletions();
17679      let changed = false;
17680      const migrated = completions.map((c) => {
17681        const canon = canonicalFromStored(c);
17682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
17683        const plusOne = canonicalPlusOne(c);
17684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
17685          if (plusOne !== c) changed = true;
17686          return plusOne;
17687        }
17688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
17689          changed = true;
17690          return canon;
17691        }
17692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
17693        if (canon !== c) {
17694          changed = true;
17695          return canon;
17696        }
17697        return c;
17698      });
17699      // Elimina duplicados tras migración
17700      const deduped = [...new Set(migrated)];
17701      if (changed || deduped.length !== completions.length) {
17702        saveCompletions(deduped);
17703        return deduped;
17704      }
17705      return completions;
17706    }
17707
17708    function updateButtonState(btn, completions) {
17709      const id = btn.dataset.completionId;
17710      const list = completions ?? getCompletions();
17711      btn.classList.toggle("completed", isCompleted(id, list));
17712    }
17713
17714    function checkModuleCompletion(completions) {
17715      const list = completions ?? getCompletions();
17716      document.querySelectorAll(".course-video").forEach((module) => {
17717        const btns = module.querySelectorAll(".complete-btn");
17718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
17719        const check = module.querySelector(".module-check");
17720        if (check) check.classList.toggle("visible", allCompleted);
17721      });
17722    }
17723
17724    function initCompletionButtons() {
17725      const completions = migrateCompletions();
17726
17727      document.querySelectorAll(".complete-btn").forEach((btn) => {
17728        if (btn.dataset.completionInit) return;
17729        btn.dataset.completionInit = "true";
17730        updateButtonState(btn, completions);
17731
17732        btn.addEventListener("click", (e) => {
17733          e.preventDefault();
17734          e.stopPropagation();
17735
17736          const id = btn.dataset.completionId;
17737          let list = getCompletions();
17738          const completed = isCompleted(id, list);
17739
17740          if (completed) {
17741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
17742            list = list.filter((c) =>
17742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
17743          } else {
17744            list.push(id);
17745          }
17746
17747          saveCompletions(list);
17748          updateButtonState(btn, list);
17749          checkModuleCompletion(list);
17750        });
17751      });
17752
17753      checkModuleCompletion(completions);
17754    }
17755
17756    if (document.readyState === "loading") {
17757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
17758    } else {
17759      initCompletionButtons();
17760    }
17761  })();
17762</script>
17762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="1980" data-end="2690" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Overflow (desbordamiento)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>12 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-overflow-desbordamiento" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
17762<script>
17763  (() => {
17764    if (window.__courseLazyPlayersInitialized) return;
17765    window.__courseLazyPlayersInitialized = true;
17766
17767    function createPlayer(details) {
17768      const videoId = details.dataset.videoId;
17769      const start = details.dataset.start;
17770      const end = details.dataset.end;
17771
17772      const container = document.createElement("div");
17773      container.className = "player-container";
17774
17775      const spinner = document.createElement("div");
17776      spinner.className = "spinner";
17777      container.appendChild(spinner);
17778
17779      const controller = document.createElement("media-controller");
17780      controller.setAttribute("defaultsubtitles", "");
17781
17782      const provider = document.createElement("youtube-media-provider");
17783      provider.setAttribute("slot", "media");
17784      provider.setAttribute("video-id", videoId);
17785      provider.setAttribute("start", start);
17786      provider.setAttribute("end", end);
17787
17788      const controlBar = document.createElement("media-control-bar");
17789
17790      const playBtn = document.createElement("media-play-button");
17791      const timeDisplay = document.createElement("media-time-display");
17792      timeDisplay.setAttribute("showduration", "");
17793      const timeRange = document.createElement("media-time-range");
17794      const playbackRate = document.createElement("media-playback-rate-button");
17795      const muteBtn = document.createElement("media-mute-button");
17796      const volumeRange = document.createElement("media-volume-range");
17797
17798      const captionsBtn = document.createElement("media-captions-button");
17799      captionsBtn.addEventListener("click", (e) => {
17800        e.stopPropagation();
17801        const provider = container.querySelector("youtube-media-provider");
17802        if (provider) provider.toggleCaptions();
17803      });
17804
17805      const qualityBtn = document.createElement("media-chrome-button");
17806      qualityBtn.textContent = "--";
17807      qualityBtn.addEventListener("click", () => {
17808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
17809      });
17810
17811      const fullscreenBtn = document.createElement("media-fullscreen-button");
17812
17813      controlBar.append(
17814        playBtn, timeDisplay, timeRange,
17815        playbackRate, muteBtn, volumeRange,
17816        captionsBtn, qualityBtn, fullscreenBtn
17817      );
17818
17819      const poster = document.createElement("media-poster-image");
17820      poster.setAttribute("slot", "poster");
17821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
17822
17823      controller.append(provider, poster, controlBar);
17824
17825      captionsBtn.setAttribute("aria-checked", "true");
17826      controller.setAttribute("mediasubtitlesshowing", "on");
17827
17828      container.appendChild(controller);
17829      details.appendChild(container);
17830
17831      provider.addEventListener("waiting", () => container.classList.add("loading"));
17832      provider.addEventListener("playing", () => container.classList.remove("loading"));
17833      provider.addEventListener("play", () => container.classList.remove("loading"));
17834
17835      provider._textTracks.addEventListener("change", () => {
17836        const enabled = provider._captionsEnabled;
17837        captionsBtn.setAttribute("aria-checked", enabled);
17838        if (enabled) {
17839          controller.setAttribute("mediasubtitlesshowing", "on");
17840        } else {
17841          controller.removeAttribute("mediasubtitlesshowing");
17842        }
17843      });
17844
17845      provider.addEventListener("qualitychange", (e) => {
17846        qualityBtn.textContent = e.detail.quality;
17847      });
17848    }
17849
17850    function initLazyPlayers() {
17851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
17852        if (details.dataset.lazyInit) return;
17853        details.dataset.lazyInit = "true";
17854        details.addEventListener("toggle", () => {
17855          if (details.open && !details.querySelector(".player-container")) {
17856            createPlayer(details);
17857          } else if (!details.open) {
17858            const provider = details.querySelector("youtube-media-provider");
17859            if (provider && typeof provider.pause === "function") {
17860              provider.pause();
17861            }
17862          }
17863        });
17864      });
17865    }
17866
17867    if (document.readyState === "loading") {
17868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
17869    } else {
17870      initLazyPlayers();
17871    }
17872  })();
17873</script>
17873<script>
17874  (() => {
17875    if (window.__courseCompletionsInitialized) return;
17876    window.__courseCompletionsInitialized = true;
17877
17878    const STORAGE_KEY = "course-completions";
17879
17880    function slugify(text) {
17881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17882    }
17883
17884    function getCompletions() {
17885      try {
17886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
17887      } catch {
17888        return [];
17889      }
17890    }
17891
17892    function saveCompletions(completions) {
17893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
17894    }
17895
17896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
17897    function canonicalFromStored(storedId) {
17898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17899      if (!m) return slugify(storedId);
17900      const [, slug, p, mv, title] = m;
17901      return `${slug}-${p}-${mv}-${slugify(title)}`;
17902    }
17903
17904    function canonicalPlusOne(storedId) {
17905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
17906      if (!m) return null;
17907      const [, slug, p, mv, title] = m;
17908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
17909    }
17910
17911    function isCompleted(canonicalId, completions) {
17912      if (completions.includes(canonicalId)) return true;
17913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
17914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
17915    }
17916
17917    function migrateCompletions() {
17918      const completions = getCompletions();
17919      let changed = false;
17920      const migrated = completions.map((c) => {
17921        const canon = canonicalFromStored(c);
17922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
17923        const plusOne = canonicalPlusOne(c);
17924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
17925          if (plusOne !== c) changed = true;
17926          return plusOne;
17927        }
17928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
17929          changed = true;
17930          return canon;
17931        }
17932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
17933        if (canon !== c) {
17934          changed = true;
17935          return canon;
17936        }
17937        return c;
17938      });
17939      // Elimina duplicados tras migración
17940      const deduped = [...new Set(migrated)];
17941      if (changed || deduped.length !== completions.length) {
17942        saveCompletions(deduped);
17943        return deduped;
17944      }
17945      return completions;
17946    }
17947
17948    function updateButtonState(btn, completions) {
17949      const id = btn.dataset.completionId;
17950      const list = completions ?? getCompletions();
17951      btn.classList.toggle("completed", isCompleted(id, list));
17952    }
17953
17954    function checkModuleCompletion(completions) {
17955      const list = completions ?? getCompletions();
17956      document.querySelectorAll(".course-video").forEach((module) => {
17957        const btns = module.querySelectorAll(".complete-btn");
17958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
17959        const check = module.querySelector(".module-check");
17960        if (check) check.classList.toggle("visible", allCompleted);
17961      });
17962    }
17963
17964    function initCompletionButtons() {
17965      const completions = migrateCompletions();
17966
17967      document.querySelectorAll(".complete-btn").forEach((btn) => {
17968        if (btn.dataset.completionInit) return;
17969        btn.dataset.completionInit = "true";
17970        updateButtonState(btn, completions);
17971
17972        btn.addEventListener("click", (e) => {
17973          e.preventDefault();
17974          e.stopPropagation();
17975
17976          const id = btn.dataset.completionId;
17977          let list = getCompletions();
17978          const completed = isCompleted(id, list);
17979
17980          if (completed) {
17981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
17982            list = list.filter((c) =>
17982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
17983          } else {
17984            list.push(id);
17985          }
17986
17987          saveCompletions(list);
17988          updateButtonState(btn, list);
17989          checkModuleCompletion(list);
17990        });
17991      });
17992
17993      checkModuleCompletion(completions);
17994    }
17995
17996    if (document.readyState === "loading") {
17997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
17998    } else {
17999      initCompletionButtons();
18000    }
18001  })();
18002</script>
18002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="2690" data-end="2830" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Cargar CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>2 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-cargar-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
18002<script>
18003  (() => {
18004    if (window.__courseLazyPlayersInitialized) return;
18005    window.__courseLazyPlayersInitialized = true;
18006
18007    function createPlayer(details) {
18008      const videoId = details.dataset.videoId;
18009      const start = details.dataset.start;
18010      const end = details.dataset.end;
18011
18012      const container = document.createElement("div");
18013      container.className = "player-container";
18014
18015      const spinner = document.createElement("div");
18016      spinner.className = "spinner";
18017      container.appendChild(spinner);
18018
18019      const controller = document.createElement("media-controller");
18020      controller.setAttribute("defaultsubtitles", "");
18021
18022      const provider = document.createElement("youtube-media-provider");
18023      provider.setAttribute("slot", "media");
18024      provider.setAttribute("video-id", videoId);
18025      provider.setAttribute("start", start);
18026      provider.setAttribute("end", end);
18027
18028      const controlBar = document.createElement("media-control-bar");
18029
18030      const playBtn = document.createElement("media-play-button");
18031      const timeDisplay = document.createElement("media-time-display");
18032      timeDisplay.setAttribute("showduration", "");
18033      const timeRange = document.createElement("media-time-range");
18034      const playbackRate = document.createElement("media-playback-rate-button");
18035      const muteBtn = document.createElement("media-mute-button");
18036      const volumeRange = document.createElement("media-volume-range");
18037
18038      const captionsBtn = document.createElement("media-captions-button");
18039      captionsBtn.addEventListener("click", (e) => {
18040        e.stopPropagation();
18041        const provider = container.querySelector("youtube-media-provider");
18042        if (provider) provider.toggleCaptions();
18043      });
18044
18045      const qualityBtn = document.createElement("media-chrome-button");
18046      qualityBtn.textContent = "--";
18047      qualityBtn.addEventListener("click", () => {
18048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
18049      });
18050
18051      const fullscreenBtn = document.createElement("media-fullscreen-button");
18052
18053      controlBar.append(
18054        playBtn, timeDisplay, timeRange,
18055        playbackRate, muteBtn, volumeRange,
18056        captionsBtn, qualityBtn, fullscreenBtn
18057      );
18058
18059      const poster = document.createElement("media-poster-image");
18060      poster.setAttribute("slot", "poster");
18061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
18062
18063      controller.append(provider, poster, controlBar);
18064
18065      captionsBtn.setAttribute("aria-checked", "true");
18066      controller.setAttribute("mediasubtitlesshowing", "on");
18067
18068      container.appendChild(controller);
18069      details.appendChild(container);
18070
18071      provider.addEventListener("waiting", () => container.classList.add("loading"));
18072      provider.addEventListener("playing", () => container.classList.remove("loading"));
18073      provider.addEventListener("play", () => container.classList.remove("loading"));
18074
18075      provider._textTracks.addEventListener("change", () => {
18076        const enabled = provider._captionsEnabled;
18077        captionsBtn.setAttribute("aria-checked", enabled);
18078        if (enabled) {
18079          controller.setAttribute("mediasubtitlesshowing", "on");
18080        } else {
18081          controller.removeAttribute("mediasubtitlesshowing");
18082        }
18083      });
18084
18085      provider.addEventListener("qualitychange", (e) => {
18086        qualityBtn.textContent = e.detail.quality;
18087      });
18088    }
18089
18090    function initLazyPlayers() {
18091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
18092        if (details.dataset.lazyInit) return;
18093        details.dataset.lazyInit = "true";
18094        details.addEventListener("toggle", () => {
18095          if (details.open && !details.querySelector(".player-container")) {
18096            createPlayer(details);
18097          } else if (!details.open) {
18098            const provider = details.querySelector("youtube-media-provider");
18099            if (provider && typeof provider.pause === "function") {
18100              provider.pause();
18101            }
18102          }
18103        });
18104      });
18105    }
18106
18107    if (document.readyState === "loading") {
18108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
18109    } else {
18110      initLazyPlayers();
18111    }
18112  })();
18113</script>
18113<script>
18114  (() => {
18115    if (window.__courseCompletionsInitialized) return;
18116    window.__courseCompletionsInitialized = true;
18117
18118    const STORAGE_KEY = "course-completions";
18119
18120    function slugify(text) {
18121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
18122    }
18123
18124    function getCompletions() {
18125      try {
18126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
18127      } catch {
18128        return [];
18129      }
18130    }
18131
18132    function saveCompletions(completions) {
18133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
18134    }
18135
18136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
18137    function canonicalFromStored(storedId) {
18138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18139      if (!m) return slugify(storedId);
18140      const [, slug, p, mv, title] = m;
18141      return `${slug}-${p}-${mv}-${slugify(title)}`;
18142    }
18143
18144    function canonicalPlusOne(storedId) {
18145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18146      if (!m) return null;
18147      const [, slug, p, mv, title] = m;
18148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
18149    }
18150
18151    function isCompleted(canonicalId, completions) {
18152      if (completions.includes(canonicalId)) return true;
18153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
18154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
18155    }
18156
18157    function migrateCompletions() {
18158      const completions = getCompletions();
18159      let changed = false;
18160      const migrated = completions.map((c) => {
18161        const canon = canonicalFromStored(c);
18162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
18163        const plusOne = canonicalPlusOne(c);
18164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
18165          if (plusOne !== c) changed = true;
18166          return plusOne;
18167        }
18168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
18169          changed = true;
18170          return canon;
18171        }
18172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
18173        if (canon !== c) {
18174          changed = true;
18175          return canon;
18176        }
18177        return c;
18178      });
18179      // Elimina duplicados tras migración
18180      const deduped = [...new Set(migrated)];
18181      if (changed || deduped.length !== completions.length) {
18182        saveCompletions(deduped);
18183        return deduped;
18184      }
18185      return completions;
18186    }
18187
18188    function updateButtonState(btn, completions) {
18189      const id = btn.dataset.completionId;
18190      const list = completions ?? getCompletions();
18191      btn.classList.toggle("completed", isCompleted(id, list));
18192    }
18193
18194    function checkModuleCompletion(completions) {
18195      const list = completions ?? getCompletions();
18196      document.querySelectorAll(".course-video").forEach((module) => {
18197        const btns = module.querySelectorAll(".complete-btn");
18198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
18199        const check = module.querySelector(".module-check");
18200        if (check) check.classList.toggle("visible", allCompleted);
18201      });
18202    }
18203
18204    function initCompletionButtons() {
18205      const completions = migrateCompletions();
18206
18207      document.querySelectorAll(".complete-btn").forEach((btn) => {
18208        if (btn.dataset.completionInit) return;
18209        btn.dataset.completionInit = "true";
18210        updateButtonState(btn, completions);
18211
18212        btn.addEventListener("click", (e) => {
18213          e.preventDefault();
18214          e.stopPropagation();
18215
18216          const id = btn.dataset.completionId;
18217          let list = getCompletions();
18218          const completed = isCompleted(id, list);
18219
18220          if (completed) {
18221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
18222            list = list.filter((c) =>
18222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
18223          } else {
18224            list.push(id);
18225          }
18226
18227          saveCompletions(list);
18228          updateButtonState(btn, list);
18229          checkModuleCompletion(list);
18230        });
18231      });
18232
18233      checkModuleCompletion(completions);
18234    }
18235
18236    if (document.readyState === "loading") {
18237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
18238    } else {
18239      initCompletionButtons();
18240    }
18241  })();
18242</script>
18242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="2830" data-end="3426" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Variables CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>10 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-variables-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
18242<script>
18243  (() => {
18244    if (window.__courseLazyPlayersInitialized) return;
18245    window.__courseLazyPlayersInitialized = true;
18246
18247    function createPlayer(details) {
18248      const videoId = details.dataset.videoId;
18249      const start = details.dataset.start;
18250      const end = details.dataset.end;
18251
18252      const container = document.createElement("div");
18253      container.className = "player-container";
18254
18255      const spinner = document.createElement("div");
18256      spinner.className = "spinner";
18257      container.appendChild(spinner);
18258
18259      const controller = document.createElement("media-controller");
18260      controller.setAttribute("defaultsubtitles", "");
18261
18262      const provider = document.createElement("youtube-media-provider");
18263      provider.setAttribute("slot", "media");
18264      provider.setAttribute("video-id", videoId);
18265      provider.setAttribute("start", start);
18266      provider.setAttribute("end", end);
18267
18268      const controlBar = document.createElement("media-control-bar");
18269
18270      const playBtn = document.createElement("media-play-button");
18271      const timeDisplay = document.createElement("media-time-display");
18272      timeDisplay.setAttribute("showduration", "");
18273      const timeRange = document.createElement("media-time-range");
18274      const playbackRate = document.createElement("media-playback-rate-button");
18275      const muteBtn = document.createElement("media-mute-button");
18276      const volumeRange = document.createElement("media-volume-range");
18277
18278      const captionsBtn = document.createElement("media-captions-button");
18279      captionsBtn.addEventListener("click", (e) => {
18280        e.stopPropagation();
18281        const provider = container.querySelector("youtube-media-provider");
18282        if (provider) provider.toggleCaptions();
18283      });
18284
18285      const qualityBtn = document.createElement("media-chrome-button");
18286      qualityBtn.textContent = "--";
18287      qualityBtn.addEventListener("click", () => {
18288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
18289      });
18290
18291      const fullscreenBtn = document.createElement("media-fullscreen-button");
18292
18293      controlBar.append(
18294        playBtn, timeDisplay, timeRange,
18295        playbackRate, muteBtn, volumeRange,
18296        captionsBtn, qualityBtn, fullscreenBtn
18297      );
18298
18299      const poster = document.createElement("media-poster-image");
18300      poster.setAttribute("slot", "poster");
18301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
18302
18303      controller.append(provider, poster, controlBar);
18304
18305      captionsBtn.setAttribute("aria-checked", "true");
18306      controller.setAttribute("mediasubtitlesshowing", "on");
18307
18308      container.appendChild(controller);
18309      details.appendChild(container);
18310
18311      provider.addEventListener("waiting", () => container.classList.add("loading"));
18312      provider.addEventListener("playing", () => container.classList.remove("loading"));
18313      provider.addEventListener("play", () => container.classList.remove("loading"));
18314
18315      provider._textTracks.addEventListener("change", () => {
18316        const enabled = provider._captionsEnabled;
18317        captionsBtn.setAttribute("aria-checked", enabled);
18318        if (enabled) {
18319          controller.setAttribute("mediasubtitlesshowing", "on");
18320        } else {
18321          controller.removeAttribute("mediasubtitlesshowing");
18322        }
18323      });
18324
18325      provider.addEventListener("qualitychange", (e) => {
18326        qualityBtn.textContent = e.detail.quality;
18327      });
18328    }
18329
18330    function initLazyPlayers() {
18331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
18332        if (details.dataset.lazyInit) return;
18333        details.dataset.lazyInit = "true";
18334        details.addEventListener("toggle", () => {
18335          if (details.open && !details.querySelector(".player-container")) {
18336            createPlayer(details);
18337          } else if (!details.open) {
18338            const provider = details.querySelector("youtube-media-provider");
18339            if (provider && typeof provider.pause === "function") {
18340              provider.pause();
18341            }
18342          }
18343        });
18344      });
18345    }
18346
18347    if (document.readyState === "loading") {
18348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
18349    } else {
18350      initLazyPlayers();
18351    }
18352  })();
18353</script>
18353<script>
18354  (() => {
18355    if (window.__courseCompletionsInitialized) return;
18356    window.__courseCompletionsInitialized = true;
18357
18358    const STORAGE_KEY = "course-completions";
18359
18360    function slugify(text) {
18361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
18362    }
18363
18364    function getCompletions() {
18365      try {
18366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
18367      } catch {
18368        return [];
18369      }
18370    }
18371
18372    function saveCompletions(completions) {
18373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
18374    }
18375
18376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
18377    function canonicalFromStored(storedId) {
18378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18379      if (!m) return slugify(storedId);
18380      const [, slug, p, mv, title] = m;
18381      return `${slug}-${p}-${mv}-${slugify(title)}`;
18382    }
18383
18384    function canonicalPlusOne(storedId) {
18385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18386      if (!m) return null;
18387      const [, slug, p, mv, title] = m;
18388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
18389    }
18390
18391    function isCompleted(canonicalId, completions) {
18392      if (completions.includes(canonicalId)) return true;
18393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
18394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
18395    }
18396
18397    function migrateCompletions() {
18398      const completions = getCompletions();
18399      let changed = false;
18400      const migrated = completions.map((c) => {
18401        const canon = canonicalFromStored(c);
18402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
18403        const plusOne = canonicalPlusOne(c);
18404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
18405          if (plusOne !== c) changed = true;
18406          return plusOne;
18407        }
18408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
18409          changed = true;
18410          return canon;
18411        }
18412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
18413        if (canon !== c) {
18414          changed = true;
18415          return canon;
18416        }
18417        return c;
18418      });
18419      // Elimina duplicados tras migración
18420      const deduped = [...new Set(migrated)];
18421      if (changed || deduped.length !== completions.length) {
18422        saveCompletions(deduped);
18423        return deduped;
18424      }
18425      return completions;
18426    }
18427
18428    function updateButtonState(btn, completions) {
18429      const id = btn.dataset.completionId;
18430      const list = completions ?? getCompletions();
18431      btn.classList.toggle("completed", isCompleted(id, list));
18432    }
18433
18434    function checkModuleCompletion(completions) {
18435      const list = completions ?? getCompletions();
18436      document.querySelectorAll(".course-video").forEach((module) => {
18437        const btns = module.querySelectorAll(".complete-btn");
18438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
18439        const check = module.querySelector(".module-check");
18440        if (check) check.classList.toggle("visible", allCompleted);
18441      });
18442    }
18443
18444    function initCompletionButtons() {
18445      const completions = migrateCompletions();
18446
18447      document.querySelectorAll(".complete-btn").forEach((btn) => {
18448        if (btn.dataset.completionInit) return;
18449        btn.dataset.completionInit = "true";
18450        updateButtonState(btn, completions);
18451
18452        btn.addEventListener("click", (e) => {
18453          e.preventDefault();
18454          e.stopPropagation();
18455
18456          const id = btn.dataset.completionId;
18457          let list = getCompletions();
18458          const completed = isCompleted(id, list);
18459
18460          if (completed) {
18461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
18462            list = list.filter((c) =>
18462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
18463          } else {
18464            list.push(id);
18465          }
18466
18467          saveCompletions(list);
18468          updateButtonState(btn, list);
18469          checkModuleCompletion(list);
18470        });
18471      });
18472
18473      checkModuleCompletion(completions);
18474    }
18475
18476    if (document.readyState === "loading") {
18477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
18478    } else {
18479      initCompletionButtons();
18480    }
18481  })();
18482</script>
18482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="3426" data-end="4340" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Unidades CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-unidades-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
18482<script>
18483  (() => {
18484    if (window.__courseLazyPlayersInitialized) return;
18485    window.__courseLazyPlayersInitialized = true;
18486
18487    function createPlayer(details) {
18488      const videoId = details.dataset.videoId;
18489      const start = details.dataset.start;
18490      const end = details.dataset.end;
18491
18492      const container = document.createElement("div");
18493      container.className = "player-container";
18494
18495      const spinner = document.createElement("div");
18496      spinner.className = "spinner";
18497      container.appendChild(spinner);
18498
18499      const controller = document.createElement("media-controller");
18500      controller.setAttribute("defaultsubtitles", "");
18501
18502      const provider = document.createElement("youtube-media-provider");
18503      provider.setAttribute("slot", "media");
18504      provider.setAttribute("video-id", videoId);
18505      provider.setAttribute("start", start);
18506      provider.setAttribute("end", end);
18507
18508      const controlBar = document.createElement("media-control-bar");
18509
18510      const playBtn = document.createElement("media-play-button");
18511      const timeDisplay = document.createElement("media-time-display");
18512      timeDisplay.setAttribute("showduration", "");
18513      const timeRange = document.createElement("media-time-range");
18514      const playbackRate = document.createElement("media-playback-rate-button");
18515      const muteBtn = document.createElement("media-mute-button");
18516      const volumeRange = document.createElement("media-volume-range");
18517
18518      const captionsBtn = document.createElement("media-captions-button");
18519      captionsBtn.addEventListener("click", (e) => {
18520        e.stopPropagation();
18521        const provider = container.querySelector("youtube-media-provider");
18522        if (provider) provider.toggleCaptions();
18523      });
18524
18525      const qualityBtn = document.createElement("media-chrome-button");
18526      qualityBtn.textContent = "--";
18527      qualityBtn.addEventListener("click", () => {
18528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
18529      });
18530
18531      const fullscreenBtn = document.createElement("media-fullscreen-button");
18532
18533      controlBar.append(
18534        playBtn, timeDisplay, timeRange,
18535        playbackRate, muteBtn, volumeRange,
18536        captionsBtn, qualityBtn, fullscreenBtn
18537      );
18538
18539      const poster = document.createElement("media-poster-image");
18540      poster.setAttribute("slot", "poster");
18541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
18542
18543      controller.append(provider, poster, controlBar);
18544
18545      captionsBtn.setAttribute("aria-checked", "true");
18546      controller.setAttribute("mediasubtitlesshowing", "on");
18547
18548      container.appendChild(controller);
18549      details.appendChild(container);
18550
18551      provider.addEventListener("waiting", () => container.classList.add("loading"));
18552      provider.addEventListener("playing", () => container.classList.remove("loading"));
18553      provider.addEventListener("play", () => container.classList.remove("loading"));
18554
18555      provider._textTracks.addEventListener("change", () => {
18556        const enabled = provider._captionsEnabled;
18557        captionsBtn.setAttribute("aria-checked", enabled);
18558        if (enabled) {
18559          controller.setAttribute("mediasubtitlesshowing", "on");
18560        } else {
18561          controller.removeAttribute("mediasubtitlesshowing");
18562        }
18563      });
18564
18565      provider.addEventListener("qualitychange", (e) => {
18566        qualityBtn.textContent = e.detail.quality;
18567      });
18568    }
18569
18570    function initLazyPlayers() {
18571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
18572        if (details.dataset.lazyInit) return;
18573        details.dataset.lazyInit = "true";
18574        details.addEventListener("toggle", () => {
18575          if (details.open && !details.querySelector(".player-container")) {
18576            createPlayer(details);
18577          } else if (!details.open) {
18578            const provider = details.querySelector("youtube-media-provider");
18579            if (provider && typeof provider.pause === "function") {
18580              provider.pause();
18581            }
18582          }
18583        });
18584      });
18585    }
18586
18587    if (document.readyState === "loading") {
18588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
18589    } else {
18590      initLazyPlayers();
18591    }
18592  })();
18593</script>
18593<script>
18594  (() => {
18595    if (window.__courseCompletionsInitialized) return;
18596    window.__courseCompletionsInitialized = true;
18597
18598    const STORAGE_KEY = "course-completions";
18599
18600    function slugify(text) {
18601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
18602    }
18603
18604    function getCompletions() {
18605      try {
18606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
18607      } catch {
18608        return [];
18609      }
18610    }
18611
18612    function saveCompletions(completions) {
18613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
18614    }
18615
18616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
18617    function canonicalFromStored(storedId) {
18618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18619      if (!m) return slugify(storedId);
18620      const [, slug, p, mv, title] = m;
18621      return `${slug}-${p}-${mv}-${slugify(title)}`;
18622    }
18623
18624    function canonicalPlusOne(storedId) {
18625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18626      if (!m) return null;
18627      const [, slug, p, mv, title] = m;
18628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
18629    }
18630
18631    function isCompleted(canonicalId, completions) {
18632      if (completions.includes(canonicalId)) return true;
18633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
18634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
18635    }
18636
18637    function migrateCompletions() {
18638      const completions = getCompletions();
18639      let changed = false;
18640      const migrated = completions.map((c) => {
18641        const canon = canonicalFromStored(c);
18642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
18643        const plusOne = canonicalPlusOne(c);
18644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
18645          if (plusOne !== c) changed = true;
18646          return plusOne;
18647        }
18648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
18649          changed = true;
18650          return canon;
18651        }
18652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
18653        if (canon !== c) {
18654          changed = true;
18655          return canon;
18656        }
18657        return c;
18658      });
18659      // Elimina duplicados tras migración
18660      const deduped = [...new Set(migrated)];
18661      if (changed || deduped.length !== completions.length) {
18662        saveCompletions(deduped);
18663        return deduped;
18664      }
18665      return completions;
18666    }
18667
18668    function updateButtonState(btn, completions) {
18669      const id = btn.dataset.completionId;
18670      const list = completions ?? getCompletions();
18671      btn.classList.toggle("completed", isCompleted(id, list));
18672    }
18673
18674    function checkModuleCompletion(completions) {
18675      const list = completions ?? getCompletions();
18676      document.querySelectorAll(".course-video").forEach((module) => {
18677        const btns = module.querySelectorAll(".complete-btn");
18678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
18679        const check = module.querySelector(".module-check");
18680        if (check) check.classList.toggle("visible", allCompleted);
18681      });
18682    }
18683
18684    function initCompletionButtons() {
18685      const completions = migrateCompletions();
18686
18687      document.querySelectorAll(".complete-btn").forEach((btn) => {
18688        if (btn.dataset.completionInit) return;
18689        btn.dataset.completionInit = "true";
18690        updateButtonState(btn, completions);
18691
18692        btn.addEventListener("click", (e) => {
18693          e.preventDefault();
18694          e.stopPropagation();
18695
18696          const id = btn.dataset.completionId;
18697          let list = getCompletions();
18698          const completed = isCompleted(id, list);
18699
18700          if (completed) {
18701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
18702            list = list.filter((c) =>
18702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
18703          } else {
18704            list.push(id);
18705          }
18706
18707          saveCompletions(list);
18708          updateButtonState(btn, list);
18709          checkModuleCompletion(list);
18710        });
18711      });
18712
18713      checkModuleCompletion(completions);
18714    }
18715
18716    if (document.readyState === "loading") {
18717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
18718    } else {
18719      initCompletionButtons();
18720    }
18721  })();
18722</script>
18722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="4340" data-end="5160" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Modelo de cajas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-modelo-de-cajas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
18722<script>
18723  (() => {
18724    if (window.__courseLazyPlayersInitialized) return;
18725    window.__courseLazyPlayersInitialized = true;
18726
18727    function createPlayer(details) {
18728      const videoId = details.dataset.videoId;
18729      const start = details.dataset.start;
18730      const end = details.dataset.end;
18731
18732      const container = document.createElement("div");
18733      container.className = "player-container";
18734
18735      const spinner = document.createElement("div");
18736      spinner.className = "spinner";
18737      container.appendChild(spinner);
18738
18739      const controller = document.createElement("media-controller");
18740      controller.setAttribute("defaultsubtitles", "");
18741
18742      const provider = document.createElement("youtube-media-provider");
18743      provider.setAttribute("slot", "media");
18744      provider.setAttribute("video-id", videoId);
18745      provider.setAttribute("start", start);
18746      provider.setAttribute("end", end);
18747
18748      const controlBar = document.createElement("media-control-bar");
18749
18750      const playBtn = document.createElement("media-play-button");
18751      const timeDisplay = document.createElement("media-time-display");
18752      timeDisplay.setAttribute("showduration", "");
18753      const timeRange = document.createElement("media-time-range");
18754      const playbackRate = document.createElement("media-playback-rate-button");
18755      const muteBtn = document.createElement("media-mute-button");
18756      const volumeRange = document.createElement("media-volume-range");
18757
18758      const captionsBtn = document.createElement("media-captions-button");
18759      captionsBtn.addEventListener("click", (e) => {
18760        e.stopPropagation();
18761        const provider = container.querySelector("youtube-media-provider");
18762        if (provider) provider.toggleCaptions();
18763      });
18764
18765      const qualityBtn = document.createElement("media-chrome-button");
18766      qualityBtn.textContent = "--";
18767      qualityBtn.addEventListener("click", () => {
18768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
18769      });
18770
18771      const fullscreenBtn = document.createElement("media-fullscreen-button");
18772
18773      controlBar.append(
18774        playBtn, timeDisplay, timeRange,
18775        playbackRate, muteBtn, volumeRange,
18776        captionsBtn, qualityBtn, fullscreenBtn
18777      );
18778
18779      const poster = document.createElement("media-poster-image");
18780      poster.setAttribute("slot", "poster");
18781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
18782
18783      controller.append(provider, poster, controlBar);
18784
18785      captionsBtn.setAttribute("aria-checked", "true");
18786      controller.setAttribute("mediasubtitlesshowing", "on");
18787
18788      container.appendChild(controller);
18789      details.appendChild(container);
18790
18791      provider.addEventListener("waiting", () => container.classList.add("loading"));
18792      provider.addEventListener("playing", () => container.classList.remove("loading"));
18793      provider.addEventListener("play", () => container.classList.remove("loading"));
18794
18795      provider._textTracks.addEventListener("change", () => {
18796        const enabled = provider._captionsEnabled;
18797        captionsBtn.setAttribute("aria-checked", enabled);
18798        if (enabled) {
18799          controller.setAttribute("mediasubtitlesshowing", "on");
18800        } else {
18801          controller.removeAttribute("mediasubtitlesshowing");
18802        }
18803      });
18804
18805      provider.addEventListener("qualitychange", (e) => {
18806        qualityBtn.textContent = e.detail.quality;
18807      });
18808    }
18809
18810    function initLazyPlayers() {
18811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
18812        if (details.dataset.lazyInit) return;
18813        details.dataset.lazyInit = "true";
18814        details.addEventListener("toggle", () => {
18815          if (details.open && !details.querySelector(".player-container")) {
18816            createPlayer(details);
18817          } else if (!details.open) {
18818            const provider = details.querySelector("youtube-media-provider");
18819            if (provider && typeof provider.pause === "function") {
18820              provider.pause();
18821            }
18822          }
18823        });
18824      });
18825    }
18826
18827    if (document.readyState === "loading") {
18828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
18829    } else {
18830      initLazyPlayers();
18831    }
18832  })();
18833</script>
18833<script>
18834  (() => {
18835    if (window.__courseCompletionsInitialized) return;
18836    window.__courseCompletionsInitialized = true;
18837
18838    const STORAGE_KEY = "course-completions";
18839
18840    function slugify(text) {
18841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
18842    }
18843
18844    function getCompletions() {
18845      try {
18846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
18847      } catch {
18848        return [];
18849      }
18850    }
18851
18852    function saveCompletions(completions) {
18853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
18854    }
18855
18856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
18857    function canonicalFromStored(storedId) {
18858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18859      if (!m) return slugify(storedId);
18860      const [, slug, p, mv, title] = m;
18861      return `${slug}-${p}-${mv}-${slugify(title)}`;
18862    }
18863
18864    function canonicalPlusOne(storedId) {
18865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
18866      if (!m) return null;
18867      const [, slug, p, mv, title] = m;
18868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
18869    }
18870
18871    function isCompleted(canonicalId, completions) {
18872      if (completions.includes(canonicalId)) return true;
18873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
18874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
18875    }
18876
18877    function migrateCompletions() {
18878      const completions = getCompletions();
18879      let changed = false;
18880      const migrated = completions.map((c) => {
18881        const canon = canonicalFromStored(c);
18882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
18883        const plusOne = canonicalPlusOne(c);
18884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
18885          if (plusOne !== c) changed = true;
18886          return plusOne;
18887        }
18888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
18889          changed = true;
18890          return canon;
18891        }
18892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
18893        if (canon !== c) {
18894          changed = true;
18895          return canon;
18896        }
18897        return c;
18898      });
18899      // Elimina duplicados tras migración
18900      const deduped = [...new Set(migrated)];
18901      if (changed || deduped.length !== completions.length) {
18902        saveCompletions(deduped);
18903        return deduped;
18904      }
18905      return completions;
18906    }
18907
18908    function updateButtonState(btn, completions) {
18909      const id = btn.dataset.completionId;
18910      const list = completions ?? getCompletions();
18911      btn.classList.toggle("completed", isCompleted(id, list));
18912    }
18913
18914    function checkModuleCompletion(completions) {
18915      const list = completions ?? getCompletions();
18916      document.querySelectorAll(".course-video").forEach((module) => {
18917        const btns = module.querySelectorAll(".complete-btn");
18918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
18919        const check = module.querySelector(".module-check");
18920        if (check) check.classList.toggle("visible", allCompleted);
18921      });
18922    }
18923
18924    function initCompletionButtons() {
18925      const completions = migrateCompletions();
18926
18927      document.querySelectorAll(".complete-btn").forEach((btn) => {
18928        if (btn.dataset.completionInit) return;
18929        btn.dataset.completionInit = "true";
18930        updateButtonState(btn, completions);
18931
18932        btn.addEventListener("click", (e) => {
18933          e.preventDefault();
18934          e.stopPropagation();
18935
18936          const id = btn.dataset.completionId;
18937          let list = getCompletions();
18938          const completed = isCompleted(id, list);
18939
18940          if (completed) {
18941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
18942            list = list.filter((c) =>
18942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
18943          } else {
18944            list.push(id);
18945          }
18946
18947          saveCompletions(list);
18948          updateButtonState(btn, list);
18949          checkModuleCompletion(list);
18950        });
18951      });
18952
18953      checkModuleCompletion(completions);
18954    }
18955
18956    if (document.readyState === "loading") {
18957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
18958    } else {
18959      initCompletionButtons();
18960    }
18961  })();
18962</script>
18962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="5160" data-end="6522" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Bordes y esquinas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>23 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-bordes-y-esquinas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
18962<script>
18963  (() => {
18964    if (window.__courseLazyPlayersInitialized) return;
18965    window.__courseLazyPlayersInitialized = true;
18966
18967    function createPlayer(details) {
18968      const videoId = details.dataset.videoId;
18969      const start = details.dataset.start;
18970      const end = details.dataset.end;
18971
18972      const container = document.createElement("div");
18973      container.className = "player-container";
18974
18975      const spinner = document.createElement("div");
18976      spinner.className = "spinner";
18977      container.appendChild(spinner);
18978
18979      const controller = document.createElement("media-controller");
18980      controller.setAttribute("defaultsubtitles", "");
18981
18982      const provider = document.createElement("youtube-media-provider");
18983      provider.setAttribute("slot", "media");
18984      provider.setAttribute("video-id", videoId);
18985      provider.setAttribute("start", start);
18986      provider.setAttribute("end", end);
18987
18988      const controlBar = document.createElement("media-control-bar");
18989
18990      const playBtn = document.createElement("media-play-button");
18991      const timeDisplay = document.createElement("media-time-display");
18992      timeDisplay.setAttribute("showduration", "");
18993      const timeRange = document.createElement("media-time-range");
18994      const playbackRate = document.createElement("media-playback-rate-button");
18995      const muteBtn = document.createElement("media-mute-button");
18996      const volumeRange = document.createElement("media-volume-range");
18997
18998      const captionsBtn = document.createElement("media-captions-button");
18999      captionsBtn.addEventListener("click", (e) => {
19000        e.stopPropagation();
19001        const provider = container.querySelector("youtube-media-provider");
19002        if (provider) provider.toggleCaptions();
19003      });
19004
19005      const qualityBtn = document.createElement("media-chrome-button");
19006      qualityBtn.textContent = "--";
19007      qualityBtn.addEventListener("click", () => {
19008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
19009      });
19010
19011      const fullscreenBtn = document.createElement("media-fullscreen-button");
19012
19013      controlBar.append(
19014        playBtn, timeDisplay, timeRange,
19015        playbackRate, muteBtn, volumeRange,
19016        captionsBtn, qualityBtn, fullscreenBtn
19017      );
19018
19019      const poster = document.createElement("media-poster-image");
19020      poster.setAttribute("slot", "poster");
19021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
19022
19023      controller.append(provider, poster, controlBar);
19024
19025      captionsBtn.setAttribute("aria-checked", "true");
19026      controller.setAttribute("mediasubtitlesshowing", "on");
19027
19028      container.appendChild(controller);
19029      details.appendChild(container);
19030
19031      provider.addEventListener("waiting", () => container.classList.add("loading"));
19032      provider.addEventListener("playing", () => container.classList.remove("loading"));
19033      provider.addEventListener("play", () => container.classList.remove("loading"));
19034
19035      provider._textTracks.addEventListener("change", () => {
19036        const enabled = provider._captionsEnabled;
19037        captionsBtn.setAttribute("aria-checked", enabled);
19038        if (enabled) {
19039          controller.setAttribute("mediasubtitlesshowing", "on");
19040        } else {
19041          controller.removeAttribute("mediasubtitlesshowing");
19042        }
19043      });
19044
19045      provider.addEventListener("qualitychange", (e) => {
19046        qualityBtn.textContent = e.detail.quality;
19047      });
19048    }
19049
19050    function initLazyPlayers() {
19051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
19052        if (details.dataset.lazyInit) return;
19053        details.dataset.lazyInit = "true";
19054        details.addEventListener("toggle", () => {
19055          if (details.open && !details.querySelector(".player-container")) {
19056            createPlayer(details);
19057          } else if (!details.open) {
19058            const provider = details.querySelector("youtube-media-provider");
19059            if (provider && typeof provider.pause === "function") {
19060              provider.pause();
19061            }
19062          }
19063        });
19064      });
19065    }
19066
19067    if (document.readyState === "loading") {
19068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
19069    } else {
19070      initLazyPlayers();
19071    }
19072  })();
19073</script>
19073<script>
19074  (() => {
19075    if (window.__courseCompletionsInitialized) return;
19076    window.__courseCompletionsInitialized = true;
19077
19078    const STORAGE_KEY = "course-completions";
19079
19080    function slugify(text) {
19081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19082    }
19083
19084    function getCompletions() {
19085      try {
19086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
19087      } catch {
19088        return [];
19089      }
19090    }
19091
19092    function saveCompletions(completions) {
19093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
19094    }
19095
19096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
19097    function canonicalFromStored(storedId) {
19098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19099      if (!m) return slugify(storedId);
19100      const [, slug, p, mv, title] = m;
19101      return `${slug}-${p}-${mv}-${slugify(title)}`;
19102    }
19103
19104    function canonicalPlusOne(storedId) {
19105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19106      if (!m) return null;
19107      const [, slug, p, mv, title] = m;
19108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
19109    }
19110
19111    function isCompleted(canonicalId, completions) {
19112      if (completions.includes(canonicalId)) return true;
19113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
19114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
19115    }
19116
19117    function migrateCompletions() {
19118      const completions = getCompletions();
19119      let changed = false;
19120      const migrated = completions.map((c) => {
19121        const canon = canonicalFromStored(c);
19122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
19123        const plusOne = canonicalPlusOne(c);
19124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
19125          if (plusOne !== c) changed = true;
19126          return plusOne;
19127        }
19128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
19129          changed = true;
19130          return canon;
19131        }
19132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
19133        if (canon !== c) {
19134          changed = true;
19135          return canon;
19136        }
19137        return c;
19138      });
19139      // Elimina duplicados tras migración
19140      const deduped = [...new Set(migrated)];
19141      if (changed || deduped.length !== completions.length) {
19142        saveCompletions(deduped);
19143        return deduped;
19144      }
19145      return completions;
19146    }
19147
19148    function updateButtonState(btn, completions) {
19149      const id = btn.dataset.completionId;
19150      const list = completions ?? getCompletions();
19151      btn.classList.toggle("completed", isCompleted(id, list));
19152    }
19153
19154    function checkModuleCompletion(completions) {
19155      const list = completions ?? getCompletions();
19156      document.querySelectorAll(".course-video").forEach((module) => {
19157        const btns = module.querySelectorAll(".complete-btn");
19158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
19159        const check = module.querySelector(".module-check");
19160        if (check) check.classList.toggle("visible", allCompleted);
19161      });
19162    }
19163
19164    function initCompletionButtons() {
19165      const completions = migrateCompletions();
19166
19167      document.querySelectorAll(".complete-btn").forEach((btn) => {
19168        if (btn.dataset.completionInit) return;
19169        btn.dataset.completionInit = "true";
19170        updateButtonState(btn, completions);
19171
19172        btn.addEventListener("click", (e) => {
19173          e.preventDefault();
19174          e.stopPropagation();
19175
19176          const id = btn.dataset.completionId;
19177          let list = getCompletions();
19178          const completed = isCompleted(id, list);
19179
19180          if (completed) {
19181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
19182            list = list.filter((c) =>
19182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
19183          } else {
19184            list.push(id);
19185          }
19186
19187          saveCompletions(list);
19188          updateButtonState(btn, list);
19189          checkModuleCompletion(list);
19190        });
19191      });
19192
19193      checkModuleCompletion(completions);
19194    }
19195
19196    if (document.readyState === "loading") {
19197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
19198    } else {
19199      initCompletionButtons();
19200    }
19201  })();
19202</script>
19202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="6815" data-end="7660" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Esquemas de colores</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-esquemas-de-colores" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
19202<script>
19203  (() => {
19204    if (window.__courseLazyPlayersInitialized) return;
19205    window.__courseLazyPlayersInitialized = true;
19206
19207    function createPlayer(details) {
19208      const videoId = details.dataset.videoId;
19209      const start = details.dataset.start;
19210      const end = details.dataset.end;
19211
19212      const container = document.createElement("div");
19213      container.className = "player-container";
19214
19215      const spinner = document.createElement("div");
19216      spinner.className = "spinner";
19217      container.appendChild(spinner);
19218
19219      const controller = document.createElement("media-controller");
19220      controller.setAttribute("defaultsubtitles", "");
19221
19222      const provider = document.createElement("youtube-media-provider");
19223      provider.setAttribute("slot", "media");
19224      provider.setAttribute("video-id", videoId);
19225      provider.setAttribute("start", start);
19226      provider.setAttribute("end", end);
19227
19228      const controlBar = document.createElement("media-control-bar");
19229
19230      const playBtn = document.createElement("media-play-button");
19231      const timeDisplay = document.createElement("media-time-display");
19232      timeDisplay.setAttribute("showduration", "");
19233      const timeRange = document.createElement("media-time-range");
19234      const playbackRate = document.createElement("media-playback-rate-button");
19235      const muteBtn = document.createElement("media-mute-button");
19236      const volumeRange = document.createElement("media-volume-range");
19237
19238      const captionsBtn = document.createElement("media-captions-button");
19239      captionsBtn.addEventListener("click", (e) => {
19240        e.stopPropagation();
19241        const provider = container.querySelector("youtube-media-provider");
19242        if (provider) provider.toggleCaptions();
19243      });
19244
19245      const qualityBtn = document.createElement("media-chrome-button");
19246      qualityBtn.textContent = "--";
19247      qualityBtn.addEventListener("click", () => {
19248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
19249      });
19250
19251      const fullscreenBtn = document.createElement("media-fullscreen-button");
19252
19253      controlBar.append(
19254        playBtn, timeDisplay, timeRange,
19255        playbackRate, muteBtn, volumeRange,
19256        captionsBtn, qualityBtn, fullscreenBtn
19257      );
19258
19259      const poster = document.createElement("media-poster-image");
19260      poster.setAttribute("slot", "poster");
19261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
19262
19263      controller.append(provider, poster, controlBar);
19264
19265      captionsBtn.setAttribute("aria-checked", "true");
19266      controller.setAttribute("mediasubtitlesshowing", "on");
19267
19268      container.appendChild(controller);
19269      details.appendChild(container);
19270
19271      provider.addEventListener("waiting", () => container.classList.add("loading"));
19272      provider.addEventListener("playing", () => container.classList.remove("loading"));
19273      provider.addEventListener("play", () => container.classList.remove("loading"));
19274
19275      provider._textTracks.addEventListener("change", () => {
19276        const enabled = provider._captionsEnabled;
19277        captionsBtn.setAttribute("aria-checked", enabled);
19278        if (enabled) {
19279          controller.setAttribute("mediasubtitlesshowing", "on");
19280        } else {
19281          controller.removeAttribute("mediasubtitlesshowing");
19282        }
19283      });
19284
19285      provider.addEventListener("qualitychange", (e) => {
19286        qualityBtn.textContent = e.detail.quality;
19287      });
19288    }
19289
19290    function initLazyPlayers() {
19291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
19292        if (details.dataset.lazyInit) return;
19293        details.dataset.lazyInit = "true";
19294        details.addEventListener("toggle", () => {
19295          if (details.open && !details.querySelector(".player-container")) {
19296            createPlayer(details);
19297          } else if (!details.open) {
19298            const provider = details.querySelector("youtube-media-provider");
19299            if (provider && typeof provider.pause === "function") {
19300              provider.pause();
19301            }
19302          }
19303        });
19304      });
19305    }
19306
19307    if (document.readyState === "loading") {
19308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
19309    } else {
19310      initLazyPlayers();
19311    }
19312  })();
19313</script>
19313<script>
19314  (() => {
19315    if (window.__courseCompletionsInitialized) return;
19316    window.__courseCompletionsInitialized = true;
19317
19318    const STORAGE_KEY = "course-completions";
19319
19320    function slugify(text) {
19321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19322    }
19323
19324    function getCompletions() {
19325      try {
19326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
19327      } catch {
19328        return [];
19329      }
19330    }
19331
19332    function saveCompletions(completions) {
19333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
19334    }
19335
19336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
19337    function canonicalFromStored(storedId) {
19338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19339      if (!m) return slugify(storedId);
19340      const [, slug, p, mv, title] = m;
19341      return `${slug}-${p}-${mv}-${slugify(title)}`;
19342    }
19343
19344    function canonicalPlusOne(storedId) {
19345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19346      if (!m) return null;
19347      const [, slug, p, mv, title] = m;
19348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
19349    }
19350
19351    function isCompleted(canonicalId, completions) {
19352      if (completions.includes(canonicalId)) return true;
19353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
19354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
19355    }
19356
19357    function migrateCompletions() {
19358      const completions = getCompletions();
19359      let changed = false;
19360      const migrated = completions.map((c) => {
19361        const canon = canonicalFromStored(c);
19362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
19363        const plusOne = canonicalPlusOne(c);
19364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
19365          if (plusOne !== c) changed = true;
19366          return plusOne;
19367        }
19368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
19369          changed = true;
19370          return canon;
19371        }
19372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
19373        if (canon !== c) {
19374          changed = true;
19375          return canon;
19376        }
19377        return c;
19378      });
19379      // Elimina duplicados tras migración
19380      const deduped = [...new Set(migrated)];
19381      if (changed || deduped.length !== completions.length) {
19382        saveCompletions(deduped);
19383        return deduped;
19384      }
19385      return completions;
19386    }
19387
19388    function updateButtonState(btn, completions) {
19389      const id = btn.dataset.completionId;
19390      const list = completions ?? getCompletions();
19391      btn.classList.toggle("completed", isCompleted(id, list));
19392    }
19393
19394    function checkModuleCompletion(completions) {
19395      const list = completions ?? getCompletions();
19396      document.querySelectorAll(".course-video").forEach((module) => {
19397        const btns = module.querySelectorAll(".complete-btn");
19398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
19399        const check = module.querySelector(".module-check");
19400        if (check) check.classList.toggle("visible", allCompleted);
19401      });
19402    }
19403
19404    function initCompletionButtons() {
19405      const completions = migrateCompletions();
19406
19407      document.querySelectorAll(".complete-btn").forEach((btn) => {
19408        if (btn.dataset.completionInit) return;
19409        btn.dataset.completionInit = "true";
19410        updateButtonState(btn, completions);
19411
19412        btn.addEventListener("click", (e) => {
19413          e.preventDefault();
19414          e.stopPropagation();
19415
19416          const id = btn.dataset.completionId;
19417          let list = getCompletions();
19418          const completed = isCompleted(id, list);
19419
19420          if (completed) {
19421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
19422            list = list.filter((c) =>
19422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
19423          } else {
19424            list.push(id);
19425          }
19426
19427          saveCompletions(list);
19428          updateButtonState(btn, list);
19429          checkModuleCompletion(list);
19430        });
19431      });
19432
19433      checkModuleCompletion(completions);
19434    }
19435
19436    if (document.readyState === "loading") {
19437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
19438    } else {
19439      initCompletionButtons();
19440    }
19441  })();
19442</script>
19442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="7660" data-end="8070" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Fondos e imágenes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-fondos-e-imagenes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
19442<script>
19443  (() => {
19444    if (window.__courseLazyPlayersInitialized) return;
19445    window.__courseLazyPlayersInitialized = true;
19446
19447    function createPlayer(details) {
19448      const videoId = details.dataset.videoId;
19449      const start = details.dataset.start;
19450      const end = details.dataset.end;
19451
19452      const container = document.createElement("div");
19453      container.className = "player-container";
19454
19455      const spinner = document.createElement("div");
19456      spinner.className = "spinner";
19457      container.appendChild(spinner);
19458
19459      const controller = document.createElement("media-controller");
19460      controller.setAttribute("defaultsubtitles", "");
19461
19462      const provider = document.createElement("youtube-media-provider");
19463      provider.setAttribute("slot", "media");
19464      provider.setAttribute("video-id", videoId);
19465      provider.setAttribute("start", start);
19466      provider.setAttribute("end", end);
19467
19468      const controlBar = document.createElement("media-control-bar");
19469
19470      const playBtn = document.createElement("media-play-button");
19471      const timeDisplay = document.createElement("media-time-display");
19472      timeDisplay.setAttribute("showduration", "");
19473      const timeRange = document.createElement("media-time-range");
19474      const playbackRate = document.createElement("media-playback-rate-button");
19475      const muteBtn = document.createElement("media-mute-button");
19476      const volumeRange = document.createElement("media-volume-range");
19477
19478      const captionsBtn = document.createElement("media-captions-button");
19479      captionsBtn.addEventListener("click", (e) => {
19480        e.stopPropagation();
19481        const provider = container.querySelector("youtube-media-provider");
19482        if (provider) provider.toggleCaptions();
19483      });
19484
19485      const qualityBtn = document.createElement("media-chrome-button");
19486      qualityBtn.textContent = "--";
19487      qualityBtn.addEventListener("click", () => {
19488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
19489      });
19490
19491      const fullscreenBtn = document.createElement("media-fullscreen-button");
19492
19493      controlBar.append(
19494        playBtn, timeDisplay, timeRange,
19495        playbackRate, muteBtn, volumeRange,
19496        captionsBtn, qualityBtn, fullscreenBtn
19497      );
19498
19499      const poster = document.createElement("media-poster-image");
19500      poster.setAttribute("slot", "poster");
19501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
19502
19503      controller.append(provider, poster, controlBar);
19504
19505      captionsBtn.setAttribute("aria-checked", "true");
19506      controller.setAttribute("mediasubtitlesshowing", "on");
19507
19508      container.appendChild(controller);
19509      details.appendChild(container);
19510
19511      provider.addEventListener("waiting", () => container.classList.add("loading"));
19512      provider.addEventListener("playing", () => container.classList.remove("loading"));
19513      provider.addEventListener("play", () => container.classList.remove("loading"));
19514
19515      provider._textTracks.addEventListener("change", () => {
19516        const enabled = provider._captionsEnabled;
19517        captionsBtn.setAttribute("aria-checked", enabled);
19518        if (enabled) {
19519          controller.setAttribute("mediasubtitlesshowing", "on");
19520        } else {
19521          controller.removeAttribute("mediasubtitlesshowing");
19522        }
19523      });
19524
19525      provider.addEventListener("qualitychange", (e) => {
19526        qualityBtn.textContent = e.detail.quality;
19527      });
19528    }
19529
19530    function initLazyPlayers() {
19531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
19532        if (details.dataset.lazyInit) return;
19533        details.dataset.lazyInit = "true";
19534        details.addEventListener("toggle", () => {
19535          if (details.open && !details.querySelector(".player-container")) {
19536            createPlayer(details);
19537          } else if (!details.open) {
19538            const provider = details.querySelector("youtube-media-provider");
19539            if (provider && typeof provider.pause === "function") {
19540              provider.pause();
19541            }
19542          }
19543        });
19544      });
19545    }
19546
19547    if (document.readyState === "loading") {
19548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
19549    } else {
19550      initLazyPlayers();
19551    }
19552  })();
19553</script>
19553<script>
19554  (() => {
19555    if (window.__courseCompletionsInitialized) return;
19556    window.__courseCompletionsInitialized = true;
19557
19558    const STORAGE_KEY = "course-completions";
19559
19560    function slugify(text) {
19561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19562    }
19563
19564    function getCompletions() {
19565      try {
19566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
19567      } catch {
19568        return [];
19569      }
19570    }
19571
19572    function saveCompletions(completions) {
19573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
19574    }
19575
19576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
19577    function canonicalFromStored(storedId) {
19578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19579      if (!m) return slugify(storedId);
19580      const [, slug, p, mv, title] = m;
19581      return `${slug}-${p}-${mv}-${slugify(title)}`;
19582    }
19583
19584    function canonicalPlusOne(storedId) {
19585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19586      if (!m) return null;
19587      const [, slug, p, mv, title] = m;
19588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
19589    }
19590
19591    function isCompleted(canonicalId, completions) {
19592      if (completions.includes(canonicalId)) return true;
19593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
19594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
19595    }
19596
19597    function migrateCompletions() {
19598      const completions = getCompletions();
19599      let changed = false;
19600      const migrated = completions.map((c) => {
19601        const canon = canonicalFromStored(c);
19602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
19603        const plusOne = canonicalPlusOne(c);
19604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
19605          if (plusOne !== c) changed = true;
19606          return plusOne;
19607        }
19608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
19609          changed = true;
19610          return canon;
19611        }
19612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
19613        if (canon !== c) {
19614          changed = true;
19615          return canon;
19616        }
19617        return c;
19618      });
19619      // Elimina duplicados tras migración
19620      const deduped = [...new Set(migrated)];
19621      if (changed || deduped.length !== completions.length) {
19622        saveCompletions(deduped);
19623        return deduped;
19624      }
19625      return completions;
19626    }
19627
19628    function updateButtonState(btn, completions) {
19629      const id = btn.dataset.completionId;
19630      const list = completions ?? getCompletions();
19631      btn.classList.toggle("completed", isCompleted(id, list));
19632    }
19633
19634    function checkModuleCompletion(completions) {
19635      const list = completions ?? getCompletions();
19636      document.querySelectorAll(".course-video").forEach((module) => {
19637        const btns = module.querySelectorAll(".complete-btn");
19638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
19639        const check = module.querySelector(".module-check");
19640        if (check) check.classList.toggle("visible", allCompleted);
19641      });
19642    }
19643
19644    function initCompletionButtons() {
19645      const completions = migrateCompletions();
19646
19647      document.querySelectorAll(".complete-btn").forEach((btn) => {
19648        if (btn.dataset.completionInit) return;
19649        btn.dataset.completionInit = "true";
19650        updateButtonState(btn, completions);
19651
19652        btn.addEventListener("click", (e) => {
19653          e.preventDefault();
19654          e.stopPropagation();
19655
19656          const id = btn.dataset.completionId;
19657          let list = getCompletions();
19658          const completed = isCompleted(id, list);
19659
19660          if (completed) {
19661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
19662            list = list.filter((c) =>
19662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
19663          } else {
19664            list.push(id);
19665          }
19666
19667          saveCompletions(list);
19668          updateButtonState(btn, list);
19669          checkModuleCompletion(list);
19670        });
19671      });
19672
19673      checkModuleCompletion(completions);
19674    }
19675
19676    if (document.readyState === "loading") {
19677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
19678    } else {
19679      initCompletionButtons();
19680    }
19681  })();
19682</script>
19682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="8070" data-end="9900" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Gradientes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>31 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-gradientes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
19682<script>
19683  (() => {
19684    if (window.__courseLazyPlayersInitialized) return;
19685    window.__courseLazyPlayersInitialized = true;
19686
19687    function createPlayer(details) {
19688      const videoId = details.dataset.videoId;
19689      const start = details.dataset.start;
19690      const end = details.dataset.end;
19691
19692      const container = document.createElement("div");
19693      container.className = "player-container";
19694
19695      const spinner = document.createElement("div");
19696      spinner.className = "spinner";
19697      container.appendChild(spinner);
19698
19699      const controller = document.createElement("media-controller");
19700      controller.setAttribute("defaultsubtitles", "");
19701
19702      const provider = document.createElement("youtube-media-provider");
19703      provider.setAttribute("slot", "media");
19704      provider.setAttribute("video-id", videoId);
19705      provider.setAttribute("start", start);
19706      provider.setAttribute("end", end);
19707
19708      const controlBar = document.createElement("media-control-bar");
19709
19710      const playBtn = document.createElement("media-play-button");
19711      const timeDisplay = document.createElement("media-time-display");
19712      timeDisplay.setAttribute("showduration", "");
19713      const timeRange = document.createElement("media-time-range");
19714      const playbackRate = document.createElement("media-playback-rate-button");
19715      const muteBtn = document.createElement("media-mute-button");
19716      const volumeRange = document.createElement("media-volume-range");
19717
19718      const captionsBtn = document.createElement("media-captions-button");
19719      captionsBtn.addEventListener("click", (e) => {
19720        e.stopPropagation();
19721        const provider = container.querySelector("youtube-media-provider");
19722        if (provider) provider.toggleCaptions();
19723      });
19724
19725      const qualityBtn = document.createElement("media-chrome-button");
19726      qualityBtn.textContent = "--";
19727      qualityBtn.addEventListener("click", () => {
19728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
19729      });
19730
19731      const fullscreenBtn = document.createElement("media-fullscreen-button");
19732
19733      controlBar.append(
19734        playBtn, timeDisplay, timeRange,
19735        playbackRate, muteBtn, volumeRange,
19736        captionsBtn, qualityBtn, fullscreenBtn
19737      );
19738
19739      const poster = document.createElement("media-poster-image");
19740      poster.setAttribute("slot", "poster");
19741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
19742
19743      controller.append(provider, poster, controlBar);
19744
19745      captionsBtn.setAttribute("aria-checked", "true");
19746      controller.setAttribute("mediasubtitlesshowing", "on");
19747
19748      container.appendChild(controller);
19749      details.appendChild(container);
19750
19751      provider.addEventListener("waiting", () => container.classList.add("loading"));
19752      provider.addEventListener("playing", () => container.classList.remove("loading"));
19753      provider.addEventListener("play", () => container.classList.remove("loading"));
19754
19755      provider._textTracks.addEventListener("change", () => {
19756        const enabled = provider._captionsEnabled;
19757        captionsBtn.setAttribute("aria-checked", enabled);
19758        if (enabled) {
19759          controller.setAttribute("mediasubtitlesshowing", "on");
19760        } else {
19761          controller.removeAttribute("mediasubtitlesshowing");
19762        }
19763      });
19764
19765      provider.addEventListener("qualitychange", (e) => {
19766        qualityBtn.textContent = e.detail.quality;
19767      });
19768    }
19769
19770    function initLazyPlayers() {
19771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
19772        if (details.dataset.lazyInit) return;
19773        details.dataset.lazyInit = "true";
19774        details.addEventListener("toggle", () => {
19775          if (details.open && !details.querySelector(".player-container")) {
19776            createPlayer(details);
19777          } else if (!details.open) {
19778            const provider = details.querySelector("youtube-media-provider");
19779            if (provider && typeof provider.pause === "function") {
19780              provider.pause();
19781            }
19782          }
19783        });
19784      });
19785    }
19786
19787    if (document.readyState === "loading") {
19788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
19789    } else {
19790      initLazyPlayers();
19791    }
19792  })();
19793</script>
19793<script>
19794  (() => {
19795    if (window.__courseCompletionsInitialized) return;
19796    window.__courseCompletionsInitialized = true;
19797
19798    const STORAGE_KEY = "course-completions";
19799
19800    function slugify(text) {
19801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19802    }
19803
19804    function getCompletions() {
19805      try {
19806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
19807      } catch {
19808        return [];
19809      }
19810    }
19811
19812    function saveCompletions(completions) {
19813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
19814    }
19815
19816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
19817    function canonicalFromStored(storedId) {
19818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19819      if (!m) return slugify(storedId);
19820      const [, slug, p, mv, title] = m;
19821      return `${slug}-${p}-${mv}-${slugify(title)}`;
19822    }
19823
19824    function canonicalPlusOne(storedId) {
19825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
19826      if (!m) return null;
19827      const [, slug, p, mv, title] = m;
19828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
19829    }
19830
19831    function isCompleted(canonicalId, completions) {
19832      if (completions.includes(canonicalId)) return true;
19833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
19834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
19835    }
19836
19837    function migrateCompletions() {
19838      const completions = getCompletions();
19839      let changed = false;
19840      const migrated = completions.map((c) => {
19841        const canon = canonicalFromStored(c);
19842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
19843        const plusOne = canonicalPlusOne(c);
19844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
19845          if (plusOne !== c) changed = true;
19846          return plusOne;
19847        }
19848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
19849          changed = true;
19850          return canon;
19851        }
19852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
19853        if (canon !== c) {
19854          changed = true;
19855          return canon;
19856        }
19857        return c;
19858      });
19859      // Elimina duplicados tras migración
19860      const deduped = [...new Set(migrated)];
19861      if (changed || deduped.length !== completions.length) {
19862        saveCompletions(deduped);
19863        return deduped;
19864      }
19865      return completions;
19866    }
19867
19868    function updateButtonState(btn, completions) {
19869      const id = btn.dataset.completionId;
19870      const list = completions ?? getCompletions();
19871      btn.classList.toggle("completed", isCompleted(id, list));
19872    }
19873
19874    function checkModuleCompletion(completions) {
19875      const list = completions ?? getCompletions();
19876      document.querySelectorAll(".course-video").forEach((module) => {
19877        const btns = module.querySelectorAll(".complete-btn");
19878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
19879        const check = module.querySelector(".module-check");
19880        if (check) check.classList.toggle("visible", allCompleted);
19881      });
19882    }
19883
19884    function initCompletionButtons() {
19885      const completions = migrateCompletions();
19886
19887      document.querySelectorAll(".complete-btn").forEach((btn) => {
19888        if (btn.dataset.completionInit) return;
19889        btn.dataset.completionInit = "true";
19890        updateButtonState(btn, completions);
19891
19892        btn.addEventListener("click", (e) => {
19893          e.preventDefault();
19894          e.stopPropagation();
19895
19896          const id = btn.dataset.completionId;
19897          let list = getCompletions();
19898          const completed = isCompleted(id, list);
19899
19900          if (completed) {
19901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
19902            list = list.filter((c) =>
19902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
19903          } else {
19904            list.push(id);
19905          }
19906
19907          saveCompletions(list);
19908          updateButtonState(btn, list);
19909          checkModuleCompletion(list);
19910        });
19911      });
19912
19913      checkModuleCompletion(completions);
19914    }
19915
19916    if (document.readyState === "loading") {
19917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
19918    } else {
19919      initCompletionButtons();
19920    }
19921  })();
19922</script>
19922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="9900" data-end="10420" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Tipografías</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-tipografias" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
19922<script>
19923  (() => {
19924    if (window.__courseLazyPlayersInitialized) return;
19925    window.__courseLazyPlayersInitialized = true;
19926
19927    function createPlayer(details) {
19928      const videoId = details.dataset.videoId;
19929      const start = details.dataset.start;
19930      const end = details.dataset.end;
19931
19932      const container = document.createElement("div");
19933      container.className = "player-container";
19934
19935      const spinner = document.createElement("div");
19936      spinner.className = "spinner";
19937      container.appendChild(spinner);
19938
19939      const controller = document.createElement("media-controller");
19940      controller.setAttribute("defaultsubtitles", "");
19941
19942      const provider = document.createElement("youtube-media-provider");
19943      provider.setAttribute("slot", "media");
19944      provider.setAttribute("video-id", videoId);
19945      provider.setAttribute("start", start);
19946      provider.setAttribute("end", end);
19947
19948      const controlBar = document.createElement("media-control-bar");
19949
19950      const playBtn = document.createElement("media-play-button");
19951      const timeDisplay = document.createElement("media-time-display");
19952      timeDisplay.setAttribute("showduration", "");
19953      const timeRange = document.createElement("media-time-range");
19954      const playbackRate = document.createElement("media-playback-rate-button");
19955      const muteBtn = document.createElement("media-mute-button");
19956      const volumeRange = document.createElement("media-volume-range");
19957
19958      const captionsBtn = document.createElement("media-captions-button");
19959      captionsBtn.addEventListener("click", (e) => {
19960        e.stopPropagation();
19961        const provider = container.querySelector("youtube-media-provider");
19962        if (provider) provider.toggleCaptions();
19963      });
19964
19965      const qualityBtn = document.createElement("media-chrome-button");
19966      qualityBtn.textContent = "--";
19967      qualityBtn.addEventListener("click", () => {
19968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
19969      });
19970
19971      const fullscreenBtn = document.createElement("media-fullscreen-button");
19972
19973      controlBar.append(
19974        playBtn, timeDisplay, timeRange,
19975        playbackRate, muteBtn, volumeRange,
19976        captionsBtn, qualityBtn, fullscreenBtn
19977      );
19978
19979      const poster = document.createElement("media-poster-image");
19980      poster.setAttribute("slot", "poster");
19981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
19982
19983      controller.append(provider, poster, controlBar);
19984
19985      captionsBtn.setAttribute("aria-checked", "true");
19986      controller.setAttribute("mediasubtitlesshowing", "on");
19987
19988      container.appendChild(controller);
19989      details.appendChild(container);
19990
19991      provider.addEventListener("waiting", () => container.classList.add("loading"));
19992      provider.addEventListener("playing", () => container.classList.remove("loading"));
19993      provider.addEventListener("play", () => container.classList.remove("loading"));
19994
19995      provider._textTracks.addEventListener("change", () => {
19996        const enabled = provider._captionsEnabled;
19997        captionsBtn.setAttribute("aria-checked", enabled);
19998        if (enabled) {
19999          controller.setAttribute("mediasubtitlesshowing", "on");
20000        } else {
20001          controller.removeAttribute("mediasubtitlesshowing");
20002        }
20003      });
20004
20005      provider.addEventListener("qualitychange", (e) => {
20006        qualityBtn.textContent = e.detail.quality;
20007      });
20008    }
20009
20010    function initLazyPlayers() {
20011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
20012        if (details.dataset.lazyInit) return;
20013        details.dataset.lazyInit = "true";
20014        details.addEventListener("toggle", () => {
20015          if (details.open && !details.querySelector(".player-container")) {
20016            createPlayer(details);
20017          } else if (!details.open) {
20018            const provider = details.querySelector("youtube-media-provider");
20019            if (provider && typeof provider.pause === "function") {
20020              provider.pause();
20021            }
20022          }
20023        });
20024      });
20025    }
20026
20027    if (document.readyState === "loading") {
20028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
20029    } else {
20030      initLazyPlayers();
20031    }
20032  })();
20033</script>
20033<script>
20034  (() => {
20035    if (window.__courseCompletionsInitialized) return;
20036    window.__courseCompletionsInitialized = true;
20037
20038    const STORAGE_KEY = "course-completions";
20039
20040    function slugify(text) {
20041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
20042    }
20043
20044    function getCompletions() {
20045      try {
20046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
20047      } catch {
20048        return [];
20049      }
20050    }
20051
20052    function saveCompletions(completions) {
20053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
20054    }
20055
20056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
20057    function canonicalFromStored(storedId) {
20058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20059      if (!m) return slugify(storedId);
20060      const [, slug, p, mv, title] = m;
20061      return `${slug}-${p}-${mv}-${slugify(title)}`;
20062    }
20063
20064    function canonicalPlusOne(storedId) {
20065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20066      if (!m) return null;
20067      const [, slug, p, mv, title] = m;
20068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
20069    }
20070
20071    function isCompleted(canonicalId, completions) {
20072      if (completions.includes(canonicalId)) return true;
20073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
20074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
20075    }
20076
20077    function migrateCompletions() {
20078      const completions = getCompletions();
20079      let changed = false;
20080      const migrated = completions.map((c) => {
20081        const canon = canonicalFromStored(c);
20082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
20083        const plusOne = canonicalPlusOne(c);
20084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
20085          if (plusOne !== c) changed = true;
20086          return plusOne;
20087        }
20088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
20089          changed = true;
20090          return canon;
20091        }
20092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
20093        if (canon !== c) {
20094          changed = true;
20095          return canon;
20096        }
20097        return c;
20098      });
20099      // Elimina duplicados tras migración
20100      const deduped = [...new Set(migrated)];
20101      if (changed || deduped.length !== completions.length) {
20102        saveCompletions(deduped);
20103        return deduped;
20104      }
20105      return completions;
20106    }
20107
20108    function updateButtonState(btn, completions) {
20109      const id = btn.dataset.completionId;
20110      const list = completions ?? getCompletions();
20111      btn.classList.toggle("completed", isCompleted(id, list));
20112    }
20113
20114    function checkModuleCompletion(completions) {
20115      const list = completions ?? getCompletions();
20116      document.querySelectorAll(".course-video").forEach((module) => {
20117        const btns = module.querySelectorAll(".complete-btn");
20118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
20119        const check = module.querySelector(".module-check");
20120        if (check) check.classList.toggle("visible", allCompleted);
20121      });
20122    }
20123
20124    function initCompletionButtons() {
20125      const completions = migrateCompletions();
20126
20127      document.querySelectorAll(".complete-btn").forEach((btn) => {
20128        if (btn.dataset.completionInit) return;
20129        btn.dataset.completionInit = "true";
20130        updateButtonState(btn, completions);
20131
20132        btn.addEventListener("click", (e) => {
20133          e.preventDefault();
20134          e.stopPropagation();
20135
20136          const id = btn.dataset.completionId;
20137          let list = getCompletions();
20138          const completed = isCompleted(id, list);
20139
20140          if (completed) {
20141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
20142            list = list.filter((c) =>
20142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
20143          } else {
20144            list.push(id);
20145          }
20146
20147          saveCompletions(list);
20148          updateButtonState(btn, list);
20149          checkModuleCompletion(list);
20150        });
20151      });
20152
20153      checkModuleCompletion(completions);
20154    }
20155
20156    if (document.readyState === "loading") {
20157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
20158    } else {
20159      initCompletionButtons();
20160    }
20161  })();
20162</script>
20162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="10420" data-end="12030" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>14. Cargar tipografías</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>27 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-cargar-tipografias" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
20162<script>
20163  (() => {
20164    if (window.__courseLazyPlayersInitialized) return;
20165    window.__courseLazyPlayersInitialized = true;
20166
20167    function createPlayer(details) {
20168      const videoId = details.dataset.videoId;
20169      const start = details.dataset.start;
20170      const end = details.dataset.end;
20171
20172      const container = document.createElement("div");
20173      container.className = "player-container";
20174
20175      const spinner = document.createElement("div");
20176      spinner.className = "spinner";
20177      container.appendChild(spinner);
20178
20179      const controller = document.createElement("media-controller");
20180      controller.setAttribute("defaultsubtitles", "");
20181
20182      const provider = document.createElement("youtube-media-provider");
20183      provider.setAttribute("slot", "media");
20184      provider.setAttribute("video-id", videoId);
20185      provider.setAttribute("start", start);
20186      provider.setAttribute("end", end);
20187
20188      const controlBar = document.createElement("media-control-bar");
20189
20190      const playBtn = document.createElement("media-play-button");
20191      const timeDisplay = document.createElement("media-time-display");
20192      timeDisplay.setAttribute("showduration", "");
20193      const timeRange = document.createElement("media-time-range");
20194      const playbackRate = document.createElement("media-playback-rate-button");
20195      const muteBtn = document.createElement("media-mute-button");
20196      const volumeRange = document.createElement("media-volume-range");
20197
20198      const captionsBtn = document.createElement("media-captions-button");
20199      captionsBtn.addEventListener("click", (e) => {
20200        e.stopPropagation();
20201        const provider = container.querySelector("youtube-media-provider");
20202        if (provider) provider.toggleCaptions();
20203      });
20204
20205      const qualityBtn = document.createElement("media-chrome-button");
20206      qualityBtn.textContent = "--";
20207      qualityBtn.addEventListener("click", () => {
20208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
20209      });
20210
20211      const fullscreenBtn = document.createElement("media-fullscreen-button");
20212
20213      controlBar.append(
20214        playBtn, timeDisplay, timeRange,
20215        playbackRate, muteBtn, volumeRange,
20216        captionsBtn, qualityBtn, fullscreenBtn
20217      );
20218
20219      const poster = document.createElement("media-poster-image");
20220      poster.setAttribute("slot", "poster");
20221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
20222
20223      controller.append(provider, poster, controlBar);
20224
20225      captionsBtn.setAttribute("aria-checked", "true");
20226      controller.setAttribute("mediasubtitlesshowing", "on");
20227
20228      container.appendChild(controller);
20229      details.appendChild(container);
20230
20231      provider.addEventListener("waiting", () => container.classList.add("loading"));
20232      provider.addEventListener("playing", () => container.classList.remove("loading"));
20233      provider.addEventListener("play", () => container.classList.remove("loading"));
20234
20235      provider._textTracks.addEventListener("change", () => {
20236        const enabled = provider._captionsEnabled;
20237        captionsBtn.setAttribute("aria-checked", enabled);
20238        if (enabled) {
20239          controller.setAttribute("mediasubtitlesshowing", "on");
20240        } else {
20241          controller.removeAttribute("mediasubtitlesshowing");
20242        }
20243      });
20244
20245      provider.addEventListener("qualitychange", (e) => {
20246        qualityBtn.textContent = e.detail.quality;
20247      });
20248    }
20249
20250    function initLazyPlayers() {
20251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
20252        if (details.dataset.lazyInit) return;
20253        details.dataset.lazyInit = "true";
20254        details.addEventListener("toggle", () => {
20255          if (details.open && !details.querySelector(".player-container")) {
20256            createPlayer(details);
20257          } else if (!details.open) {
20258            const provider = details.querySelector("youtube-media-provider");
20259            if (provider && typeof provider.pause === "function") {
20260              provider.pause();
20261            }
20262          }
20263        });
20264      });
20265    }
20266
20267    if (document.readyState === "loading") {
20268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
20269    } else {
20270      initLazyPlayers();
20271    }
20272  })();
20273</script>
20273<script>
20274  (() => {
20275    if (window.__courseCompletionsInitialized) return;
20276    window.__courseCompletionsInitialized = true;
20277
20278    const STORAGE_KEY = "course-completions";
20279
20280    function slugify(text) {
20281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
20282    }
20283
20284    function getCompletions() {
20285      try {
20286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
20287      } catch {
20288        return [];
20289      }
20290    }
20291
20292    function saveCompletions(completions) {
20293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
20294    }
20295
20296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
20297    function canonicalFromStored(storedId) {
20298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20299      if (!m) return slugify(storedId);
20300      const [, slug, p, mv, title] = m;
20301      return `${slug}-${p}-${mv}-${slugify(title)}`;
20302    }
20303
20304    function canonicalPlusOne(storedId) {
20305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20306      if (!m) return null;
20307      const [, slug, p, mv, title] = m;
20308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
20309    }
20310
20311    function isCompleted(canonicalId, completions) {
20312      if (completions.includes(canonicalId)) return true;
20313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
20314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
20315    }
20316
20317    function migrateCompletions() {
20318      const completions = getCompletions();
20319      let changed = false;
20320      const migrated = completions.map((c) => {
20321        const canon = canonicalFromStored(c);
20322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
20323        const plusOne = canonicalPlusOne(c);
20324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
20325          if (plusOne !== c) changed = true;
20326          return plusOne;
20327        }
20328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
20329          changed = true;
20330          return canon;
20331        }
20332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
20333        if (canon !== c) {
20334          changed = true;
20335          return canon;
20336        }
20337        return c;
20338      });
20339      // Elimina duplicados tras migración
20340      const deduped = [...new Set(migrated)];
20341      if (changed || deduped.length !== completions.length) {
20342        saveCompletions(deduped);
20343        return deduped;
20344      }
20345      return completions;
20346    }
20347
20348    function updateButtonState(btn, completions) {
20349      const id = btn.dataset.completionId;
20350      const list = completions ?? getCompletions();
20351      btn.classList.toggle("completed", isCompleted(id, list));
20352    }
20353
20354    function checkModuleCompletion(completions) {
20355      const list = completions ?? getCompletions();
20356      document.querySelectorAll(".course-video").forEach((module) => {
20357        const btns = module.querySelectorAll(".complete-btn");
20358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
20359        const check = module.querySelector(".module-check");
20360        if (check) check.classList.toggle("visible", allCompleted);
20361      });
20362    }
20363
20364    function initCompletionButtons() {
20365      const completions = migrateCompletions();
20366
20367      document.querySelectorAll(".complete-btn").forEach((btn) => {
20368        if (btn.dataset.completionInit) return;
20369        btn.dataset.completionInit = "true";
20370        updateButtonState(btn, completions);
20371
20372        btn.addEventListener("click", (e) => {
20373          e.preventDefault();
20374          e.stopPropagation();
20375
20376          const id = btn.dataset.completionId;
20377          let list = getCompletions();
20378          const completed = isCompleted(id, list);
20379
20380          if (completed) {
20381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
20382            list = list.filter((c) =>
20382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
20383          } else {
20384            list.push(id);
20385          }
20386
20387          saveCompletions(list);
20388          updateButtonState(btn, list);
20389          checkModuleCompletion(list);
20390        });
20391      });
20392
20393      checkModuleCompletion(completions);
20394    }
20395
20396    if (document.readyState === "loading") {
20397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
20398    } else {
20399      initCompletionButtons();
20400    }
20401  })();
20402</script>
20402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="12030" data-end="13220" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>15. Selectores CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>20 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-selectores-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
20402<script>
20403  (() => {
20404    if (window.__courseLazyPlayersInitialized) return;
20405    window.__courseLazyPlayersInitialized = true;
20406
20407    function createPlayer(details) {
20408      const videoId = details.dataset.videoId;
20409      const start = details.dataset.start;
20410      const end = details.dataset.end;
20411
20412      const container = document.createElement("div");
20413      container.className = "player-container";
20414
20415      const spinner = document.createElement("div");
20416      spinner.className = "spinner";
20417      container.appendChild(spinner);
20418
20419      const controller = document.createElement("media-controller");
20420      controller.setAttribute("defaultsubtitles", "");
20421
20422      const provider = document.createElement("youtube-media-provider");
20423      provider.setAttribute("slot", "media");
20424      provider.setAttribute("video-id", videoId);
20425      provider.setAttribute("start", start);
20426      provider.setAttribute("end", end);
20427
20428      const controlBar = document.createElement("media-control-bar");
20429
20430      const playBtn = document.createElement("media-play-button");
20431      const timeDisplay = document.createElement("media-time-display");
20432      timeDisplay.setAttribute("showduration", "");
20433      const timeRange = document.createElement("media-time-range");
20434      const playbackRate = document.createElement("media-playback-rate-button");
20435      const muteBtn = document.createElement("media-mute-button");
20436      const volumeRange = document.createElement("media-volume-range");
20437
20438      const captionsBtn = document.createElement("media-captions-button");
20439      captionsBtn.addEventListener("click", (e) => {
20440        e.stopPropagation();
20441        const provider = container.querySelector("youtube-media-provider");
20442        if (provider) provider.toggleCaptions();
20443      });
20444
20445      const qualityBtn = document.createElement("media-chrome-button");
20446      qualityBtn.textContent = "--";
20447      qualityBtn.addEventListener("click", () => {
20448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
20449      });
20450
20451      const fullscreenBtn = document.createElement("media-fullscreen-button");
20452
20453      controlBar.append(
20454        playBtn, timeDisplay, timeRange,
20455        playbackRate, muteBtn, volumeRange,
20456        captionsBtn, qualityBtn, fullscreenBtn
20457      );
20458
20459      const poster = document.createElement("media-poster-image");
20460      poster.setAttribute("slot", "poster");
20461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
20462
20463      controller.append(provider, poster, controlBar);
20464
20465      captionsBtn.setAttribute("aria-checked", "true");
20466      controller.setAttribute("mediasubtitlesshowing", "on");
20467
20468      container.appendChild(controller);
20469      details.appendChild(container);
20470
20471      provider.addEventListener("waiting", () => container.classList.add("loading"));
20472      provider.addEventListener("playing", () => container.classList.remove("loading"));
20473      provider.addEventListener("play", () => container.classList.remove("loading"));
20474
20475      provider._textTracks.addEventListener("change", () => {
20476        const enabled = provider._captionsEnabled;
20477        captionsBtn.setAttribute("aria-checked", enabled);
20478        if (enabled) {
20479          controller.setAttribute("mediasubtitlesshowing", "on");
20480        } else {
20481          controller.removeAttribute("mediasubtitlesshowing");
20482        }
20483      });
20484
20485      provider.addEventListener("qualitychange", (e) => {
20486        qualityBtn.textContent = e.detail.quality;
20487      });
20488    }
20489
20490    function initLazyPlayers() {
20491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
20492        if (details.dataset.lazyInit) return;
20493        details.dataset.lazyInit = "true";
20494        details.addEventListener("toggle", () => {
20495          if (details.open && !details.querySelector(".player-container")) {
20496            createPlayer(details);
20497          } else if (!details.open) {
20498            const provider = details.querySelector("youtube-media-provider");
20499            if (provider && typeof provider.pause === "function") {
20500              provider.pause();
20501            }
20502          }
20503        });
20504      });
20505    }
20506
20507    if (document.readyState === "loading") {
20508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
20509    } else {
20510      initLazyPlayers();
20511    }
20512  })();
20513</script>
20513<script>
20514  (() => {
20515    if (window.__courseCompletionsInitialized) return;
20516    window.__courseCompletionsInitialized = true;
20517
20518    const STORAGE_KEY = "course-completions";
20519
20520    function slugify(text) {
20521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
20522    }
20523
20524    function getCompletions() {
20525      try {
20526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
20527      } catch {
20528        return [];
20529      }
20530    }
20531
20532    function saveCompletions(completions) {
20533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
20534    }
20535
20536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
20537    function canonicalFromStored(storedId) {
20538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20539      if (!m) return slugify(storedId);
20540      const [, slug, p, mv, title] = m;
20541      return `${slug}-${p}-${mv}-${slugify(title)}`;
20542    }
20543
20544    function canonicalPlusOne(storedId) {
20545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20546      if (!m) return null;
20547      const [, slug, p, mv, title] = m;
20548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
20549    }
20550
20551    function isCompleted(canonicalId, completions) {
20552      if (completions.includes(canonicalId)) return true;
20553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
20554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
20555    }
20556
20557    function migrateCompletions() {
20558      const completions = getCompletions();
20559      let changed = false;
20560      const migrated = completions.map((c) => {
20561        const canon = canonicalFromStored(c);
20562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
20563        const plusOne = canonicalPlusOne(c);
20564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
20565          if (plusOne !== c) changed = true;
20566          return plusOne;
20567        }
20568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
20569          changed = true;
20570          return canon;
20571        }
20572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
20573        if (canon !== c) {
20574          changed = true;
20575          return canon;
20576        }
20577        return c;
20578      });
20579      // Elimina duplicados tras migración
20580      const deduped = [...new Set(migrated)];
20581      if (changed || deduped.length !== completions.length) {
20582        saveCompletions(deduped);
20583        return deduped;
20584      }
20585      return completions;
20586    }
20587
20588    function updateButtonState(btn, completions) {
20589      const id = btn.dataset.completionId;
20590      const list = completions ?? getCompletions();
20591      btn.classList.toggle("completed", isCompleted(id, list));
20592    }
20593
20594    function checkModuleCompletion(completions) {
20595      const list = completions ?? getCompletions();
20596      document.querySelectorAll(".course-video").forEach((module) => {
20597        const btns = module.querySelectorAll(".complete-btn");
20598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
20599        const check = module.querySelector(".module-check");
20600        if (check) check.classList.toggle("visible", allCompleted);
20601      });
20602    }
20603
20604    function initCompletionButtons() {
20605      const completions = migrateCompletions();
20606
20607      document.querySelectorAll(".complete-btn").forEach((btn) => {
20608        if (btn.dataset.completionInit) return;
20609        btn.dataset.completionInit = "true";
20610        updateButtonState(btn, completions);
20611
20612        btn.addEventListener("click", (e) => {
20613          e.preventDefault();
20614          e.stopPropagation();
20615
20616          const id = btn.dataset.completionId;
20617          let list = getCompletions();
20618          const completed = isCompleted(id, list);
20619
20620          if (completed) {
20621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
20622            list = list.filter((c) =>
20622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
20623          } else {
20624            list.push(id);
20625          }
20626
20627          saveCompletions(list);
20628          updateButtonState(btn, list);
20629          checkModuleCompletion(list);
20630        });
20631      });
20632
20633      checkModuleCompletion(completions);
20634    }
20635
20636    if (document.readyState === "loading") {
20637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
20638    } else {
20639      initCompletionButtons();
20640    }
20641  })();
20642</script>
20642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="13220" data-end="13933" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>16. Pseudoselectores CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>12 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-pseudoselectores-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
20642<script>
20643  (() => {
20644    if (window.__courseLazyPlayersInitialized) return;
20645    window.__courseLazyPlayersInitialized = true;
20646
20647    function createPlayer(details) {
20648      const videoId = details.dataset.videoId;
20649      const start = details.dataset.start;
20650      const end = details.dataset.end;
20651
20652      const container = document.createElement("div");
20653      container.className = "player-container";
20654
20655      const spinner = document.createElement("div");
20656      spinner.className = "spinner";
20657      container.appendChild(spinner);
20658
20659      const controller = document.createElement("media-controller");
20660      controller.setAttribute("defaultsubtitles", "");
20661
20662      const provider = document.createElement("youtube-media-provider");
20663      provider.setAttribute("slot", "media");
20664      provider.setAttribute("video-id", videoId);
20665      provider.setAttribute("start", start);
20666      provider.setAttribute("end", end);
20667
20668      const controlBar = document.createElement("media-control-bar");
20669
20670      const playBtn = document.createElement("media-play-button");
20671      const timeDisplay = document.createElement("media-time-display");
20672      timeDisplay.setAttribute("showduration", "");
20673      const timeRange = document.createElement("media-time-range");
20674      const playbackRate = document.createElement("media-playback-rate-button");
20675      const muteBtn = document.createElement("media-mute-button");
20676      const volumeRange = document.createElement("media-volume-range");
20677
20678      const captionsBtn = document.createElement("media-captions-button");
20679      captionsBtn.addEventListener("click", (e) => {
20680        e.stopPropagation();
20681        const provider = container.querySelector("youtube-media-provider");
20682        if (provider) provider.toggleCaptions();
20683      });
20684
20685      const qualityBtn = document.createElement("media-chrome-button");
20686      qualityBtn.textContent = "--";
20687      qualityBtn.addEventListener("click", () => {
20688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
20689      });
20690
20691      const fullscreenBtn = document.createElement("media-fullscreen-button");
20692
20693      controlBar.append(
20694        playBtn, timeDisplay, timeRange,
20695        playbackRate, muteBtn, volumeRange,
20696        captionsBtn, qualityBtn, fullscreenBtn
20697      );
20698
20699      const poster = document.createElement("media-poster-image");
20700      poster.setAttribute("slot", "poster");
20701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
20702
20703      controller.append(provider, poster, controlBar);
20704
20705      captionsBtn.setAttribute("aria-checked", "true");
20706      controller.setAttribute("mediasubtitlesshowing", "on");
20707
20708      container.appendChild(controller);
20709      details.appendChild(container);
20710
20711      provider.addEventListener("waiting", () => container.classList.add("loading"));
20712      provider.addEventListener("playing", () => container.classList.remove("loading"));
20713      provider.addEventListener("play", () => container.classList.remove("loading"));
20714
20715      provider._textTracks.addEventListener("change", () => {
20716        const enabled = provider._captionsEnabled;
20717        captionsBtn.setAttribute("aria-checked", enabled);
20718        if (enabled) {
20719          controller.setAttribute("mediasubtitlesshowing", "on");
20720        } else {
20721          controller.removeAttribute("mediasubtitlesshowing");
20722        }
20723      });
20724
20725      provider.addEventListener("qualitychange", (e) => {
20726        qualityBtn.textContent = e.detail.quality;
20727      });
20728    }
20729
20730    function initLazyPlayers() {
20731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
20732        if (details.dataset.lazyInit) return;
20733        details.dataset.lazyInit = "true";
20734        details.addEventListener("toggle", () => {
20735          if (details.open && !details.querySelector(".player-container")) {
20736            createPlayer(details);
20737          } else if (!details.open) {
20738            const provider = details.querySelector("youtube-media-provider");
20739            if (provider && typeof provider.pause === "function") {
20740              provider.pause();
20741            }
20742          }
20743        });
20744      });
20745    }
20746
20747    if (document.readyState === "loading") {
20748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
20749    } else {
20750      initLazyPlayers();
20751    }
20752  })();
20753</script>
20753<script>
20754  (() => {
20755    if (window.__courseCompletionsInitialized) return;
20756    window.__courseCompletionsInitialized = true;
20757
20758    const STORAGE_KEY = "course-completions";
20759
20760    function slugify(text) {
20761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
20762    }
20763
20764    function getCompletions() {
20765      try {
20766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
20767      } catch {
20768        return [];
20769      }
20770    }
20771
20772    function saveCompletions(completions) {
20773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
20774    }
20775
20776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
20777    function canonicalFromStored(storedId) {
20778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20779      if (!m) return slugify(storedId);
20780      const [, slug, p, mv, title] = m;
20781      return `${slug}-${p}-${mv}-${slugify(title)}`;
20782    }
20783
20784    function canonicalPlusOne(storedId) {
20785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
20786      if (!m) return null;
20787      const [, slug, p, mv, title] = m;
20788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
20789    }
20790
20791    function isCompleted(canonicalId, completions) {
20792      if (completions.includes(canonicalId)) return true;
20793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
20794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
20795    }
20796
20797    function migrateCompletions() {
20798      const completions = getCompletions();
20799      let changed = false;
20800      const migrated = completions.map((c) => {
20801        const canon = canonicalFromStored(c);
20802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
20803        const plusOne = canonicalPlusOne(c);
20804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
20805          if (plusOne !== c) changed = true;
20806          return plusOne;
20807        }
20808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
20809          changed = true;
20810          return canon;
20811        }
20812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
20813        if (canon !== c) {
20814          changed = true;
20815          return canon;
20816        }
20817        return c;
20818      });
20819      // Elimina duplicados tras migración
20820      const deduped = [...new Set(migrated)];
20821      if (changed || deduped.length !== completions.length) {
20822        saveCompletions(deduped);
20823        return deduped;
20824      }
20825      return completions;
20826    }
20827
20828    function updateButtonState(btn, completions) {
20829      const id = btn.dataset.completionId;
20830      const list = completions ?? getCompletions();
20831      btn.classList.toggle("completed", isCompleted(id, list));
20832    }
20833
20834    function checkModuleCompletion(completions) {
20835      const list = completions ?? getCompletions();
20836      document.querySelectorAll(".course-video").forEach((module) => {
20837        const btns = module.querySelectorAll(".complete-btn");
20838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
20839        const check = module.querySelector(".module-check");
20840        if (check) check.classList.toggle("visible", allCompleted);
20841      });
20842    }
20843
20844    function initCompletionButtons() {
20845      const completions = migrateCompletions();
20846
20847      document.querySelectorAll(".complete-btn").forEach((btn) => {
20848        if (btn.dataset.completionInit) return;
20849        btn.dataset.completionInit = "true";
20850        updateButtonState(btn, completions);
20851
20852        btn.addEventListener("click", (e) => {
20853          e.preventDefault();
20854          e.stopPropagation();
20855
20856          const id = btn.dataset.completionId;
20857          let list = getCompletions();
20858          const completed = isCompleted(id, list);
20859
20860          if (completed) {
20861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
20862            list = list.filter((c) =>
20862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
20863          } else {
20864            list.push(id);
20865          }
20866
20867          saveCompletions(list);
20868          updateButtonState(btn, list);
20869          checkModuleCompletion(list);
20870        });
20871      });
20872
20873      checkModuleCompletion(completions);
20874    }
20875
20876    if (document.readyState === "loading") {
20877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
20878    } else {
20879      initCompletionButtons();
20880    }
20881  })();
20882</script>
20882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="13933" data-end="14460" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>17. Combinadores lógicos CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-combinadores-logicos-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
20882<script>
20883  (() => {
20884    if (window.__courseLazyPlayersInitialized) return;
20885    window.__courseLazyPlayersInitialized = true;
20886
20887    function createPlayer(details) {
20888      const videoId = details.dataset.videoId;
20889      const start = details.dataset.start;
20890      const end = details.dataset.end;
20891
20892      const container = document.createElement("div");
20893      container.className = "player-container";
20894
20895      const spinner = document.createElement("div");
20896      spinner.className = "spinner";
20897      container.appendChild(spinner);
20898
20899      const controller = document.createElement("media-controller");
20900      controller.setAttribute("defaultsubtitles", "");
20901
20902      const provider = document.createElement("youtube-media-provider");
20903      provider.setAttribute("slot", "media");
20904      provider.setAttribute("video-id", videoId);
20905      provider.setAttribute("start", start);
20906      provider.setAttribute("end", end);
20907
20908      const controlBar = document.createElement("media-control-bar");
20909
20910      const playBtn = document.createElement("media-play-button");
20911      const timeDisplay = document.createElement("media-time-display");
20912      timeDisplay.setAttribute("showduration", "");
20913      const timeRange = document.createElement("media-time-range");
20914      const playbackRate = document.createElement("media-playback-rate-button");
20915      const muteBtn = document.createElement("media-mute-button");
20916      const volumeRange = document.createElement("media-volume-range");
20917
20918      const captionsBtn = document.createElement("media-captions-button");
20919      captionsBtn.addEventListener("click", (e) => {
20920        e.stopPropagation();
20921        const provider = container.querySelector("youtube-media-provider");
20922        if (provider) provider.toggleCaptions();
20923      });
20924
20925      const qualityBtn = document.createElement("media-chrome-button");
20926      qualityBtn.textContent = "--";
20927      qualityBtn.addEventListener("click", () => {
20928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
20929      });
20930
20931      const fullscreenBtn = document.createElement("media-fullscreen-button");
20932
20933      controlBar.append(
20934        playBtn, timeDisplay, timeRange,
20935        playbackRate, muteBtn, volumeRange,
20936        captionsBtn, qualityBtn, fullscreenBtn
20937      );
20938
20939      const poster = document.createElement("media-poster-image");
20940      poster.setAttribute("slot", "poster");
20941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
20942
20943      controller.append(provider, poster, controlBar);
20944
20945      captionsBtn.setAttribute("aria-checked", "true");
20946      controller.setAttribute("mediasubtitlesshowing", "on");
20947
20948      container.appendChild(controller);
20949      details.appendChild(container);
20950
20951      provider.addEventListener("waiting", () => container.classList.add("loading"));
20952      provider.addEventListener("playing", () => container.classList.remove("loading"));
20953      provider.addEventListener("play", () => container.classList.remove("loading"));
20954
20955      provider._textTracks.addEventListener("change", () => {
20956        const enabled = provider._captionsEnabled;
20957        captionsBtn.setAttribute("aria-checked", enabled);
20958        if (enabled) {
20959          controller.setAttribute("mediasubtitlesshowing", "on");
20960        } else {
20961          controller.removeAttribute("mediasubtitlesshowing");
20962        }
20963      });
20964
20965      provider.addEventListener("qualitychange", (e) => {
20966        qualityBtn.textContent = e.detail.quality;
20967      });
20968    }
20969
20970    function initLazyPlayers() {
20971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
20972        if (details.dataset.lazyInit) return;
20973        details.dataset.lazyInit = "true";
20974        details.addEventListener("toggle", () => {
20975          if (details.open && !details.querySelector(".player-container")) {
20976            createPlayer(details);
20977          } else if (!details.open) {
20978            const provider = details.querySelector("youtube-media-provider");
20979            if (provider && typeof provider.pause === "function") {
20980              provider.pause();
20981            }
20982          }
20983        });
20984      });
20985    }
20986
20987    if (document.readyState === "loading") {
20988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
20989    } else {
20990      initLazyPlayers();
20991    }
20992  })();
20993</script>
20993<script>
20994  (() => {
20995    if (window.__courseCompletionsInitialized) return;
20996    window.__courseCompletionsInitialized = true;
20997
20998    const STORAGE_KEY = "course-completions";
20999
21000    function slugify(text) {
21001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21002    }
21003
21004    function getCompletions() {
21005      try {
21006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
21007      } catch {
21008        return [];
21009      }
21010    }
21011
21012    function saveCompletions(completions) {
21013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
21014    }
21015
21016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
21017    function canonicalFromStored(storedId) {
21018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21019      if (!m) return slugify(storedId);
21020      const [, slug, p, mv, title] = m;
21021      return `${slug}-${p}-${mv}-${slugify(title)}`;
21022    }
21023
21024    function canonicalPlusOne(storedId) {
21025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21026      if (!m) return null;
21027      const [, slug, p, mv, title] = m;
21028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
21029    }
21030
21031    function isCompleted(canonicalId, completions) {
21032      if (completions.includes(canonicalId)) return true;
21033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
21034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
21035    }
21036
21037    function migrateCompletions() {
21038      const completions = getCompletions();
21039      let changed = false;
21040      const migrated = completions.map((c) => {
21041        const canon = canonicalFromStored(c);
21042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
21043        const plusOne = canonicalPlusOne(c);
21044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
21045          if (plusOne !== c) changed = true;
21046          return plusOne;
21047        }
21048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
21049          changed = true;
21050          return canon;
21051        }
21052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
21053        if (canon !== c) {
21054          changed = true;
21055          return canon;
21056        }
21057        return c;
21058      });
21059      // Elimina duplicados tras migración
21060      const deduped = [...new Set(migrated)];
21061      if (changed || deduped.length !== completions.length) {
21062        saveCompletions(deduped);
21063        return deduped;
21064      }
21065      return completions;
21066    }
21067
21068    function updateButtonState(btn, completions) {
21069      const id = btn.dataset.completionId;
21070      const list = completions ?? getCompletions();
21071      btn.classList.toggle("completed", isCompleted(id, list));
21072    }
21073
21074    function checkModuleCompletion(completions) {
21075      const list = completions ?? getCompletions();
21076      document.querySelectorAll(".course-video").forEach((module) => {
21077        const btns = module.querySelectorAll(".complete-btn");
21078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
21079        const check = module.querySelector(".module-check");
21080        if (check) check.classList.toggle("visible", allCompleted);
21081      });
21082    }
21083
21084    function initCompletionButtons() {
21085      const completions = migrateCompletions();
21086
21087      document.querySelectorAll(".complete-btn").forEach((btn) => {
21088        if (btn.dataset.completionInit) return;
21089        btn.dataset.completionInit = "true";
21090        updateButtonState(btn, completions);
21091
21092        btn.addEventListener("click", (e) => {
21093          e.preventDefault();
21094          e.stopPropagation();
21095
21096          const id = btn.dataset.completionId;
21097          let list = getCompletions();
21098          const completed = isCompleted(id, list);
21099
21100          if (completed) {
21101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
21102            list = list.filter((c) =>
21102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
21103          } else {
21104            list.push(id);
21105          }
21106
21107          saveCompletions(list);
21108          updateButtonState(btn, list);
21109          checkModuleCompletion(list);
21110        });
21111      });
21112
21113      checkModuleCompletion(completions);
21114    }
21115
21116    if (document.readyState === "loading") {
21117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
21118    } else {
21119      initCompletionButtons();
21120    }
21121  })();
21122</script>
21122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pZJzyAhyV5A" data-start="14460" data-end="15533" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>18. Preguntas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-2-preguntas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
21122<script>
21123  (() => {
21124    if (window.__courseLazyPlayersInitialized) return;
21125    window.__courseLazyPlayersInitialized = true;
21126
21127    function createPlayer(details) {
21128      const videoId = details.dataset.videoId;
21129      const start = details.dataset.start;
21130      const end = details.dataset.end;
21131
21132      const container = document.createElement("div");
21133      container.className = "player-container";
21134
21135      const spinner = document.createElement("div");
21136      spinner.className = "spinner";
21137      container.appendChild(spinner);
21138
21139      const controller = document.createElement("media-controller");
21140      controller.setAttribute("defaultsubtitles", "");
21141
21142      const provider = document.createElement("youtube-media-provider");
21143      provider.setAttribute("slot", "media");
21144      provider.setAttribute("video-id", videoId);
21145      provider.setAttribute("start", start);
21146      provider.setAttribute("end", end);
21147
21148      const controlBar = document.createElement("media-control-bar");
21149
21150      const playBtn = document.createElement("media-play-button");
21151      const timeDisplay = document.createElement("media-time-display");
21152      timeDisplay.setAttribute("showduration", "");
21153      const timeRange = document.createElement("media-time-range");
21154      const playbackRate = document.createElement("media-playback-rate-button");
21155      const muteBtn = document.createElement("media-mute-button");
21156      const volumeRange = document.createElement("media-volume-range");
21157
21158      const captionsBtn = document.createElement("media-captions-button");
21159      captionsBtn.addEventListener("click", (e) => {
21160        e.stopPropagation();
21161        const provider = container.querySelector("youtube-media-provider");
21162        if (provider) provider.toggleCaptions();
21163      });
21164
21165      const qualityBtn = document.createElement("media-chrome-button");
21166      qualityBtn.textContent = "--";
21167      qualityBtn.addEventListener("click", () => {
21168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
21169      });
21170
21171      const fullscreenBtn = document.createElement("media-fullscreen-button");
21172
21173      controlBar.append(
21174        playBtn, timeDisplay, timeRange,
21175        playbackRate, muteBtn, volumeRange,
21176        captionsBtn, qualityBtn, fullscreenBtn
21177      );
21178
21179      const poster = document.createElement("media-poster-image");
21180      poster.setAttribute("slot", "poster");
21181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
21182
21183      controller.append(provider, poster, controlBar);
21184
21185      captionsBtn.setAttribute("aria-checked", "true");
21186      controller.setAttribute("mediasubtitlesshowing", "on");
21187
21188      container.appendChild(controller);
21189      details.appendChild(container);
21190
21191      provider.addEventListener("waiting", () => container.classList.add("loading"));
21192      provider.addEventListener("playing", () => container.classList.remove("loading"));
21193      provider.addEventListener("play", () => container.classList.remove("loading"));
21194
21195      provider._textTracks.addEventListener("change", () => {
21196        const enabled = provider._captionsEnabled;
21197        captionsBtn.setAttribute("aria-checked", enabled);
21198        if (enabled) {
21199          controller.setAttribute("mediasubtitlesshowing", "on");
21200        } else {
21201          controller.removeAttribute("mediasubtitlesshowing");
21202        }
21203      });
21204
21205      provider.addEventListener("qualitychange", (e) => {
21206        qualityBtn.textContent = e.detail.quality;
21207      });
21208    }
21209
21210    function initLazyPlayers() {
21211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
21212        if (details.dataset.lazyInit) return;
21213        details.dataset.lazyInit = "true";
21214        details.addEventListener("toggle", () => {
21215          if (details.open && !details.querySelector(".player-container")) {
21216            createPlayer(details);
21217          } else if (!details.open) {
21218            const provider = details.querySelector("youtube-media-provider");
21219            if (provider && typeof provider.pause === "function") {
21220              provider.pause();
21221            }
21222          }
21223        });
21224      });
21225    }
21226
21227    if (document.readyState === "loading") {
21228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
21229    } else {
21230      initLazyPlayers();
21231    }
21232  })();
21233</script>
21233<script>
21234  (() => {
21235    if (window.__courseCompletionsInitialized) return;
21236    window.__courseCompletionsInitialized = true;
21237
21238    const STORAGE_KEY = "course-completions";
21239
21240    function slugify(text) {
21241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21242    }
21243
21244    function getCompletions() {
21245      try {
21246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
21247      } catch {
21248        return [];
21249      }
21250    }
21251
21252    function saveCompletions(completions) {
21253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
21254    }
21255
21256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
21257    function canonicalFromStored(storedId) {
21258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21259      if (!m) return slugify(storedId);
21260      const [, slug, p, mv, title] = m;
21261      return `${slug}-${p}-${mv}-${slugify(title)}`;
21262    }
21263
21264    function canonicalPlusOne(storedId) {
21265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21266      if (!m) return null;
21267      const [, slug, p, mv, title] = m;
21268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
21269    }
21270
21271    function isCompleted(canonicalId, completions) {
21272      if (completions.includes(canonicalId)) return true;
21273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
21274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
21275    }
21276
21277    function migrateCompletions() {
21278      const completions = getCompletions();
21279      let changed = false;
21280      const migrated = completions.map((c) => {
21281        const canon = canonicalFromStored(c);
21282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
21283        const plusOne = canonicalPlusOne(c);
21284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
21285          if (plusOne !== c) changed = true;
21286          return plusOne;
21287        }
21288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
21289          changed = true;
21290          return canon;
21291        }
21292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
21293        if (canon !== c) {
21294          changed = true;
21295          return canon;
21296        }
21297        return c;
21298      });
21299      // Elimina duplicados tras migración
21300      const deduped = [...new Set(migrated)];
21301      if (changed || deduped.length !== completions.length) {
21302        saveCompletions(deduped);
21303        return deduped;
21304      }
21305      return completions;
21306    }
21307
21308    function updateButtonState(btn, completions) {
21309      const id = btn.dataset.completionId;
21310      const list = completions ?? getCompletions();
21311      btn.classList.toggle("completed", isCompleted(id, list));
21312    }
21313
21314    function checkModuleCompletion(completions) {
21315      const list = completions ?? getCompletions();
21316      document.querySelectorAll(".course-video").forEach((module) => {
21317        const btns = module.querySelectorAll(".complete-btn");
21318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
21319        const check = module.querySelector(".module-check");
21320        if (check) check.classList.toggle("visible", allCompleted);
21321      });
21322    }
21323
21324    function initCompletionButtons() {
21325      const completions = migrateCompletions();
21326
21327      document.querySelectorAll(".complete-btn").forEach((btn) => {
21328        if (btn.dataset.completionInit) return;
21329        btn.dataset.completionInit = "true";
21330        updateButtonState(btn, completions);
21331
21332        btn.addEventListener("click", (e) => {
21333          e.preventDefault();
21334          e.stopPropagation();
21335
21336          const id = btn.dataset.completionId;
21337          let list = getCompletions();
21338          const completed = isCompleted(id, list);
21339
21340          if (completed) {
21341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
21342            list = list.filter((c) =>
21342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
21343          } else {
21344            list.push(id);
21345          }
21346
21347          saveCompletions(list);
21348          updateButtonState(btn, list);
21349          checkModuleCompletion(list);
21350        });
21351      });
21352
21353      checkModuleCompletion(completions);
21354    }
21355
21356    if (document.readyState === "loading") {
21357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
21358    } else {
21359      initCompletionButtons();
21360    }
21361  })();
21362</script>
21362</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 7 📙 Layouts de CSS<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="907" data-end="1327" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Charla inicial</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-charla-inicial" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
21362<script>
21363  (() => {
21364    if (window.__courseLazyPlayersInitialized) return;
21365    window.__courseLazyPlayersInitialized = true;
21366
21367    function createPlayer(details) {
21368      const videoId = details.dataset.videoId;
21369      const start = details.dataset.start;
21370      const end = details.dataset.end;
21371
21372      const container = document.createElement("div");
21373      container.className = "player-container";
21374
21375      const spinner = document.createElement("div");
21376      spinner.className = "spinner";
21377      container.appendChild(spinner);
21378
21379      const controller = document.createElement("media-controller");
21380      controller.setAttribute("defaultsubtitles", "");
21381
21382      const provider = document.createElement("youtube-media-provider");
21383      provider.setAttribute("slot", "media");
21384      provider.setAttribute("video-id", videoId);
21385      provider.setAttribute("start", start);
21386      provider.setAttribute("end", end);
21387
21388      const controlBar = document.createElement("media-control-bar");
21389
21390      const playBtn = document.createElement("media-play-button");
21391      const timeDisplay = document.createElement("media-time-display");
21392      timeDisplay.setAttribute("showduration", "");
21393      const timeRange = document.createElement("media-time-range");
21394      const playbackRate = document.createElement("media-playback-rate-button");
21395      const muteBtn = document.createElement("media-mute-button");
21396      const volumeRange = document.createElement("media-volume-range");
21397
21398      const captionsBtn = document.createElement("media-captions-button");
21399      captionsBtn.addEventListener("click", (e) => {
21400        e.stopPropagation();
21401        const provider = container.querySelector("youtube-media-provider");
21402        if (provider) provider.toggleCaptions();
21403      });
21404
21405      const qualityBtn = document.createElement("media-chrome-button");
21406      qualityBtn.textContent = "--";
21407      qualityBtn.addEventListener("click", () => {
21408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
21409      });
21410
21411      const fullscreenBtn = document.createElement("media-fullscreen-button");
21412
21413      controlBar.append(
21414        playBtn, timeDisplay, timeRange,
21415        playbackRate, muteBtn, volumeRange,
21416        captionsBtn, qualityBtn, fullscreenBtn
21417      );
21418
21419      const poster = document.createElement("media-poster-image");
21420      poster.setAttribute("slot", "poster");
21421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
21422
21423      controller.append(provider, poster, controlBar);
21424
21425      captionsBtn.setAttribute("aria-checked", "true");
21426      controller.setAttribute("mediasubtitlesshowing", "on");
21427
21428      container.appendChild(controller);
21429      details.appendChild(container);
21430
21431      provider.addEventListener("waiting", () => container.classList.add("loading"));
21432      provider.addEventListener("playing", () => container.classList.remove("loading"));
21433      provider.addEventListener("play", () => container.classList.remove("loading"));
21434
21435      provider._textTracks.addEventListener("change", () => {
21436        const enabled = provider._captionsEnabled;
21437        captionsBtn.setAttribute("aria-checked", enabled);
21438        if (enabled) {
21439          controller.setAttribute("mediasubtitlesshowing", "on");
21440        } else {
21441          controller.removeAttribute("mediasubtitlesshowing");
21442        }
21443      });
21444
21445      provider.addEventListener("qualitychange", (e) => {
21446        qualityBtn.textContent = e.detail.quality;
21447      });
21448    }
21449
21450    function initLazyPlayers() {
21451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
21452        if (details.dataset.lazyInit) return;
21453        details.dataset.lazyInit = "true";
21454        details.addEventListener("toggle", () => {
21455          if (details.open && !details.querySelector(".player-container")) {
21456            createPlayer(details);
21457          } else if (!details.open) {
21458            const provider = details.querySelector("youtube-media-provider");
21459            if (provider && typeof provider.pause === "function") {
21460              provider.pause();
21461            }
21462          }
21463        });
21464      });
21465    }
21466
21467    if (document.readyState === "loading") {
21468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
21469    } else {
21470      initLazyPlayers();
21471    }
21472  })();
21473</script>
21473<script>
21474  (() => {
21475    if (window.__courseCompletionsInitialized) return;
21476    window.__courseCompletionsInitialized = true;
21477
21478    const STORAGE_KEY = "course-completions";
21479
21480    function slugify(text) {
21481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21482    }
21483
21484    function getCompletions() {
21485      try {
21486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
21487      } catch {
21488        return [];
21489      }
21490    }
21491
21492    function saveCompletions(completions) {
21493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
21494    }
21495
21496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
21497    function canonicalFromStored(storedId) {
21498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21499      if (!m) return slugify(storedId);
21500      const [, slug, p, mv, title] = m;
21501      return `${slug}-${p}-${mv}-${slugify(title)}`;
21502    }
21503
21504    function canonicalPlusOne(storedId) {
21505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21506      if (!m) return null;
21507      const [, slug, p, mv, title] = m;
21508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
21509    }
21510
21511    function isCompleted(canonicalId, completions) {
21512      if (completions.includes(canonicalId)) return true;
21513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
21514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
21515    }
21516
21517    function migrateCompletions() {
21518      const completions = getCompletions();
21519      let changed = false;
21520      const migrated = completions.map((c) => {
21521        const canon = canonicalFromStored(c);
21522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
21523        const plusOne = canonicalPlusOne(c);
21524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
21525          if (plusOne !== c) changed = true;
21526          return plusOne;
21527        }
21528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
21529          changed = true;
21530          return canon;
21531        }
21532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
21533        if (canon !== c) {
21534          changed = true;
21535          return canon;
21536        }
21537        return c;
21538      });
21539      // Elimina duplicados tras migración
21540      const deduped = [...new Set(migrated)];
21541      if (changed || deduped.length !== completions.length) {
21542        saveCompletions(deduped);
21543        return deduped;
21544      }
21545      return completions;
21546    }
21547
21548    function updateButtonState(btn, completions) {
21549      const id = btn.dataset.completionId;
21550      const list = completions ?? getCompletions();
21551      btn.classList.toggle("completed", isCompleted(id, list));
21552    }
21553
21554    function checkModuleCompletion(completions) {
21555      const list = completions ?? getCompletions();
21556      document.querySelectorAll(".course-video").forEach((module) => {
21557        const btns = module.querySelectorAll(".complete-btn");
21558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
21559        const check = module.querySelector(".module-check");
21560        if (check) check.classList.toggle("visible", allCompleted);
21561      });
21562    }
21563
21564    function initCompletionButtons() {
21565      const completions = migrateCompletions();
21566
21567      document.querySelectorAll(".complete-btn").forEach((btn) => {
21568        if (btn.dataset.completionInit) return;
21569        btn.dataset.completionInit = "true";
21570        updateButtonState(btn, completions);
21571
21572        btn.addEventListener("click", (e) => {
21573          e.preventDefault();
21574          e.stopPropagation();
21575
21576          const id = btn.dataset.completionId;
21577          let list = getCompletions();
21578          const completed = isCompleted(id, list);
21579
21580          if (completed) {
21581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
21582            list = list.filter((c) =>
21582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
21583          } else {
21584            list.push(id);
21585          }
21586
21587          saveCompletions(list);
21588          updateButtonState(btn, list);
21589          checkModuleCompletion(list);
21590        });
21591      });
21592
21593      checkModuleCompletion(completions);
21594    }
21595
21596    if (document.readyState === "loading") {
21597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
21598    } else {
21599      initCompletionButtons();
21600    }
21601  })();
21602</script>
21602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="1327" data-end="2284" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Display</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-display" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
21602<script>
21603  (() => {
21604    if (window.__courseLazyPlayersInitialized) return;
21605    window.__courseLazyPlayersInitialized = true;
21606
21607    function createPlayer(details) {
21608      const videoId = details.dataset.videoId;
21609      const start = details.dataset.start;
21610      const end = details.dataset.end;
21611
21612      const container = document.createElement("div");
21613      container.className = "player-container";
21614
21615      const spinner = document.createElement("div");
21616      spinner.className = "spinner";
21617      container.appendChild(spinner);
21618
21619      const controller = document.createElement("media-controller");
21620      controller.setAttribute("defaultsubtitles", "");
21621
21622      const provider = document.createElement("youtube-media-provider");
21623      provider.setAttribute("slot", "media");
21624      provider.setAttribute("video-id", videoId);
21625      provider.setAttribute("start", start);
21626      provider.setAttribute("end", end);
21627
21628      const controlBar = document.createElement("media-control-bar");
21629
21630      const playBtn = document.createElement("media-play-button");
21631      const timeDisplay = document.createElement("media-time-display");
21632      timeDisplay.setAttribute("showduration", "");
21633      const timeRange = document.createElement("media-time-range");
21634      const playbackRate = document.createElement("media-playback-rate-button");
21635      const muteBtn = document.createElement("media-mute-button");
21636      const volumeRange = document.createElement("media-volume-range");
21637
21638      const captionsBtn = document.createElement("media-captions-button");
21639      captionsBtn.addEventListener("click", (e) => {
21640        e.stopPropagation();
21641        const provider = container.querySelector("youtube-media-provider");
21642        if (provider) provider.toggleCaptions();
21643      });
21644
21645      const qualityBtn = document.createElement("media-chrome-button");
21646      qualityBtn.textContent = "--";
21647      qualityBtn.addEventListener("click", () => {
21648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
21649      });
21650
21651      const fullscreenBtn = document.createElement("media-fullscreen-button");
21652
21653      controlBar.append(
21654        playBtn, timeDisplay, timeRange,
21655        playbackRate, muteBtn, volumeRange,
21656        captionsBtn, qualityBtn, fullscreenBtn
21657      );
21658
21659      const poster = document.createElement("media-poster-image");
21660      poster.setAttribute("slot", "poster");
21661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
21662
21663      controller.append(provider, poster, controlBar);
21664
21665      captionsBtn.setAttribute("aria-checked", "true");
21666      controller.setAttribute("mediasubtitlesshowing", "on");
21667
21668      container.appendChild(controller);
21669      details.appendChild(container);
21670
21671      provider.addEventListener("waiting", () => container.classList.add("loading"));
21672      provider.addEventListener("playing", () => container.classList.remove("loading"));
21673      provider.addEventListener("play", () => container.classList.remove("loading"));
21674
21675      provider._textTracks.addEventListener("change", () => {
21676        const enabled = provider._captionsEnabled;
21677        captionsBtn.setAttribute("aria-checked", enabled);
21678        if (enabled) {
21679          controller.setAttribute("mediasubtitlesshowing", "on");
21680        } else {
21681          controller.removeAttribute("mediasubtitlesshowing");
21682        }
21683      });
21684
21685      provider.addEventListener("qualitychange", (e) => {
21686        qualityBtn.textContent = e.detail.quality;
21687      });
21688    }
21689
21690    function initLazyPlayers() {
21691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
21692        if (details.dataset.lazyInit) return;
21693        details.dataset.lazyInit = "true";
21694        details.addEventListener("toggle", () => {
21695          if (details.open && !details.querySelector(".player-container")) {
21696            createPlayer(details);
21697          } else if (!details.open) {
21698            const provider = details.querySelector("youtube-media-provider");
21699            if (provider && typeof provider.pause === "function") {
21700              provider.pause();
21701            }
21702          }
21703        });
21704      });
21705    }
21706
21707    if (document.readyState === "loading") {
21708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
21709    } else {
21710      initLazyPlayers();
21711    }
21712  })();
21713</script>
21713<script>
21714  (() => {
21715    if (window.__courseCompletionsInitialized) return;
21716    window.__courseCompletionsInitialized = true;
21717
21718    const STORAGE_KEY = "course-completions";
21719
21720    function slugify(text) {
21721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21722    }
21723
21724    function getCompletions() {
21725      try {
21726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
21727      } catch {
21728        return [];
21729      }
21730    }
21731
21732    function saveCompletions(completions) {
21733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
21734    }
21735
21736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
21737    function canonicalFromStored(storedId) {
21738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21739      if (!m) return slugify(storedId);
21740      const [, slug, p, mv, title] = m;
21741      return `${slug}-${p}-${mv}-${slugify(title)}`;
21742    }
21743
21744    function canonicalPlusOne(storedId) {
21745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21746      if (!m) return null;
21747      const [, slug, p, mv, title] = m;
21748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
21749    }
21750
21751    function isCompleted(canonicalId, completions) {
21752      if (completions.includes(canonicalId)) return true;
21753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
21754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
21755    }
21756
21757    function migrateCompletions() {
21758      const completions = getCompletions();
21759      let changed = false;
21760      const migrated = completions.map((c) => {
21761        const canon = canonicalFromStored(c);
21762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
21763        const plusOne = canonicalPlusOne(c);
21764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
21765          if (plusOne !== c) changed = true;
21766          return plusOne;
21767        }
21768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
21769          changed = true;
21770          return canon;
21771        }
21772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
21773        if (canon !== c) {
21774          changed = true;
21775          return canon;
21776        }
21777        return c;
21778      });
21779      // Elimina duplicados tras migración
21780      const deduped = [...new Set(migrated)];
21781      if (changed || deduped.length !== completions.length) {
21782        saveCompletions(deduped);
21783        return deduped;
21784      }
21785      return completions;
21786    }
21787
21788    function updateButtonState(btn, completions) {
21789      const id = btn.dataset.completionId;
21790      const list = completions ?? getCompletions();
21791      btn.classList.toggle("completed", isCompleted(id, list));
21792    }
21793
21794    function checkModuleCompletion(completions) {
21795      const list = completions ?? getCompletions();
21796      document.querySelectorAll(".course-video").forEach((module) => {
21797        const btns = module.querySelectorAll(".complete-btn");
21798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
21799        const check = module.querySelector(".module-check");
21800        if (check) check.classList.toggle("visible", allCompleted);
21801      });
21802    }
21803
21804    function initCompletionButtons() {
21805      const completions = migrateCompletions();
21806
21807      document.querySelectorAll(".complete-btn").forEach((btn) => {
21808        if (btn.dataset.completionInit) return;
21809        btn.dataset.completionInit = "true";
21810        updateButtonState(btn, completions);
21811
21812        btn.addEventListener("click", (e) => {
21813          e.preventDefault();
21814          e.stopPropagation();
21815
21816          const id = btn.dataset.completionId;
21817          let list = getCompletions();
21818          const completed = isCompleted(id, list);
21819
21820          if (completed) {
21821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
21822            list = list.filter((c) =>
21822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
21823          } else {
21824            list.push(id);
21825          }
21826
21827          saveCompletions(list);
21828          updateButtonState(btn, list);
21829          checkModuleCompletion(list);
21830        });
21831      });
21832
21833      checkModuleCompletion(completions);
21834    }
21835
21836    if (document.readyState === "loading") {
21837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
21838    } else {
21839      initCompletionButtons();
21840    }
21841  })();
21842</script>
21842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="2284" data-end="3750" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Flex</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-flex" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
21842<script>
21843  (() => {
21844    if (window.__courseLazyPlayersInitialized) return;
21845    window.__courseLazyPlayersInitialized = true;
21846
21847    function createPlayer(details) {
21848      const videoId = details.dataset.videoId;
21849      const start = details.dataset.start;
21850      const end = details.dataset.end;
21851
21852      const container = document.createElement("div");
21853      container.className = "player-container";
21854
21855      const spinner = document.createElement("div");
21856      spinner.className = "spinner";
21857      container.appendChild(spinner);
21858
21859      const controller = document.createElement("media-controller");
21860      controller.setAttribute("defaultsubtitles", "");
21861
21862      const provider = document.createElement("youtube-media-provider");
21863      provider.setAttribute("slot", "media");
21864      provider.setAttribute("video-id", videoId);
21865      provider.setAttribute("start", start);
21866      provider.setAttribute("end", end);
21867
21868      const controlBar = document.createElement("media-control-bar");
21869
21870      const playBtn = document.createElement("media-play-button");
21871      const timeDisplay = document.createElement("media-time-display");
21872      timeDisplay.setAttribute("showduration", "");
21873      const timeRange = document.createElement("media-time-range");
21874      const playbackRate = document.createElement("media-playback-rate-button");
21875      const muteBtn = document.createElement("media-mute-button");
21876      const volumeRange = document.createElement("media-volume-range");
21877
21878      const captionsBtn = document.createElement("media-captions-button");
21879      captionsBtn.addEventListener("click", (e) => {
21880        e.stopPropagation();
21881        const provider = container.querySelector("youtube-media-provider");
21882        if (provider) provider.toggleCaptions();
21883      });
21884
21885      const qualityBtn = document.createElement("media-chrome-button");
21886      qualityBtn.textContent = "--";
21887      qualityBtn.addEventListener("click", () => {
21888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
21889      });
21890
21891      const fullscreenBtn = document.createElement("media-fullscreen-button");
21892
21893      controlBar.append(
21894        playBtn, timeDisplay, timeRange,
21895        playbackRate, muteBtn, volumeRange,
21896        captionsBtn, qualityBtn, fullscreenBtn
21897      );
21898
21899      const poster = document.createElement("media-poster-image");
21900      poster.setAttribute("slot", "poster");
21901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
21902
21903      controller.append(provider, poster, controlBar);
21904
21905      captionsBtn.setAttribute("aria-checked", "true");
21906      controller.setAttribute("mediasubtitlesshowing", "on");
21907
21908      container.appendChild(controller);
21909      details.appendChild(container);
21910
21911      provider.addEventListener("waiting", () => container.classList.add("loading"));
21912      provider.addEventListener("playing", () => container.classList.remove("loading"));
21913      provider.addEventListener("play", () => container.classList.remove("loading"));
21914
21915      provider._textTracks.addEventListener("change", () => {
21916        const enabled = provider._captionsEnabled;
21917        captionsBtn.setAttribute("aria-checked", enabled);
21918        if (enabled) {
21919          controller.setAttribute("mediasubtitlesshowing", "on");
21920        } else {
21921          controller.removeAttribute("mediasubtitlesshowing");
21922        }
21923      });
21924
21925      provider.addEventListener("qualitychange", (e) => {
21926        qualityBtn.textContent = e.detail.quality;
21927      });
21928    }
21929
21930    function initLazyPlayers() {
21931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
21932        if (details.dataset.lazyInit) return;
21933        details.dataset.lazyInit = "true";
21934        details.addEventListener("toggle", () => {
21935          if (details.open && !details.querySelector(".player-container")) {
21936            createPlayer(details);
21937          } else if (!details.open) {
21938            const provider = details.querySelector("youtube-media-provider");
21939            if (provider && typeof provider.pause === "function") {
21940              provider.pause();
21941            }
21942          }
21943        });
21944      });
21945    }
21946
21947    if (document.readyState === "loading") {
21948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
21949    } else {
21950      initLazyPlayers();
21951    }
21952  })();
21953</script>
21953<script>
21954  (() => {
21955    if (window.__courseCompletionsInitialized) return;
21956    window.__courseCompletionsInitialized = true;
21957
21958    const STORAGE_KEY = "course-completions";
21959
21960    function slugify(text) {
21961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21962    }
21963
21964    function getCompletions() {
21965      try {
21966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
21967      } catch {
21968        return [];
21969      }
21970    }
21971
21972    function saveCompletions(completions) {
21973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
21974    }
21975
21976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
21977    function canonicalFromStored(storedId) {
21978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21979      if (!m) return slugify(storedId);
21980      const [, slug, p, mv, title] = m;
21981      return `${slug}-${p}-${mv}-${slugify(title)}`;
21982    }
21983
21984    function canonicalPlusOne(storedId) {
21985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
21986      if (!m) return null;
21987      const [, slug, p, mv, title] = m;
21988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
21989    }
21990
21991    function isCompleted(canonicalId, completions) {
21992      if (completions.includes(canonicalId)) return true;
21993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
21994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
21995    }
21996
21997    function migrateCompletions() {
21998      const completions = getCompletions();
21999      let changed = false;
22000      const migrated = completions.map((c) => {
22001        const canon = canonicalFromStored(c);
22002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
22003        const plusOne = canonicalPlusOne(c);
22004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
22005          if (plusOne !== c) changed = true;
22006          return plusOne;
22007        }
22008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
22009          changed = true;
22010          return canon;
22011        }
22012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
22013        if (canon !== c) {
22014          changed = true;
22015          return canon;
22016        }
22017        return c;
22018      });
22019      // Elimina duplicados tras migración
22020      const deduped = [...new Set(migrated)];
22021      if (changed || deduped.length !== completions.length) {
22022        saveCompletions(deduped);
22023        return deduped;
22024      }
22025      return completions;
22026    }
22027
22028    function updateButtonState(btn, completions) {
22029      const id = btn.dataset.completionId;
22030      const list = completions ?? getCompletions();
22031      btn.classList.toggle("completed", isCompleted(id, list));
22032    }
22033
22034    function checkModuleCompletion(completions) {
22035      const list = completions ?? getCompletions();
22036      document.querySelectorAll(".course-video").forEach((module) => {
22037        const btns = module.querySelectorAll(".complete-btn");
22038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
22039        const check = module.querySelector(".module-check");
22040        if (check) check.classList.toggle("visible", allCompleted);
22041      });
22042    }
22043
22044    function initCompletionButtons() {
22045      const completions = migrateCompletions();
22046
22047      document.querySelectorAll(".complete-btn").forEach((btn) => {
22048        if (btn.dataset.completionInit) return;
22049        btn.dataset.completionInit = "true";
22050        updateButtonState(btn, completions);
22051
22052        btn.addEventListener("click", (e) => {
22053          e.preventDefault();
22054          e.stopPropagation();
22055
22056          const id = btn.dataset.completionId;
22057          let list = getCompletions();
22058          const completed = isCompleted(id, list);
22059
22060          if (completed) {
22061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
22062            list = list.filter((c) =>
22062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
22063          } else {
22064            list.push(id);
22065          }
22066
22067          saveCompletions(list);
22068          updateButtonState(btn, list);
22069          checkModuleCompletion(list);
22070        });
22071      });
22072
22073      checkModuleCompletion(completions);
22074    }
22075
22076    if (document.readyState === "loading") {
22077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
22078    } else {
22079      initCompletionButtons();
22080    }
22081  })();
22082</script>
22082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="3750" data-end="5062" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Flex multilínea</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>22 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-flex-multilinea" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
22082<script>
22083  (() => {
22084    if (window.__courseLazyPlayersInitialized) return;
22085    window.__courseLazyPlayersInitialized = true;
22086
22087    function createPlayer(details) {
22088      const videoId = details.dataset.videoId;
22089      const start = details.dataset.start;
22090      const end = details.dataset.end;
22091
22092      const container = document.createElement("div");
22093      container.className = "player-container";
22094
22095      const spinner = document.createElement("div");
22096      spinner.className = "spinner";
22097      container.appendChild(spinner);
22098
22099      const controller = document.createElement("media-controller");
22100      controller.setAttribute("defaultsubtitles", "");
22101
22102      const provider = document.createElement("youtube-media-provider");
22103      provider.setAttribute("slot", "media");
22104      provider.setAttribute("video-id", videoId);
22105      provider.setAttribute("start", start);
22106      provider.setAttribute("end", end);
22107
22108      const controlBar = document.createElement("media-control-bar");
22109
22110      const playBtn = document.createElement("media-play-button");
22111      const timeDisplay = document.createElement("media-time-display");
22112      timeDisplay.setAttribute("showduration", "");
22113      const timeRange = document.createElement("media-time-range");
22114      const playbackRate = document.createElement("media-playback-rate-button");
22115      const muteBtn = document.createElement("media-mute-button");
22116      const volumeRange = document.createElement("media-volume-range");
22117
22118      const captionsBtn = document.createElement("media-captions-button");
22119      captionsBtn.addEventListener("click", (e) => {
22120        e.stopPropagation();
22121        const provider = container.querySelector("youtube-media-provider");
22122        if (provider) provider.toggleCaptions();
22123      });
22124
22125      const qualityBtn = document.createElement("media-chrome-button");
22126      qualityBtn.textContent = "--";
22127      qualityBtn.addEventListener("click", () => {
22128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
22129      });
22130
22131      const fullscreenBtn = document.createElement("media-fullscreen-button");
22132
22133      controlBar.append(
22134        playBtn, timeDisplay, timeRange,
22135        playbackRate, muteBtn, volumeRange,
22136        captionsBtn, qualityBtn, fullscreenBtn
22137      );
22138
22139      const poster = document.createElement("media-poster-image");
22140      poster.setAttribute("slot", "poster");
22141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
22142
22143      controller.append(provider, poster, controlBar);
22144
22145      captionsBtn.setAttribute("aria-checked", "true");
22146      controller.setAttribute("mediasubtitlesshowing", "on");
22147
22148      container.appendChild(controller);
22149      details.appendChild(container);
22150
22151      provider.addEventListener("waiting", () => container.classList.add("loading"));
22152      provider.addEventListener("playing", () => container.classList.remove("loading"));
22153      provider.addEventListener("play", () => container.classList.remove("loading"));
22154
22155      provider._textTracks.addEventListener("change", () => {
22156        const enabled = provider._captionsEnabled;
22157        captionsBtn.setAttribute("aria-checked", enabled);
22158        if (enabled) {
22159          controller.setAttribute("mediasubtitlesshowing", "on");
22160        } else {
22161          controller.removeAttribute("mediasubtitlesshowing");
22162        }
22163      });
22164
22165      provider.addEventListener("qualitychange", (e) => {
22166        qualityBtn.textContent = e.detail.quality;
22167      });
22168    }
22169
22170    function initLazyPlayers() {
22171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
22172        if (details.dataset.lazyInit) return;
22173        details.dataset.lazyInit = "true";
22174        details.addEventListener("toggle", () => {
22175          if (details.open && !details.querySelector(".player-container")) {
22176            createPlayer(details);
22177          } else if (!details.open) {
22178            const provider = details.querySelector("youtube-media-provider");
22179            if (provider && typeof provider.pause === "function") {
22180              provider.pause();
22181            }
22182          }
22183        });
22184      });
22185    }
22186
22187    if (document.readyState === "loading") {
22188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
22189    } else {
22190      initLazyPlayers();
22191    }
22192  })();
22193</script>
22193<script>
22194  (() => {
22195    if (window.__courseCompletionsInitialized) return;
22196    window.__courseCompletionsInitialized = true;
22197
22198    const STORAGE_KEY = "course-completions";
22199
22200    function slugify(text) {
22201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
22202    }
22203
22204    function getCompletions() {
22205      try {
22206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
22207      } catch {
22208        return [];
22209      }
22210    }
22211
22212    function saveCompletions(completions) {
22213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
22214    }
22215
22216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
22217    function canonicalFromStored(storedId) {
22218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22219      if (!m) return slugify(storedId);
22220      const [, slug, p, mv, title] = m;
22221      return `${slug}-${p}-${mv}-${slugify(title)}`;
22222    }
22223
22224    function canonicalPlusOne(storedId) {
22225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22226      if (!m) return null;
22227      const [, slug, p, mv, title] = m;
22228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
22229    }
22230
22231    function isCompleted(canonicalId, completions) {
22232      if (completions.includes(canonicalId)) return true;
22233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
22234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
22235    }
22236
22237    function migrateCompletions() {
22238      const completions = getCompletions();
22239      let changed = false;
22240      const migrated = completions.map((c) => {
22241        const canon = canonicalFromStored(c);
22242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
22243        const plusOne = canonicalPlusOne(c);
22244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
22245          if (plusOne !== c) changed = true;
22246          return plusOne;
22247        }
22248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
22249          changed = true;
22250          return canon;
22251        }
22252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
22253        if (canon !== c) {
22254          changed = true;
22255          return canon;
22256        }
22257        return c;
22258      });
22259      // Elimina duplicados tras migración
22260      const deduped = [...new Set(migrated)];
22261      if (changed || deduped.length !== completions.length) {
22262        saveCompletions(deduped);
22263        return deduped;
22264      }
22265      return completions;
22266    }
22267
22268    function updateButtonState(btn, completions) {
22269      const id = btn.dataset.completionId;
22270      const list = completions ?? getCompletions();
22271      btn.classList.toggle("completed", isCompleted(id, list));
22272    }
22273
22274    function checkModuleCompletion(completions) {
22275      const list = completions ?? getCompletions();
22276      document.querySelectorAll(".course-video").forEach((module) => {
22277        const btns = module.querySelectorAll(".complete-btn");
22278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
22279        const check = module.querySelector(".module-check");
22280        if (check) check.classList.toggle("visible", allCompleted);
22281      });
22282    }
22283
22284    function initCompletionButtons() {
22285      const completions = migrateCompletions();
22286
22287      document.querySelectorAll(".complete-btn").forEach((btn) => {
22288        if (btn.dataset.completionInit) return;
22289        btn.dataset.completionInit = "true";
22290        updateButtonState(btn, completions);
22291
22292        btn.addEventListener("click", (e) => {
22293          e.preventDefault();
22294          e.stopPropagation();
22295
22296          const id = btn.dataset.completionId;
22297          let list = getCompletions();
22298          const completed = isCompleted(id, list);
22299
22300          if (completed) {
22301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
22302            list = list.filter((c) =>
22302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
22303          } else {
22304            list.push(id);
22305          }
22306
22307          saveCompletions(list);
22308          updateButtonState(btn, list);
22309          checkModuleCompletion(list);
22310        });
22311      });
22312
22313      checkModuleCompletion(completions);
22314    }
22315
22316    if (document.readyState === "loading") {
22317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
22318    } else {
22319      initCompletionButtons();
22320    }
22321  })();
22322</script>
22322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="5407" data-end="7116" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Grid</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-grid" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
22322<script>
22323  (() => {
22324    if (window.__courseLazyPlayersInitialized) return;
22325    window.__courseLazyPlayersInitialized = true;
22326
22327    function createPlayer(details) {
22328      const videoId = details.dataset.videoId;
22329      const start = details.dataset.start;
22330      const end = details.dataset.end;
22331
22332      const container = document.createElement("div");
22333      container.className = "player-container";
22334
22335      const spinner = document.createElement("div");
22336      spinner.className = "spinner";
22337      container.appendChild(spinner);
22338
22339      const controller = document.createElement("media-controller");
22340      controller.setAttribute("defaultsubtitles", "");
22341
22342      const provider = document.createElement("youtube-media-provider");
22343      provider.setAttribute("slot", "media");
22344      provider.setAttribute("video-id", videoId);
22345      provider.setAttribute("start", start);
22346      provider.setAttribute("end", end);
22347
22348      const controlBar = document.createElement("media-control-bar");
22349
22350      const playBtn = document.createElement("media-play-button");
22351      const timeDisplay = document.createElement("media-time-display");
22352      timeDisplay.setAttribute("showduration", "");
22353      const timeRange = document.createElement("media-time-range");
22354      const playbackRate = document.createElement("media-playback-rate-button");
22355      const muteBtn = document.createElement("media-mute-button");
22356      const volumeRange = document.createElement("media-volume-range");
22357
22358      const captionsBtn = document.createElement("media-captions-button");
22359      captionsBtn.addEventListener("click", (e) => {
22360        e.stopPropagation();
22361        const provider = container.querySelector("youtube-media-provider");
22362        if (provider) provider.toggleCaptions();
22363      });
22364
22365      const qualityBtn = document.createElement("media-chrome-button");
22366      qualityBtn.textContent = "--";
22367      qualityBtn.addEventListener("click", () => {
22368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
22369      });
22370
22371      const fullscreenBtn = document.createElement("media-fullscreen-button");
22372
22373      controlBar.append(
22374        playBtn, timeDisplay, timeRange,
22375        playbackRate, muteBtn, volumeRange,
22376        captionsBtn, qualityBtn, fullscreenBtn
22377      );
22378
22379      const poster = document.createElement("media-poster-image");
22380      poster.setAttribute("slot", "poster");
22381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
22382
22383      controller.append(provider, poster, controlBar);
22384
22385      captionsBtn.setAttribute("aria-checked", "true");
22386      controller.setAttribute("mediasubtitlesshowing", "on");
22387
22388      container.appendChild(controller);
22389      details.appendChild(container);
22390
22391      provider.addEventListener("waiting", () => container.classList.add("loading"));
22392      provider.addEventListener("playing", () => container.classList.remove("loading"));
22393      provider.addEventListener("play", () => container.classList.remove("loading"));
22394
22395      provider._textTracks.addEventListener("change", () => {
22396        const enabled = provider._captionsEnabled;
22397        captionsBtn.setAttribute("aria-checked", enabled);
22398        if (enabled) {
22399          controller.setAttribute("mediasubtitlesshowing", "on");
22400        } else {
22401          controller.removeAttribute("mediasubtitlesshowing");
22402        }
22403      });
22404
22405      provider.addEventListener("qualitychange", (e) => {
22406        qualityBtn.textContent = e.detail.quality;
22407      });
22408    }
22409
22410    function initLazyPlayers() {
22411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
22412        if (details.dataset.lazyInit) return;
22413        details.dataset.lazyInit = "true";
22414        details.addEventListener("toggle", () => {
22415          if (details.open && !details.querySelector(".player-container")) {
22416            createPlayer(details);
22417          } else if (!details.open) {
22418            const provider = details.querySelector("youtube-media-provider");
22419            if (provider && typeof provider.pause === "function") {
22420              provider.pause();
22421            }
22422          }
22423        });
22424      });
22425    }
22426
22427    if (document.readyState === "loading") {
22428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
22429    } else {
22430      initLazyPlayers();
22431    }
22432  })();
22433</script>
22433<script>
22434  (() => {
22435    if (window.__courseCompletionsInitialized) return;
22436    window.__courseCompletionsInitialized = true;
22437
22438    const STORAGE_KEY = "course-completions";
22439
22440    function slugify(text) {
22441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
22442    }
22443
22444    function getCompletions() {
22445      try {
22446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
22447      } catch {
22448        return [];
22449      }
22450    }
22451
22452    function saveCompletions(completions) {
22453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
22454    }
22455
22456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
22457    function canonicalFromStored(storedId) {
22458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22459      if (!m) return slugify(storedId);
22460      const [, slug, p, mv, title] = m;
22461      return `${slug}-${p}-${mv}-${slugify(title)}`;
22462    }
22463
22464    function canonicalPlusOne(storedId) {
22465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22466      if (!m) return null;
22467      const [, slug, p, mv, title] = m;
22468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
22469    }
22470
22471    function isCompleted(canonicalId, completions) {
22472      if (completions.includes(canonicalId)) return true;
22473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
22474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
22475    }
22476
22477    function migrateCompletions() {
22478      const completions = getCompletions();
22479      let changed = false;
22480      const migrated = completions.map((c) => {
22481        const canon = canonicalFromStored(c);
22482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
22483        const plusOne = canonicalPlusOne(c);
22484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
22485          if (plusOne !== c) changed = true;
22486          return plusOne;
22487        }
22488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
22489          changed = true;
22490          return canon;
22491        }
22492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
22493        if (canon !== c) {
22494          changed = true;
22495          return canon;
22496        }
22497        return c;
22498      });
22499      // Elimina duplicados tras migración
22500      const deduped = [...new Set(migrated)];
22501      if (changed || deduped.length !== completions.length) {
22502        saveCompletions(deduped);
22503        return deduped;
22504      }
22505      return completions;
22506    }
22507
22508    function updateButtonState(btn, completions) {
22509      const id = btn.dataset.completionId;
22510      const list = completions ?? getCompletions();
22511      btn.classList.toggle("completed", isCompleted(id, list));
22512    }
22513
22514    function checkModuleCompletion(completions) {
22515      const list = completions ?? getCompletions();
22516      document.querySelectorAll(".course-video").forEach((module) => {
22517        const btns = module.querySelectorAll(".complete-btn");
22518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
22519        const check = module.querySelector(".module-check");
22520        if (check) check.classList.toggle("visible", allCompleted);
22521      });
22522    }
22523
22524    function initCompletionButtons() {
22525      const completions = migrateCompletions();
22526
22527      document.querySelectorAll(".complete-btn").forEach((btn) => {
22528        if (btn.dataset.completionInit) return;
22529        btn.dataset.completionInit = "true";
22530        updateButtonState(btn, completions);
22531
22532        btn.addEventListener("click", (e) => {
22533          e.preventDefault();
22534          e.stopPropagation();
22535
22536          const id = btn.dataset.completionId;
22537          let list = getCompletions();
22538          const completed = isCompleted(id, list);
22539
22540          if (completed) {
22541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
22542            list = list.filter((c) =>
22542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
22543          } else {
22544            list.push(id);
22545          }
22546
22547          saveCompletions(list);
22548          updateButtonState(btn, list);
22549          checkModuleCompletion(list);
22550        });
22551      });
22552
22553      checkModuleCompletion(completions);
22554    }
22555
22556    if (document.readyState === "loading") {
22557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
22558    } else {
22559      initCompletionButtons();
22560    }
22561  })();
22562</script>
22562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="7116" data-end="7934" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Grid por áreas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-grid-por-areas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
22562<script>
22563  (() => {
22564    if (window.__courseLazyPlayersInitialized) return;
22565    window.__courseLazyPlayersInitialized = true;
22566
22567    function createPlayer(details) {
22568      const videoId = details.dataset.videoId;
22569      const start = details.dataset.start;
22570      const end = details.dataset.end;
22571
22572      const container = document.createElement("div");
22573      container.className = "player-container";
22574
22575      const spinner = document.createElement("div");
22576      spinner.className = "spinner";
22577      container.appendChild(spinner);
22578
22579      const controller = document.createElement("media-controller");
22580      controller.setAttribute("defaultsubtitles", "");
22581
22582      const provider = document.createElement("youtube-media-provider");
22583      provider.setAttribute("slot", "media");
22584      provider.setAttribute("video-id", videoId);
22585      provider.setAttribute("start", start);
22586      provider.setAttribute("end", end);
22587
22588      const controlBar = document.createElement("media-control-bar");
22589
22590      const playBtn = document.createElement("media-play-button");
22591      const timeDisplay = document.createElement("media-time-display");
22592      timeDisplay.setAttribute("showduration", "");
22593      const timeRange = document.createElement("media-time-range");
22594      const playbackRate = document.createElement("media-playback-rate-button");
22595      const muteBtn = document.createElement("media-mute-button");
22596      const volumeRange = document.createElement("media-volume-range");
22597
22598      const captionsBtn = document.createElement("media-captions-button");
22599      captionsBtn.addEventListener("click", (e) => {
22600        e.stopPropagation();
22601        const provider = container.querySelector("youtube-media-provider");
22602        if (provider) provider.toggleCaptions();
22603      });
22604
22605      const qualityBtn = document.createElement("media-chrome-button");
22606      qualityBtn.textContent = "--";
22607      qualityBtn.addEventListener("click", () => {
22608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
22609      });
22610
22611      const fullscreenBtn = document.createElement("media-fullscreen-button");
22612
22613      controlBar.append(
22614        playBtn, timeDisplay, timeRange,
22615        playbackRate, muteBtn, volumeRange,
22616        captionsBtn, qualityBtn, fullscreenBtn
22617      );
22618
22619      const poster = document.createElement("media-poster-image");
22620      poster.setAttribute("slot", "poster");
22621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
22622
22623      controller.append(provider, poster, controlBar);
22624
22625      captionsBtn.setAttribute("aria-checked", "true");
22626      controller.setAttribute("mediasubtitlesshowing", "on");
22627
22628      container.appendChild(controller);
22629      details.appendChild(container);
22630
22631      provider.addEventListener("waiting", () => container.classList.add("loading"));
22632      provider.addEventListener("playing", () => container.classList.remove("loading"));
22633      provider.addEventListener("play", () => container.classList.remove("loading"));
22634
22635      provider._textTracks.addEventListener("change", () => {
22636        const enabled = provider._captionsEnabled;
22637        captionsBtn.setAttribute("aria-checked", enabled);
22638        if (enabled) {
22639          controller.setAttribute("mediasubtitlesshowing", "on");
22640        } else {
22641          controller.removeAttribute("mediasubtitlesshowing");
22642        }
22643      });
22644
22645      provider.addEventListener("qualitychange", (e) => {
22646        qualityBtn.textContent = e.detail.quality;
22647      });
22648    }
22649
22650    function initLazyPlayers() {
22651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
22652        if (details.dataset.lazyInit) return;
22653        details.dataset.lazyInit = "true";
22654        details.addEventListener("toggle", () => {
22655          if (details.open && !details.querySelector(".player-container")) {
22656            createPlayer(details);
22657          } else if (!details.open) {
22658            const provider = details.querySelector("youtube-media-provider");
22659            if (provider && typeof provider.pause === "function") {
22660              provider.pause();
22661            }
22662          }
22663        });
22664      });
22665    }
22666
22667    if (document.readyState === "loading") {
22668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
22669    } else {
22670      initLazyPlayers();
22671    }
22672  })();
22673</script>
22673<script>
22674  (() => {
22675    if (window.__courseCompletionsInitialized) return;
22676    window.__courseCompletionsInitialized = true;
22677
22678    const STORAGE_KEY = "course-completions";
22679
22680    function slugify(text) {
22681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
22682    }
22683
22684    function getCompletions() {
22685      try {
22686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
22687      } catch {
22688        return [];
22689      }
22690    }
22691
22692    function saveCompletions(completions) {
22693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
22694    }
22695
22696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
22697    function canonicalFromStored(storedId) {
22698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22699      if (!m) return slugify(storedId);
22700      const [, slug, p, mv, title] = m;
22701      return `${slug}-${p}-${mv}-${slugify(title)}`;
22702    }
22703
22704    function canonicalPlusOne(storedId) {
22705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22706      if (!m) return null;
22707      const [, slug, p, mv, title] = m;
22708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
22709    }
22710
22711    function isCompleted(canonicalId, completions) {
22712      if (completions.includes(canonicalId)) return true;
22713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
22714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
22715    }
22716
22717    function migrateCompletions() {
22718      const completions = getCompletions();
22719      let changed = false;
22720      const migrated = completions.map((c) => {
22721        const canon = canonicalFromStored(c);
22722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
22723        const plusOne = canonicalPlusOne(c);
22724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
22725          if (plusOne !== c) changed = true;
22726          return plusOne;
22727        }
22728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
22729          changed = true;
22730          return canon;
22731        }
22732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
22733        if (canon !== c) {
22734          changed = true;
22735          return canon;
22736        }
22737        return c;
22738      });
22739      // Elimina duplicados tras migración
22740      const deduped = [...new Set(migrated)];
22741      if (changed || deduped.length !== completions.length) {
22742        saveCompletions(deduped);
22743        return deduped;
22744      }
22745      return completions;
22746    }
22747
22748    function updateButtonState(btn, completions) {
22749      const id = btn.dataset.completionId;
22750      const list = completions ?? getCompletions();
22751      btn.classList.toggle("completed", isCompleted(id, list));
22752    }
22753
22754    function checkModuleCompletion(completions) {
22755      const list = completions ?? getCompletions();
22756      document.querySelectorAll(".course-video").forEach((module) => {
22757        const btns = module.querySelectorAll(".complete-btn");
22758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
22759        const check = module.querySelector(".module-check");
22760        if (check) check.classList.toggle("visible", allCompleted);
22761      });
22762    }
22763
22764    function initCompletionButtons() {
22765      const completions = migrateCompletions();
22766
22767      document.querySelectorAll(".complete-btn").forEach((btn) => {
22768        if (btn.dataset.completionInit) return;
22769        btn.dataset.completionInit = "true";
22770        updateButtonState(btn, completions);
22771
22772        btn.addEventListener("click", (e) => {
22773          e.preventDefault();
22774          e.stopPropagation();
22775
22776          const id = btn.dataset.completionId;
22777          let list = getCompletions();
22778          const completed = isCompleted(id, list);
22779
22780          if (completed) {
22781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
22782            list = list.filter((c) =>
22782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
22783          } else {
22784            list.push(id);
22785          }
22786
22787          saveCompletions(list);
22788          updateButtonState(btn, list);
22789          checkModuleCompletion(list);
22790        });
22791      });
22792
22793      checkModuleCompletion(completions);
22794    }
22795
22796    if (document.readyState === "loading") {
22797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
22798    } else {
22799      initCompletionButtons();
22800    }
22801  })();
22802</script>
22802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="7934" data-end="8472" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Grid irregulares</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-grid-irregulares" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
22802<script>
22803  (() => {
22804    if (window.__courseLazyPlayersInitialized) return;
22805    window.__courseLazyPlayersInitialized = true;
22806
22807    function createPlayer(details) {
22808      const videoId = details.dataset.videoId;
22809      const start = details.dataset.start;
22810      const end = details.dataset.end;
22811
22812      const container = document.createElement("div");
22813      container.className = "player-container";
22814
22815      const spinner = document.createElement("div");
22816      spinner.className = "spinner";
22817      container.appendChild(spinner);
22818
22819      const controller = document.createElement("media-controller");
22820      controller.setAttribute("defaultsubtitles", "");
22821
22822      const provider = document.createElement("youtube-media-provider");
22823      provider.setAttribute("slot", "media");
22824      provider.setAttribute("video-id", videoId);
22825      provider.setAttribute("start", start);
22826      provider.setAttribute("end", end);
22827
22828      const controlBar = document.createElement("media-control-bar");
22829
22830      const playBtn = document.createElement("media-play-button");
22831      const timeDisplay = document.createElement("media-time-display");
22832      timeDisplay.setAttribute("showduration", "");
22833      const timeRange = document.createElement("media-time-range");
22834      const playbackRate = document.createElement("media-playback-rate-button");
22835      const muteBtn = document.createElement("media-mute-button");
22836      const volumeRange = document.createElement("media-volume-range");
22837
22838      const captionsBtn = document.createElement("media-captions-button");
22839      captionsBtn.addEventListener("click", (e) => {
22840        e.stopPropagation();
22841        const provider = container.querySelector("youtube-media-provider");
22842        if (provider) provider.toggleCaptions();
22843      });
22844
22845      const qualityBtn = document.createElement("media-chrome-button");
22846      qualityBtn.textContent = "--";
22847      qualityBtn.addEventListener("click", () => {
22848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
22849      });
22850
22851      const fullscreenBtn = document.createElement("media-fullscreen-button");
22852
22853      controlBar.append(
22854        playBtn, timeDisplay, timeRange,
22855        playbackRate, muteBtn, volumeRange,
22856        captionsBtn, qualityBtn, fullscreenBtn
22857      );
22858
22859      const poster = document.createElement("media-poster-image");
22860      poster.setAttribute("slot", "poster");
22861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
22862
22863      controller.append(provider, poster, controlBar);
22864
22865      captionsBtn.setAttribute("aria-checked", "true");
22866      controller.setAttribute("mediasubtitlesshowing", "on");
22867
22868      container.appendChild(controller);
22869      details.appendChild(container);
22870
22871      provider.addEventListener("waiting", () => container.classList.add("loading"));
22872      provider.addEventListener("playing", () => container.classList.remove("loading"));
22873      provider.addEventListener("play", () => container.classList.remove("loading"));
22874
22875      provider._textTracks.addEventListener("change", () => {
22876        const enabled = provider._captionsEnabled;
22877        captionsBtn.setAttribute("aria-checked", enabled);
22878        if (enabled) {
22879          controller.setAttribute("mediasubtitlesshowing", "on");
22880        } else {
22881          controller.removeAttribute("mediasubtitlesshowing");
22882        }
22883      });
22884
22885      provider.addEventListener("qualitychange", (e) => {
22886        qualityBtn.textContent = e.detail.quality;
22887      });
22888    }
22889
22890    function initLazyPlayers() {
22891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
22892        if (details.dataset.lazyInit) return;
22893        details.dataset.lazyInit = "true";
22894        details.addEventListener("toggle", () => {
22895          if (details.open && !details.querySelector(".player-container")) {
22896            createPlayer(details);
22897          } else if (!details.open) {
22898            const provider = details.querySelector("youtube-media-provider");
22899            if (provider && typeof provider.pause === "function") {
22900              provider.pause();
22901            }
22902          }
22903        });
22904      });
22905    }
22906
22907    if (document.readyState === "loading") {
22908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
22909    } else {
22910      initLazyPlayers();
22911    }
22912  })();
22913</script>
22913<script>
22914  (() => {
22915    if (window.__courseCompletionsInitialized) return;
22916    window.__courseCompletionsInitialized = true;
22917
22918    const STORAGE_KEY = "course-completions";
22919
22920    function slugify(text) {
22921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
22922    }
22923
22924    function getCompletions() {
22925      try {
22926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
22927      } catch {
22928        return [];
22929      }
22930    }
22931
22932    function saveCompletions(completions) {
22933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
22934    }
22935
22936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
22937    function canonicalFromStored(storedId) {
22938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22939      if (!m) return slugify(storedId);
22940      const [, slug, p, mv, title] = m;
22941      return `${slug}-${p}-${mv}-${slugify(title)}`;
22942    }
22943
22944    function canonicalPlusOne(storedId) {
22945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
22946      if (!m) return null;
22947      const [, slug, p, mv, title] = m;
22948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
22949    }
22950
22951    function isCompleted(canonicalId, completions) {
22952      if (completions.includes(canonicalId)) return true;
22953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
22954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
22955    }
22956
22957    function migrateCompletions() {
22958      const completions = getCompletions();
22959      let changed = false;
22960      const migrated = completions.map((c) => {
22961        const canon = canonicalFromStored(c);
22962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
22963        const plusOne = canonicalPlusOne(c);
22964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
22965          if (plusOne !== c) changed = true;
22966          return plusOne;
22967        }
22968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
22969          changed = true;
22970          return canon;
22971        }
22972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
22973        if (canon !== c) {
22974          changed = true;
22975          return canon;
22976        }
22977        return c;
22978      });
22979      // Elimina duplicados tras migración
22980      const deduped = [...new Set(migrated)];
22981      if (changed || deduped.length !== completions.length) {
22982        saveCompletions(deduped);
22983        return deduped;
22984      }
22985      return completions;
22986    }
22987
22988    function updateButtonState(btn, completions) {
22989      const id = btn.dataset.completionId;
22990      const list = completions ?? getCompletions();
22991      btn.classList.toggle("completed", isCompleted(id, list));
22992    }
22993
22994    function checkModuleCompletion(completions) {
22995      const list = completions ?? getCompletions();
22996      document.querySelectorAll(".course-video").forEach((module) => {
22997        const btns = module.querySelectorAll(".complete-btn");
22998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
22999        const check = module.querySelector(".module-check");
23000        if (check) check.classList.toggle("visible", allCompleted);
23001      });
23002    }
23003
23004    function initCompletionButtons() {
23005      const completions = migrateCompletions();
23006
23007      document.querySelectorAll(".complete-btn").forEach((btn) => {
23008        if (btn.dataset.completionInit) return;
23009        btn.dataset.completionInit = "true";
23010        updateButtonState(btn, completions);
23011
23012        btn.addEventListener("click", (e) => {
23013          e.preventDefault();
23014          e.stopPropagation();
23015
23016          const id = btn.dataset.completionId;
23017          let list = getCompletions();
23018          const completed = isCompleted(id, list);
23019
23020          if (completed) {
23021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
23022            list = list.filter((c) =>
23022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
23023          } else {
23024            list.push(id);
23025          }
23026
23027          saveCompletions(list);
23028          updateButtonState(btn, list);
23029          checkModuleCompletion(list);
23030        });
23031      });
23032
23033      checkModuleCompletion(completions);
23034    }
23035
23036    if (document.readyState === "loading") {
23037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
23038    } else {
23039      initCompletionButtons();
23040    }
23041  })();
23042</script>
23042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="8472" data-end="9138" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Grid lanes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-grid-lanes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
23042<script>
23043  (() => {
23044    if (window.__courseLazyPlayersInitialized) return;
23045    window.__courseLazyPlayersInitialized = true;
23046
23047    function createPlayer(details) {
23048      const videoId = details.dataset.videoId;
23049      const start = details.dataset.start;
23050      const end = details.dataset.end;
23051
23052      const container = document.createElement("div");
23053      container.className = "player-container";
23054
23055      const spinner = document.createElement("div");
23056      spinner.className = "spinner";
23057      container.appendChild(spinner);
23058
23059      const controller = document.createElement("media-controller");
23060      controller.setAttribute("defaultsubtitles", "");
23061
23062      const provider = document.createElement("youtube-media-provider");
23063      provider.setAttribute("slot", "media");
23064      provider.setAttribute("video-id", videoId);
23065      provider.setAttribute("start", start);
23066      provider.setAttribute("end", end);
23067
23068      const controlBar = document.createElement("media-control-bar");
23069
23070      const playBtn = document.createElement("media-play-button");
23071      const timeDisplay = document.createElement("media-time-display");
23072      timeDisplay.setAttribute("showduration", "");
23073      const timeRange = document.createElement("media-time-range");
23074      const playbackRate = document.createElement("media-playback-rate-button");
23075      const muteBtn = document.createElement("media-mute-button");
23076      const volumeRange = document.createElement("media-volume-range");
23077
23078      const captionsBtn = document.createElement("media-captions-button");
23079      captionsBtn.addEventListener("click", (e) => {
23080        e.stopPropagation();
23081        const provider = container.querySelector("youtube-media-provider");
23082        if (provider) provider.toggleCaptions();
23083      });
23084
23085      const qualityBtn = document.createElement("media-chrome-button");
23086      qualityBtn.textContent = "--";
23087      qualityBtn.addEventListener("click", () => {
23088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
23089      });
23090
23091      const fullscreenBtn = document.createElement("media-fullscreen-button");
23092
23093      controlBar.append(
23094        playBtn, timeDisplay, timeRange,
23095        playbackRate, muteBtn, volumeRange,
23096        captionsBtn, qualityBtn, fullscreenBtn
23097      );
23098
23099      const poster = document.createElement("media-poster-image");
23100      poster.setAttribute("slot", "poster");
23101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
23102
23103      controller.append(provider, poster, controlBar);
23104
23105      captionsBtn.setAttribute("aria-checked", "true");
23106      controller.setAttribute("mediasubtitlesshowing", "on");
23107
23108      container.appendChild(controller);
23109      details.appendChild(container);
23110
23111      provider.addEventListener("waiting", () => container.classList.add("loading"));
23112      provider.addEventListener("playing", () => container.classList.remove("loading"));
23113      provider.addEventListener("play", () => container.classList.remove("loading"));
23114
23115      provider._textTracks.addEventListener("change", () => {
23116        const enabled = provider._captionsEnabled;
23117        captionsBtn.setAttribute("aria-checked", enabled);
23118        if (enabled) {
23119          controller.setAttribute("mediasubtitlesshowing", "on");
23120        } else {
23121          controller.removeAttribute("mediasubtitlesshowing");
23122        }
23123      });
23124
23125      provider.addEventListener("qualitychange", (e) => {
23126        qualityBtn.textContent = e.detail.quality;
23127      });
23128    }
23129
23130    function initLazyPlayers() {
23131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
23132        if (details.dataset.lazyInit) return;
23133        details.dataset.lazyInit = "true";
23134        details.addEventListener("toggle", () => {
23135          if (details.open && !details.querySelector(".player-container")) {
23136            createPlayer(details);
23137          } else if (!details.open) {
23138            const provider = details.querySelector("youtube-media-provider");
23139            if (provider && typeof provider.pause === "function") {
23140              provider.pause();
23141            }
23142          }
23143        });
23144      });
23145    }
23146
23147    if (document.readyState === "loading") {
23148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
23149    } else {
23150      initLazyPlayers();
23151    }
23152  })();
23153</script>
23153<script>
23154  (() => {
23155    if (window.__courseCompletionsInitialized) return;
23156    window.__courseCompletionsInitialized = true;
23157
23158    const STORAGE_KEY = "course-completions";
23159
23160    function slugify(text) {
23161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
23162    }
23163
23164    function getCompletions() {
23165      try {
23166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
23167      } catch {
23168        return [];
23169      }
23170    }
23171
23172    function saveCompletions(completions) {
23173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
23174    }
23175
23176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
23177    function canonicalFromStored(storedId) {
23178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23179      if (!m) return slugify(storedId);
23180      const [, slug, p, mv, title] = m;
23181      return `${slug}-${p}-${mv}-${slugify(title)}`;
23182    }
23183
23184    function canonicalPlusOne(storedId) {
23185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23186      if (!m) return null;
23187      const [, slug, p, mv, title] = m;
23188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
23189    }
23190
23191    function isCompleted(canonicalId, completions) {
23192      if (completions.includes(canonicalId)) return true;
23193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
23194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
23195    }
23196
23197    function migrateCompletions() {
23198      const completions = getCompletions();
23199      let changed = false;
23200      const migrated = completions.map((c) => {
23201        const canon = canonicalFromStored(c);
23202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
23203        const plusOne = canonicalPlusOne(c);
23204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
23205          if (plusOne !== c) changed = true;
23206          return plusOne;
23207        }
23208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
23209          changed = true;
23210          return canon;
23211        }
23212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
23213        if (canon !== c) {
23214          changed = true;
23215          return canon;
23216        }
23217        return c;
23218      });
23219      // Elimina duplicados tras migración
23220      const deduped = [...new Set(migrated)];
23221      if (changed || deduped.length !== completions.length) {
23222        saveCompletions(deduped);
23223        return deduped;
23224      }
23225      return completions;
23226    }
23227
23228    function updateButtonState(btn, completions) {
23229      const id = btn.dataset.completionId;
23230      const list = completions ?? getCompletions();
23231      btn.classList.toggle("completed", isCompleted(id, list));
23232    }
23233
23234    function checkModuleCompletion(completions) {
23235      const list = completions ?? getCompletions();
23236      document.querySelectorAll(".course-video").forEach((module) => {
23237        const btns = module.querySelectorAll(".complete-btn");
23238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
23239        const check = module.querySelector(".module-check");
23240        if (check) check.classList.toggle("visible", allCompleted);
23241      });
23242    }
23243
23244    function initCompletionButtons() {
23245      const completions = migrateCompletions();
23246
23247      document.querySelectorAll(".complete-btn").forEach((btn) => {
23248        if (btn.dataset.completionInit) return;
23249        btn.dataset.completionInit = "true";
23250        updateButtonState(btn, completions);
23251
23252        btn.addEventListener("click", (e) => {
23253          e.preventDefault();
23254          e.stopPropagation();
23255
23256          const id = btn.dataset.completionId;
23257          let list = getCompletions();
23258          const completed = isCompleted(id, list);
23259
23260          if (completed) {
23261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
23262            list = list.filter((c) =>
23262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
23263          } else {
23264            list.push(id);
23265          }
23266
23267          saveCompletions(list);
23268          updateButtonState(btn, list);
23269          checkModuleCompletion(list);
23270        });
23271      });
23272
23273      checkModuleCompletion(completions);
23274    }
23275
23276    if (document.readyState === "loading") {
23277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
23278    } else {
23279      initCompletionButtons();
23280    }
23281  })();
23282</script>
23282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="9566" data-end="11268" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Position</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-position" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
23282<script>
23283  (() => {
23284    if (window.__courseLazyPlayersInitialized) return;
23285    window.__courseLazyPlayersInitialized = true;
23286
23287    function createPlayer(details) {
23288      const videoId = details.dataset.videoId;
23289      const start = details.dataset.start;
23290      const end = details.dataset.end;
23291
23292      const container = document.createElement("div");
23293      container.className = "player-container";
23294
23295      const spinner = document.createElement("div");
23296      spinner.className = "spinner";
23297      container.appendChild(spinner);
23298
23299      const controller = document.createElement("media-controller");
23300      controller.setAttribute("defaultsubtitles", "");
23301
23302      const provider = document.createElement("youtube-media-provider");
23303      provider.setAttribute("slot", "media");
23304      provider.setAttribute("video-id", videoId);
23305      provider.setAttribute("start", start);
23306      provider.setAttribute("end", end);
23307
23308      const controlBar = document.createElement("media-control-bar");
23309
23310      const playBtn = document.createElement("media-play-button");
23311      const timeDisplay = document.createElement("media-time-display");
23312      timeDisplay.setAttribute("showduration", "");
23313      const timeRange = document.createElement("media-time-range");
23314      const playbackRate = document.createElement("media-playback-rate-button");
23315      const muteBtn = document.createElement("media-mute-button");
23316      const volumeRange = document.createElement("media-volume-range");
23317
23318      const captionsBtn = document.createElement("media-captions-button");
23319      captionsBtn.addEventListener("click", (e) => {
23320        e.stopPropagation();
23321        const provider = container.querySelector("youtube-media-provider");
23322        if (provider) provider.toggleCaptions();
23323      });
23324
23325      const qualityBtn = document.createElement("media-chrome-button");
23326      qualityBtn.textContent = "--";
23327      qualityBtn.addEventListener("click", () => {
23328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
23329      });
23330
23331      const fullscreenBtn = document.createElement("media-fullscreen-button");
23332
23333      controlBar.append(
23334        playBtn, timeDisplay, timeRange,
23335        playbackRate, muteBtn, volumeRange,
23336        captionsBtn, qualityBtn, fullscreenBtn
23337      );
23338
23339      const poster = document.createElement("media-poster-image");
23340      poster.setAttribute("slot", "poster");
23341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
23342
23343      controller.append(provider, poster, controlBar);
23344
23345      captionsBtn.setAttribute("aria-checked", "true");
23346      controller.setAttribute("mediasubtitlesshowing", "on");
23347
23348      container.appendChild(controller);
23349      details.appendChild(container);
23350
23351      provider.addEventListener("waiting", () => container.classList.add("loading"));
23352      provider.addEventListener("playing", () => container.classList.remove("loading"));
23353      provider.addEventListener("play", () => container.classList.remove("loading"));
23354
23355      provider._textTracks.addEventListener("change", () => {
23356        const enabled = provider._captionsEnabled;
23357        captionsBtn.setAttribute("aria-checked", enabled);
23358        if (enabled) {
23359          controller.setAttribute("mediasubtitlesshowing", "on");
23360        } else {
23361          controller.removeAttribute("mediasubtitlesshowing");
23362        }
23363      });
23364
23365      provider.addEventListener("qualitychange", (e) => {
23366        qualityBtn.textContent = e.detail.quality;
23367      });
23368    }
23369
23370    function initLazyPlayers() {
23371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
23372        if (details.dataset.lazyInit) return;
23373        details.dataset.lazyInit = "true";
23374        details.addEventListener("toggle", () => {
23375          if (details.open && !details.querySelector(".player-container")) {
23376            createPlayer(details);
23377          } else if (!details.open) {
23378            const provider = details.querySelector("youtube-media-provider");
23379            if (provider && typeof provider.pause === "function") {
23380              provider.pause();
23381            }
23382          }
23383        });
23384      });
23385    }
23386
23387    if (document.readyState === "loading") {
23388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
23389    } else {
23390      initLazyPlayers();
23391    }
23392  })();
23393</script>
23393<script>
23394  (() => {
23395    if (window.__courseCompletionsInitialized) return;
23396    window.__courseCompletionsInitialized = true;
23397
23398    const STORAGE_KEY = "course-completions";
23399
23400    function slugify(text) {
23401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
23402    }
23403
23404    function getCompletions() {
23405      try {
23406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
23407      } catch {
23408        return [];
23409      }
23410    }
23411
23412    function saveCompletions(completions) {
23413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
23414    }
23415
23416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
23417    function canonicalFromStored(storedId) {
23418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23419      if (!m) return slugify(storedId);
23420      const [, slug, p, mv, title] = m;
23421      return `${slug}-${p}-${mv}-${slugify(title)}`;
23422    }
23423
23424    function canonicalPlusOne(storedId) {
23425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23426      if (!m) return null;
23427      const [, slug, p, mv, title] = m;
23428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
23429    }
23430
23431    function isCompleted(canonicalId, completions) {
23432      if (completions.includes(canonicalId)) return true;
23433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
23434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
23435    }
23436
23437    function migrateCompletions() {
23438      const completions = getCompletions();
23439      let changed = false;
23440      const migrated = completions.map((c) => {
23441        const canon = canonicalFromStored(c);
23442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
23443        const plusOne = canonicalPlusOne(c);
23444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
23445          if (plusOne !== c) changed = true;
23446          return plusOne;
23447        }
23448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
23449          changed = true;
23450          return canon;
23451        }
23452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
23453        if (canon !== c) {
23454          changed = true;
23455          return canon;
23456        }
23457        return c;
23458      });
23459      // Elimina duplicados tras migración
23460      const deduped = [...new Set(migrated)];
23461      if (changed || deduped.length !== completions.length) {
23462        saveCompletions(deduped);
23463        return deduped;
23464      }
23465      return completions;
23466    }
23467
23468    function updateButtonState(btn, completions) {
23469      const id = btn.dataset.completionId;
23470      const list = completions ?? getCompletions();
23471      btn.classList.toggle("completed", isCompleted(id, list));
23472    }
23473
23474    function checkModuleCompletion(completions) {
23475      const list = completions ?? getCompletions();
23476      document.querySelectorAll(".course-video").forEach((module) => {
23477        const btns = module.querySelectorAll(".complete-btn");
23478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
23479        const check = module.querySelector(".module-check");
23480        if (check) check.classList.toggle("visible", allCompleted);
23481      });
23482    }
23483
23484    function initCompletionButtons() {
23485      const completions = migrateCompletions();
23486
23487      document.querySelectorAll(".complete-btn").forEach((btn) => {
23488        if (btn.dataset.completionInit) return;
23489        btn.dataset.completionInit = "true";
23490        updateButtonState(btn, completions);
23491
23492        btn.addEventListener("click", (e) => {
23493          e.preventDefault();
23494          e.stopPropagation();
23495
23496          const id = btn.dataset.completionId;
23497          let list = getCompletions();
23498          const completed = isCompleted(id, list);
23499
23500          if (completed) {
23501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
23502            list = list.filter((c) =>
23502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
23503          } else {
23504            list.push(id);
23505          }
23506
23507          saveCompletions(list);
23508          updateButtonState(btn, list);
23509          checkModuleCompletion(list);
23510        });
23511      });
23512
23513      checkModuleCompletion(completions);
23514    }
23515
23516    if (document.readyState === "loading") {
23517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
23518    } else {
23519      initCompletionButtons();
23520    }
23521  })();
23522</script>
23522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="11268" data-end="11618" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Anchor position</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>6 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-anchor-position" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
23522<script>
23523  (() => {
23524    if (window.__courseLazyPlayersInitialized) return;
23525    window.__courseLazyPlayersInitialized = true;
23526
23527    function createPlayer(details) {
23528      const videoId = details.dataset.videoId;
23529      const start = details.dataset.start;
23530      const end = details.dataset.end;
23531
23532      const container = document.createElement("div");
23533      container.className = "player-container";
23534
23535      const spinner = document.createElement("div");
23536      spinner.className = "spinner";
23537      container.appendChild(spinner);
23538
23539      const controller = document.createElement("media-controller");
23540      controller.setAttribute("defaultsubtitles", "");
23541
23542      const provider = document.createElement("youtube-media-provider");
23543      provider.setAttribute("slot", "media");
23544      provider.setAttribute("video-id", videoId);
23545      provider.setAttribute("start", start);
23546      provider.setAttribute("end", end);
23547
23548      const controlBar = document.createElement("media-control-bar");
23549
23550      const playBtn = document.createElement("media-play-button");
23551      const timeDisplay = document.createElement("media-time-display");
23552      timeDisplay.setAttribute("showduration", "");
23553      const timeRange = document.createElement("media-time-range");
23554      const playbackRate = document.createElement("media-playback-rate-button");
23555      const muteBtn = document.createElement("media-mute-button");
23556      const volumeRange = document.createElement("media-volume-range");
23557
23558      const captionsBtn = document.createElement("media-captions-button");
23559      captionsBtn.addEventListener("click", (e) => {
23560        e.stopPropagation();
23561        const provider = container.querySelector("youtube-media-provider");
23562        if (provider) provider.toggleCaptions();
23563      });
23564
23565      const qualityBtn = document.createElement("media-chrome-button");
23566      qualityBtn.textContent = "--";
23567      qualityBtn.addEventListener("click", () => {
23568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
23569      });
23570
23571      const fullscreenBtn = document.createElement("media-fullscreen-button");
23572
23573      controlBar.append(
23574        playBtn, timeDisplay, timeRange,
23575        playbackRate, muteBtn, volumeRange,
23576        captionsBtn, qualityBtn, fullscreenBtn
23577      );
23578
23579      const poster = document.createElement("media-poster-image");
23580      poster.setAttribute("slot", "poster");
23581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
23582
23583      controller.append(provider, poster, controlBar);
23584
23585      captionsBtn.setAttribute("aria-checked", "true");
23586      controller.setAttribute("mediasubtitlesshowing", "on");
23587
23588      container.appendChild(controller);
23589      details.appendChild(container);
23590
23591      provider.addEventListener("waiting", () => container.classList.add("loading"));
23592      provider.addEventListener("playing", () => container.classList.remove("loading"));
23593      provider.addEventListener("play", () => container.classList.remove("loading"));
23594
23595      provider._textTracks.addEventListener("change", () => {
23596        const enabled = provider._captionsEnabled;
23597        captionsBtn.setAttribute("aria-checked", enabled);
23598        if (enabled) {
23599          controller.setAttribute("mediasubtitlesshowing", "on");
23600        } else {
23601          controller.removeAttribute("mediasubtitlesshowing");
23602        }
23603      });
23604
23605      provider.addEventListener("qualitychange", (e) => {
23606        qualityBtn.textContent = e.detail.quality;
23607      });
23608    }
23609
23610    function initLazyPlayers() {
23611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
23612        if (details.dataset.lazyInit) return;
23613        details.dataset.lazyInit = "true";
23614        details.addEventListener("toggle", () => {
23615          if (details.open && !details.querySelector(".player-container")) {
23616            createPlayer(details);
23617          } else if (!details.open) {
23618            const provider = details.querySelector("youtube-media-provider");
23619            if (provider && typeof provider.pause === "function") {
23620              provider.pause();
23621            }
23622          }
23623        });
23624      });
23625    }
23626
23627    if (document.readyState === "loading") {
23628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
23629    } else {
23630      initLazyPlayers();
23631    }
23632  })();
23633</script>
23633<script>
23634  (() => {
23635    if (window.__courseCompletionsInitialized) return;
23636    window.__courseCompletionsInitialized = true;
23637
23638    const STORAGE_KEY = "course-completions";
23639
23640    function slugify(text) {
23641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
23642    }
23643
23644    function getCompletions() {
23645      try {
23646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
23647      } catch {
23648        return [];
23649      }
23650    }
23651
23652    function saveCompletions(completions) {
23653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
23654    }
23655
23656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
23657    function canonicalFromStored(storedId) {
23658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23659      if (!m) return slugify(storedId);
23660      const [, slug, p, mv, title] = m;
23661      return `${slug}-${p}-${mv}-${slugify(title)}`;
23662    }
23663
23664    function canonicalPlusOne(storedId) {
23665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23666      if (!m) return null;
23667      const [, slug, p, mv, title] = m;
23668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
23669    }
23670
23671    function isCompleted(canonicalId, completions) {
23672      if (completions.includes(canonicalId)) return true;
23673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
23674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
23675    }
23676
23677    function migrateCompletions() {
23678      const completions = getCompletions();
23679      let changed = false;
23680      const migrated = completions.map((c) => {
23681        const canon = canonicalFromStored(c);
23682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
23683        const plusOne = canonicalPlusOne(c);
23684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
23685          if (plusOne !== c) changed = true;
23686          return plusOne;
23687        }
23688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
23689          changed = true;
23690          return canon;
23691        }
23692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
23693        if (canon !== c) {
23694          changed = true;
23695          return canon;
23696        }
23697        return c;
23698      });
23699      // Elimina duplicados tras migración
23700      const deduped = [...new Set(migrated)];
23701      if (changed || deduped.length !== completions.length) {
23702        saveCompletions(deduped);
23703        return deduped;
23704      }
23705      return completions;
23706    }
23707
23708    function updateButtonState(btn, completions) {
23709      const id = btn.dataset.completionId;
23710      const list = completions ?? getCompletions();
23711      btn.classList.toggle("completed", isCompleted(id, list));
23712    }
23713
23714    function checkModuleCompletion(completions) {
23715      const list = completions ?? getCompletions();
23716      document.querySelectorAll(".course-video").forEach((module) => {
23717        const btns = module.querySelectorAll(".complete-btn");
23718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
23719        const check = module.querySelector(".module-check");
23720        if (check) check.classList.toggle("visible", allCompleted);
23721      });
23722    }
23723
23724    function initCompletionButtons() {
23725      const completions = migrateCompletions();
23726
23727      document.querySelectorAll(".complete-btn").forEach((btn) => {
23728        if (btn.dataset.completionInit) return;
23729        btn.dataset.completionInit = "true";
23730        updateButtonState(btn, completions);
23731
23732        btn.addEventListener("click", (e) => {
23733          e.preventDefault();
23734          e.stopPropagation();
23735
23736          const id = btn.dataset.completionId;
23737          let list = getCompletions();
23738          const completed = isCompleted(id, list);
23739
23740          if (completed) {
23741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
23742            list = list.filter((c) =>
23742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
23743          } else {
23744            list.push(id);
23745          }
23746
23747          saveCompletions(list);
23748          updateButtonState(btn, list);
23749          checkModuleCompletion(list);
23750        });
23751      });
23752
23753      checkModuleCompletion(completions);
23754    }
23755
23756    if (document.readyState === "loading") {
23757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
23758    } else {
23759      initCompletionButtons();
23760    }
23761  })();
23762</script>
23762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="11618" data-end="13687" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Animaciones</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>34 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-animaciones" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
23762<script>
23763  (() => {
23764    if (window.__courseLazyPlayersInitialized) return;
23765    window.__courseLazyPlayersInitialized = true;
23766
23767    function createPlayer(details) {
23768      const videoId = details.dataset.videoId;
23769      const start = details.dataset.start;
23770      const end = details.dataset.end;
23771
23772      const container = document.createElement("div");
23773      container.className = "player-container";
23774
23775      const spinner = document.createElement("div");
23776      spinner.className = "spinner";
23777      container.appendChild(spinner);
23778
23779      const controller = document.createElement("media-controller");
23780      controller.setAttribute("defaultsubtitles", "");
23781
23782      const provider = document.createElement("youtube-media-provider");
23783      provider.setAttribute("slot", "media");
23784      provider.setAttribute("video-id", videoId);
23785      provider.setAttribute("start", start);
23786      provider.setAttribute("end", end);
23787
23788      const controlBar = document.createElement("media-control-bar");
23789
23790      const playBtn = document.createElement("media-play-button");
23791      const timeDisplay = document.createElement("media-time-display");
23792      timeDisplay.setAttribute("showduration", "");
23793      const timeRange = document.createElement("media-time-range");
23794      const playbackRate = document.createElement("media-playback-rate-button");
23795      const muteBtn = document.createElement("media-mute-button");
23796      const volumeRange = document.createElement("media-volume-range");
23797
23798      const captionsBtn = document.createElement("media-captions-button");
23799      captionsBtn.addEventListener("click", (e) => {
23800        e.stopPropagation();
23801        const provider = container.querySelector("youtube-media-provider");
23802        if (provider) provider.toggleCaptions();
23803      });
23804
23805      const qualityBtn = document.createElement("media-chrome-button");
23806      qualityBtn.textContent = "--";
23807      qualityBtn.addEventListener("click", () => {
23808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
23809      });
23810
23811      const fullscreenBtn = document.createElement("media-fullscreen-button");
23812
23813      controlBar.append(
23814        playBtn, timeDisplay, timeRange,
23815        playbackRate, muteBtn, volumeRange,
23816        captionsBtn, qualityBtn, fullscreenBtn
23817      );
23818
23819      const poster = document.createElement("media-poster-image");
23820      poster.setAttribute("slot", "poster");
23821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
23822
23823      controller.append(provider, poster, controlBar);
23824
23825      captionsBtn.setAttribute("aria-checked", "true");
23826      controller.setAttribute("mediasubtitlesshowing", "on");
23827
23828      container.appendChild(controller);
23829      details.appendChild(container);
23830
23831      provider.addEventListener("waiting", () => container.classList.add("loading"));
23832      provider.addEventListener("playing", () => container.classList.remove("loading"));
23833      provider.addEventListener("play", () => container.classList.remove("loading"));
23834
23835      provider._textTracks.addEventListener("change", () => {
23836        const enabled = provider._captionsEnabled;
23837        captionsBtn.setAttribute("aria-checked", enabled);
23838        if (enabled) {
23839          controller.setAttribute("mediasubtitlesshowing", "on");
23840        } else {
23841          controller.removeAttribute("mediasubtitlesshowing");
23842        }
23843      });
23844
23845      provider.addEventListener("qualitychange", (e) => {
23846        qualityBtn.textContent = e.detail.quality;
23847      });
23848    }
23849
23850    function initLazyPlayers() {
23851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
23852        if (details.dataset.lazyInit) return;
23853        details.dataset.lazyInit = "true";
23854        details.addEventListener("toggle", () => {
23855          if (details.open && !details.querySelector(".player-container")) {
23856            createPlayer(details);
23857          } else if (!details.open) {
23858            const provider = details.querySelector("youtube-media-provider");
23859            if (provider && typeof provider.pause === "function") {
23860              provider.pause();
23861            }
23862          }
23863        });
23864      });
23865    }
23866
23867    if (document.readyState === "loading") {
23868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
23869    } else {
23870      initLazyPlayers();
23871    }
23872  })();
23873</script>
23873<script>
23874  (() => {
23875    if (window.__courseCompletionsInitialized) return;
23876    window.__courseCompletionsInitialized = true;
23877
23878    const STORAGE_KEY = "course-completions";
23879
23880    function slugify(text) {
23881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
23882    }
23883
23884    function getCompletions() {
23885      try {
23886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
23887      } catch {
23888        return [];
23889      }
23890    }
23891
23892    function saveCompletions(completions) {
23893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
23894    }
23895
23896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
23897    function canonicalFromStored(storedId) {
23898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23899      if (!m) return slugify(storedId);
23900      const [, slug, p, mv, title] = m;
23901      return `${slug}-${p}-${mv}-${slugify(title)}`;
23902    }
23903
23904    function canonicalPlusOne(storedId) {
23905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
23906      if (!m) return null;
23907      const [, slug, p, mv, title] = m;
23908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
23909    }
23910
23911    function isCompleted(canonicalId, completions) {
23912      if (completions.includes(canonicalId)) return true;
23913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
23914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
23915    }
23916
23917    function migrateCompletions() {
23918      const completions = getCompletions();
23919      let changed = false;
23920      const migrated = completions.map((c) => {
23921        const canon = canonicalFromStored(c);
23922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
23923        const plusOne = canonicalPlusOne(c);
23924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
23925          if (plusOne !== c) changed = true;
23926          return plusOne;
23927        }
23928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
23929          changed = true;
23930          return canon;
23931        }
23932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
23933        if (canon !== c) {
23934          changed = true;
23935          return canon;
23936        }
23937        return c;
23938      });
23939      // Elimina duplicados tras migración
23940      const deduped = [...new Set(migrated)];
23941      if (changed || deduped.length !== completions.length) {
23942        saveCompletions(deduped);
23943        return deduped;
23944      }
23945      return completions;
23946    }
23947
23948    function updateButtonState(btn, completions) {
23949      const id = btn.dataset.completionId;
23950      const list = completions ?? getCompletions();
23951      btn.classList.toggle("completed", isCompleted(id, list));
23952    }
23953
23954    function checkModuleCompletion(completions) {
23955      const list = completions ?? getCompletions();
23956      document.querySelectorAll(".course-video").forEach((module) => {
23957        const btns = module.querySelectorAll(".complete-btn");
23958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
23959        const check = module.querySelector(".module-check");
23960        if (check) check.classList.toggle("visible", allCompleted);
23961      });
23962    }
23963
23964    function initCompletionButtons() {
23965      const completions = migrateCompletions();
23966
23967      document.querySelectorAll(".complete-btn").forEach((btn) => {
23968        if (btn.dataset.completionInit) return;
23969        btn.dataset.completionInit = "true";
23970        updateButtonState(btn, completions);
23971
23972        btn.addEventListener("click", (e) => {
23973          e.preventDefault();
23974          e.stopPropagation();
23975
23976          const id = btn.dataset.completionId;
23977          let list = getCompletions();
23978          const completed = isCompleted(id, list);
23979
23980          if (completed) {
23981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
23982            list = list.filter((c) =>
23982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
23983          } else {
23984            list.push(id);
23985          }
23986
23987          saveCompletions(list);
23988          updateButtonState(btn, list);
23989          checkModuleCompletion(list);
23990        });
23991      });
23992
23993      checkModuleCompletion(completions);
23994    }
23995
23996    if (document.readyState === "loading") {
23997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
23998    } else {
23999      initCompletionButtons();
24000    }
24001  })();
24002</script>
24002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="13687" data-end="14779" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Ritmos (Easing)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-ritmos-easing" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
24002<script>
24003  (() => {
24004    if (window.__courseLazyPlayersInitialized) return;
24005    window.__courseLazyPlayersInitialized = true;
24006
24007    function createPlayer(details) {
24008      const videoId = details.dataset.videoId;
24009      const start = details.dataset.start;
24010      const end = details.dataset.end;
24011
24012      const container = document.createElement("div");
24013      container.className = "player-container";
24014
24015      const spinner = document.createElement("div");
24016      spinner.className = "spinner";
24017      container.appendChild(spinner);
24018
24019      const controller = document.createElement("media-controller");
24020      controller.setAttribute("defaultsubtitles", "");
24021
24022      const provider = document.createElement("youtube-media-provider");
24023      provider.setAttribute("slot", "media");
24024      provider.setAttribute("video-id", videoId);
24025      provider.setAttribute("start", start);
24026      provider.setAttribute("end", end);
24027
24028      const controlBar = document.createElement("media-control-bar");
24029
24030      const playBtn = document.createElement("media-play-button");
24031      const timeDisplay = document.createElement("media-time-display");
24032      timeDisplay.setAttribute("showduration", "");
24033      const timeRange = document.createElement("media-time-range");
24034      const playbackRate = document.createElement("media-playback-rate-button");
24035      const muteBtn = document.createElement("media-mute-button");
24036      const volumeRange = document.createElement("media-volume-range");
24037
24038      const captionsBtn = document.createElement("media-captions-button");
24039      captionsBtn.addEventListener("click", (e) => {
24040        e.stopPropagation();
24041        const provider = container.querySelector("youtube-media-provider");
24042        if (provider) provider.toggleCaptions();
24043      });
24044
24045      const qualityBtn = document.createElement("media-chrome-button");
24046      qualityBtn.textContent = "--";
24047      qualityBtn.addEventListener("click", () => {
24048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
24049      });
24050
24051      const fullscreenBtn = document.createElement("media-fullscreen-button");
24052
24053      controlBar.append(
24054        playBtn, timeDisplay, timeRange,
24055        playbackRate, muteBtn, volumeRange,
24056        captionsBtn, qualityBtn, fullscreenBtn
24057      );
24058
24059      const poster = document.createElement("media-poster-image");
24060      poster.setAttribute("slot", "poster");
24061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
24062
24063      controller.append(provider, poster, controlBar);
24064
24065      captionsBtn.setAttribute("aria-checked", "true");
24066      controller.setAttribute("mediasubtitlesshowing", "on");
24067
24068      container.appendChild(controller);
24069      details.appendChild(container);
24070
24071      provider.addEventListener("waiting", () => container.classList.add("loading"));
24072      provider.addEventListener("playing", () => container.classList.remove("loading"));
24073      provider.addEventListener("play", () => container.classList.remove("loading"));
24074
24075      provider._textTracks.addEventListener("change", () => {
24076        const enabled = provider._captionsEnabled;
24077        captionsBtn.setAttribute("aria-checked", enabled);
24078        if (enabled) {
24079          controller.setAttribute("mediasubtitlesshowing", "on");
24080        } else {
24081          controller.removeAttribute("mediasubtitlesshowing");
24082        }
24083      });
24084
24085      provider.addEventListener("qualitychange", (e) => {
24086        qualityBtn.textContent = e.detail.quality;
24087      });
24088    }
24089
24090    function initLazyPlayers() {
24091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
24092        if (details.dataset.lazyInit) return;
24093        details.dataset.lazyInit = "true";
24094        details.addEventListener("toggle", () => {
24095          if (details.open && !details.querySelector(".player-container")) {
24096            createPlayer(details);
24097          } else if (!details.open) {
24098            const provider = details.querySelector("youtube-media-provider");
24099            if (provider && typeof provider.pause === "function") {
24100              provider.pause();
24101            }
24102          }
24103        });
24104      });
24105    }
24106
24107    if (document.readyState === "loading") {
24108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
24109    } else {
24110      initLazyPlayers();
24111    }
24112  })();
24113</script>
24113<script>
24114  (() => {
24115    if (window.__courseCompletionsInitialized) return;
24116    window.__courseCompletionsInitialized = true;
24117
24118    const STORAGE_KEY = "course-completions";
24119
24120    function slugify(text) {
24121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24122    }
24123
24124    function getCompletions() {
24125      try {
24126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
24127      } catch {
24128        return [];
24129      }
24130    }
24131
24132    function saveCompletions(completions) {
24133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
24134    }
24135
24136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
24137    function canonicalFromStored(storedId) {
24138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24139      if (!m) return slugify(storedId);
24140      const [, slug, p, mv, title] = m;
24141      return `${slug}-${p}-${mv}-${slugify(title)}`;
24142    }
24143
24144    function canonicalPlusOne(storedId) {
24145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24146      if (!m) return null;
24147      const [, slug, p, mv, title] = m;
24148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
24149    }
24150
24151    function isCompleted(canonicalId, completions) {
24152      if (completions.includes(canonicalId)) return true;
24153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
24154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
24155    }
24156
24157    function migrateCompletions() {
24158      const completions = getCompletions();
24159      let changed = false;
24160      const migrated = completions.map((c) => {
24161        const canon = canonicalFromStored(c);
24162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
24163        const plusOne = canonicalPlusOne(c);
24164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
24165          if (plusOne !== c) changed = true;
24166          return plusOne;
24167        }
24168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
24169          changed = true;
24170          return canon;
24171        }
24172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
24173        if (canon !== c) {
24174          changed = true;
24175          return canon;
24176        }
24177        return c;
24178      });
24179      // Elimina duplicados tras migración
24180      const deduped = [...new Set(migrated)];
24181      if (changed || deduped.length !== completions.length) {
24182        saveCompletions(deduped);
24183        return deduped;
24184      }
24185      return completions;
24186    }
24187
24188    function updateButtonState(btn, completions) {
24189      const id = btn.dataset.completionId;
24190      const list = completions ?? getCompletions();
24191      btn.classList.toggle("completed", isCompleted(id, list));
24192    }
24193
24194    function checkModuleCompletion(completions) {
24195      const list = completions ?? getCompletions();
24196      document.querySelectorAll(".course-video").forEach((module) => {
24197        const btns = module.querySelectorAll(".complete-btn");
24198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
24199        const check = module.querySelector(".module-check");
24200        if (check) check.classList.toggle("visible", allCompleted);
24201      });
24202    }
24203
24204    function initCompletionButtons() {
24205      const completions = migrateCompletions();
24206
24207      document.querySelectorAll(".complete-btn").forEach((btn) => {
24208        if (btn.dataset.completionInit) return;
24209        btn.dataset.completionInit = "true";
24210        updateButtonState(btn, completions);
24211
24212        btn.addEventListener("click", (e) => {
24213          e.preventDefault();
24214          e.stopPropagation();
24215
24216          const id = btn.dataset.completionId;
24217          let list = getCompletions();
24218          const completed = isCompleted(id, list);
24219
24220          if (completed) {
24221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
24222            list = list.filter((c) =>
24222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
24223          } else {
24224            list.push(id);
24225          }
24226
24227          saveCompletions(list);
24228          updateButtonState(btn, list);
24229          checkModuleCompletion(list);
24230        });
24231      });
24232
24233      checkModuleCompletion(completions);
24234    }
24235
24236    if (document.readyState === "loading") {
24237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
24238    } else {
24239      initCompletionButtons();
24240    }
24241  })();
24242</script>
24242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="14779" data-end="15402" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Scroll-driven y View Transition</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>10 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-scroll-driven-y-view-transition" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
24242<script>
24243  (() => {
24244    if (window.__courseLazyPlayersInitialized) return;
24245    window.__courseLazyPlayersInitialized = true;
24246
24247    function createPlayer(details) {
24248      const videoId = details.dataset.videoId;
24249      const start = details.dataset.start;
24250      const end = details.dataset.end;
24251
24252      const container = document.createElement("div");
24253      container.className = "player-container";
24254
24255      const spinner = document.createElement("div");
24256      spinner.className = "spinner";
24257      container.appendChild(spinner);
24258
24259      const controller = document.createElement("media-controller");
24260      controller.setAttribute("defaultsubtitles", "");
24261
24262      const provider = document.createElement("youtube-media-provider");
24263      provider.setAttribute("slot", "media");
24264      provider.setAttribute("video-id", videoId);
24265      provider.setAttribute("start", start);
24266      provider.setAttribute("end", end);
24267
24268      const controlBar = document.createElement("media-control-bar");
24269
24270      const playBtn = document.createElement("media-play-button");
24271      const timeDisplay = document.createElement("media-time-display");
24272      timeDisplay.setAttribute("showduration", "");
24273      const timeRange = document.createElement("media-time-range");
24274      const playbackRate = document.createElement("media-playback-rate-button");
24275      const muteBtn = document.createElement("media-mute-button");
24276      const volumeRange = document.createElement("media-volume-range");
24277
24278      const captionsBtn = document.createElement("media-captions-button");
24279      captionsBtn.addEventListener("click", (e) => {
24280        e.stopPropagation();
24281        const provider = container.querySelector("youtube-media-provider");
24282        if (provider) provider.toggleCaptions();
24283      });
24284
24285      const qualityBtn = document.createElement("media-chrome-button");
24286      qualityBtn.textContent = "--";
24287      qualityBtn.addEventListener("click", () => {
24288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
24289      });
24290
24291      const fullscreenBtn = document.createElement("media-fullscreen-button");
24292
24293      controlBar.append(
24294        playBtn, timeDisplay, timeRange,
24295        playbackRate, muteBtn, volumeRange,
24296        captionsBtn, qualityBtn, fullscreenBtn
24297      );
24298
24299      const poster = document.createElement("media-poster-image");
24300      poster.setAttribute("slot", "poster");
24301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
24302
24303      controller.append(provider, poster, controlBar);
24304
24305      captionsBtn.setAttribute("aria-checked", "true");
24306      controller.setAttribute("mediasubtitlesshowing", "on");
24307
24308      container.appendChild(controller);
24309      details.appendChild(container);
24310
24311      provider.addEventListener("waiting", () => container.classList.add("loading"));
24312      provider.addEventListener("playing", () => container.classList.remove("loading"));
24313      provider.addEventListener("play", () => container.classList.remove("loading"));
24314
24315      provider._textTracks.addEventListener("change", () => {
24316        const enabled = provider._captionsEnabled;
24317        captionsBtn.setAttribute("aria-checked", enabled);
24318        if (enabled) {
24319          controller.setAttribute("mediasubtitlesshowing", "on");
24320        } else {
24321          controller.removeAttribute("mediasubtitlesshowing");
24322        }
24323      });
24324
24325      provider.addEventListener("qualitychange", (e) => {
24326        qualityBtn.textContent = e.detail.quality;
24327      });
24328    }
24329
24330    function initLazyPlayers() {
24331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
24332        if (details.dataset.lazyInit) return;
24333        details.dataset.lazyInit = "true";
24334        details.addEventListener("toggle", () => {
24335          if (details.open && !details.querySelector(".player-container")) {
24336            createPlayer(details);
24337          } else if (!details.open) {
24338            const provider = details.querySelector("youtube-media-provider");
24339            if (provider && typeof provider.pause === "function") {
24340              provider.pause();
24341            }
24342          }
24343        });
24344      });
24345    }
24346
24347    if (document.readyState === "loading") {
24348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
24349    } else {
24350      initLazyPlayers();
24351    }
24352  })();
24353</script>
24353<script>
24354  (() => {
24355    if (window.__courseCompletionsInitialized) return;
24356    window.__courseCompletionsInitialized = true;
24357
24358    const STORAGE_KEY = "course-completions";
24359
24360    function slugify(text) {
24361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24362    }
24363
24364    function getCompletions() {
24365      try {
24366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
24367      } catch {
24368        return [];
24369      }
24370    }
24371
24372    function saveCompletions(completions) {
24373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
24374    }
24375
24376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
24377    function canonicalFromStored(storedId) {
24378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24379      if (!m) return slugify(storedId);
24380      const [, slug, p, mv, title] = m;
24381      return `${slug}-${p}-${mv}-${slugify(title)}`;
24382    }
24383
24384    function canonicalPlusOne(storedId) {
24385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24386      if (!m) return null;
24387      const [, slug, p, mv, title] = m;
24388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
24389    }
24390
24391    function isCompleted(canonicalId, completions) {
24392      if (completions.includes(canonicalId)) return true;
24393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
24394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
24395    }
24396
24397    function migrateCompletions() {
24398      const completions = getCompletions();
24399      let changed = false;
24400      const migrated = completions.map((c) => {
24401        const canon = canonicalFromStored(c);
24402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
24403        const plusOne = canonicalPlusOne(c);
24404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
24405          if (plusOne !== c) changed = true;
24406          return plusOne;
24407        }
24408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
24409          changed = true;
24410          return canon;
24411        }
24412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
24413        if (canon !== c) {
24414          changed = true;
24415          return canon;
24416        }
24417        return c;
24418      });
24419      // Elimina duplicados tras migración
24420      const deduped = [...new Set(migrated)];
24421      if (changed || deduped.length !== completions.length) {
24422        saveCompletions(deduped);
24423        return deduped;
24424      }
24425      return completions;
24426    }
24427
24428    function updateButtonState(btn, completions) {
24429      const id = btn.dataset.completionId;
24430      const list = completions ?? getCompletions();
24431      btn.classList.toggle("completed", isCompleted(id, list));
24432    }
24433
24434    function checkModuleCompletion(completions) {
24435      const list = completions ?? getCompletions();
24436      document.querySelectorAll(".course-video").forEach((module) => {
24437        const btns = module.querySelectorAll(".complete-btn");
24438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
24439        const check = module.querySelector(".module-check");
24440        if (check) check.classList.toggle("visible", allCompleted);
24441      });
24442    }
24443
24444    function initCompletionButtons() {
24445      const completions = migrateCompletions();
24446
24447      document.querySelectorAll(".complete-btn").forEach((btn) => {
24448        if (btn.dataset.completionInit) return;
24449        btn.dataset.completionInit = "true";
24450        updateButtonState(btn, completions);
24451
24452        btn.addEventListener("click", (e) => {
24453          e.preventDefault();
24454          e.stopPropagation();
24455
24456          const id = btn.dataset.completionId;
24457          let list = getCompletions();
24458          const completed = isCompleted(id, list);
24459
24460          if (completed) {
24461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
24462            list = list.filter((c) =>
24462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
24463          } else {
24464            list.push(id);
24465          }
24466
24467          saveCompletions(list);
24468          updateButtonState(btn, list);
24469          checkModuleCompletion(list);
24470        });
24471      });
24472
24473      checkModuleCompletion(completions);
24474    }
24475
24476    if (document.readyState === "loading") {
24477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
24478    } else {
24479      initCompletionButtons();
24480    }
24481  })();
24482</script>
24482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="itwt1xsr7ww" data-start="15402" data-end="17052" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>14. Preguntas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-3-preguntas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
24482<script>
24483  (() => {
24484    if (window.__courseLazyPlayersInitialized) return;
24485    window.__courseLazyPlayersInitialized = true;
24486
24487    function createPlayer(details) {
24488      const videoId = details.dataset.videoId;
24489      const start = details.dataset.start;
24490      const end = details.dataset.end;
24491
24492      const container = document.createElement("div");
24493      container.className = "player-container";
24494
24495      const spinner = document.createElement("div");
24496      spinner.className = "spinner";
24497      container.appendChild(spinner);
24498
24499      const controller = document.createElement("media-controller");
24500      controller.setAttribute("defaultsubtitles", "");
24501
24502      const provider = document.createElement("youtube-media-provider");
24503      provider.setAttribute("slot", "media");
24504      provider.setAttribute("video-id", videoId);
24505      provider.setAttribute("start", start);
24506      provider.setAttribute("end", end);
24507
24508      const controlBar = document.createElement("media-control-bar");
24509
24510      const playBtn = document.createElement("media-play-button");
24511      const timeDisplay = document.createElement("media-time-display");
24512      timeDisplay.setAttribute("showduration", "");
24513      const timeRange = document.createElement("media-time-range");
24514      const playbackRate = document.createElement("media-playback-rate-button");
24515      const muteBtn = document.createElement("media-mute-button");
24516      const volumeRange = document.createElement("media-volume-range");
24517
24518      const captionsBtn = document.createElement("media-captions-button");
24519      captionsBtn.addEventListener("click", (e) => {
24520        e.stopPropagation();
24521        const provider = container.querySelector("youtube-media-provider");
24522        if (provider) provider.toggleCaptions();
24523      });
24524
24525      const qualityBtn = document.createElement("media-chrome-button");
24526      qualityBtn.textContent = "--";
24527      qualityBtn.addEventListener("click", () => {
24528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
24529      });
24530
24531      const fullscreenBtn = document.createElement("media-fullscreen-button");
24532
24533      controlBar.append(
24534        playBtn, timeDisplay, timeRange,
24535        playbackRate, muteBtn, volumeRange,
24536        captionsBtn, qualityBtn, fullscreenBtn
24537      );
24538
24539      const poster = document.createElement("media-poster-image");
24540      poster.setAttribute("slot", "poster");
24541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
24542
24543      controller.append(provider, poster, controlBar);
24544
24545      captionsBtn.setAttribute("aria-checked", "true");
24546      controller.setAttribute("mediasubtitlesshowing", "on");
24547
24548      container.appendChild(controller);
24549      details.appendChild(container);
24550
24551      provider.addEventListener("waiting", () => container.classList.add("loading"));
24552      provider.addEventListener("playing", () => container.classList.remove("loading"));
24553      provider.addEventListener("play", () => container.classList.remove("loading"));
24554
24555      provider._textTracks.addEventListener("change", () => {
24556        const enabled = provider._captionsEnabled;
24557        captionsBtn.setAttribute("aria-checked", enabled);
24558        if (enabled) {
24559          controller.setAttribute("mediasubtitlesshowing", "on");
24560        } else {
24561          controller.removeAttribute("mediasubtitlesshowing");
24562        }
24563      });
24564
24565      provider.addEventListener("qualitychange", (e) => {
24566        qualityBtn.textContent = e.detail.quality;
24567      });
24568    }
24569
24570    function initLazyPlayers() {
24571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
24572        if (details.dataset.lazyInit) return;
24573        details.dataset.lazyInit = "true";
24574        details.addEventListener("toggle", () => {
24575          if (details.open && !details.querySelector(".player-container")) {
24576            createPlayer(details);
24577          } else if (!details.open) {
24578            const provider = details.querySelector("youtube-media-provider");
24579            if (provider && typeof provider.pause === "function") {
24580              provider.pause();
24581            }
24582          }
24583        });
24584      });
24585    }
24586
24587    if (document.readyState === "loading") {
24588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
24589    } else {
24590      initLazyPlayers();
24591    }
24592  })();
24593</script>
24593<script>
24594  (() => {
24595    if (window.__courseCompletionsInitialized) return;
24596    window.__courseCompletionsInitialized = true;
24597
24598    const STORAGE_KEY = "course-completions";
24599
24600    function slugify(text) {
24601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24602    }
24603
24604    function getCompletions() {
24605      try {
24606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
24607      } catch {
24608        return [];
24609      }
24610    }
24611
24612    function saveCompletions(completions) {
24613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
24614    }
24615
24616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
24617    function canonicalFromStored(storedId) {
24618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24619      if (!m) return slugify(storedId);
24620      const [, slug, p, mv, title] = m;
24621      return `${slug}-${p}-${mv}-${slugify(title)}`;
24622    }
24623
24624    function canonicalPlusOne(storedId) {
24625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24626      if (!m) return null;
24627      const [, slug, p, mv, title] = m;
24628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
24629    }
24630
24631    function isCompleted(canonicalId, completions) {
24632      if (completions.includes(canonicalId)) return true;
24633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
24634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
24635    }
24636
24637    function migrateCompletions() {
24638      const completions = getCompletions();
24639      let changed = false;
24640      const migrated = completions.map((c) => {
24641        const canon = canonicalFromStored(c);
24642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
24643        const plusOne = canonicalPlusOne(c);
24644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
24645          if (plusOne !== c) changed = true;
24646          return plusOne;
24647        }
24648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
24649          changed = true;
24650          return canon;
24651        }
24652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
24653        if (canon !== c) {
24654          changed = true;
24655          return canon;
24656        }
24657        return c;
24658      });
24659      // Elimina duplicados tras migración
24660      const deduped = [...new Set(migrated)];
24661      if (changed || deduped.length !== completions.length) {
24662        saveCompletions(deduped);
24663        return deduped;
24664      }
24665      return completions;
24666    }
24667
24668    function updateButtonState(btn, completions) {
24669      const id = btn.dataset.completionId;
24670      const list = completions ?? getCompletions();
24671      btn.classList.toggle("completed", isCompleted(id, list));
24672    }
24673
24674    function checkModuleCompletion(completions) {
24675      const list = completions ?? getCompletions();
24676      document.querySelectorAll(".course-video").forEach((module) => {
24677        const btns = module.querySelectorAll(".complete-btn");
24678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
24679        const check = module.querySelector(".module-check");
24680        if (check) check.classList.toggle("visible", allCompleted);
24681      });
24682    }
24683
24684    function initCompletionButtons() {
24685      const completions = migrateCompletions();
24686
24687      document.querySelectorAll(".complete-btn").forEach((btn) => {
24688        if (btn.dataset.completionInit) return;
24689        btn.dataset.completionInit = "true";
24690        updateButtonState(btn, completions);
24691
24692        btn.addEventListener("click", (e) => {
24693          e.preventDefault();
24694          e.stopPropagation();
24695
24696          const id = btn.dataset.completionId;
24697          let list = getCompletions();
24698          const completed = isCompleted(id, list);
24699
24700          if (completed) {
24701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
24702            list = list.filter((c) =>
24702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
24703          } else {
24704            list.push(id);
24705          }
24706
24707          saveCompletions(list);
24708          updateButtonState(btn, list);
24709          checkModuleCompletion(list);
24710        });
24711      });
24712
24713      checkModuleCompletion(completions);
24714    }
24715
24716    if (document.readyState === "loading") {
24717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
24718    } else {
24719      initCompletionButtons();
24720    }
24721  })();
24722</script>
24722</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 8 📙 CSS Avanzado<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="895" data-end="1550" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-introduccion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
24722<script>
24723  (() => {
24724    if (window.__courseLazyPlayersInitialized) return;
24725    window.__courseLazyPlayersInitialized = true;
24726
24727    function createPlayer(details) {
24728      const videoId = details.dataset.videoId;
24729      const start = details.dataset.start;
24730      const end = details.dataset.end;
24731
24732      const container = document.createElement("div");
24733      container.className = "player-container";
24734
24735      const spinner = document.createElement("div");
24736      spinner.className = "spinner";
24737      container.appendChild(spinner);
24738
24739      const controller = document.createElement("media-controller");
24740      controller.setAttribute("defaultsubtitles", "");
24741
24742      const provider = document.createElement("youtube-media-provider");
24743      provider.setAttribute("slot", "media");
24744      provider.setAttribute("video-id", videoId);
24745      provider.setAttribute("start", start);
24746      provider.setAttribute("end", end);
24747
24748      const controlBar = document.createElement("media-control-bar");
24749
24750      const playBtn = document.createElement("media-play-button");
24751      const timeDisplay = document.createElement("media-time-display");
24752      timeDisplay.setAttribute("showduration", "");
24753      const timeRange = document.createElement("media-time-range");
24754      const playbackRate = document.createElement("media-playback-rate-button");
24755      const muteBtn = document.createElement("media-mute-button");
24756      const volumeRange = document.createElement("media-volume-range");
24757
24758      const captionsBtn = document.createElement("media-captions-button");
24759      captionsBtn.addEventListener("click", (e) => {
24760        e.stopPropagation();
24761        const provider = container.querySelector("youtube-media-provider");
24762        if (provider) provider.toggleCaptions();
24763      });
24764
24765      const qualityBtn = document.createElement("media-chrome-button");
24766      qualityBtn.textContent = "--";
24767      qualityBtn.addEventListener("click", () => {
24768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
24769      });
24770
24771      const fullscreenBtn = document.createElement("media-fullscreen-button");
24772
24773      controlBar.append(
24774        playBtn, timeDisplay, timeRange,
24775        playbackRate, muteBtn, volumeRange,
24776        captionsBtn, qualityBtn, fullscreenBtn
24777      );
24778
24779      const poster = document.createElement("media-poster-image");
24780      poster.setAttribute("slot", "poster");
24781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
24782
24783      controller.append(provider, poster, controlBar);
24784
24785      captionsBtn.setAttribute("aria-checked", "true");
24786      controller.setAttribute("mediasubtitlesshowing", "on");
24787
24788      container.appendChild(controller);
24789      details.appendChild(container);
24790
24791      provider.addEventListener("waiting", () => container.classList.add("loading"));
24792      provider.addEventListener("playing", () => container.classList.remove("loading"));
24793      provider.addEventListener("play", () => container.classList.remove("loading"));
24794
24795      provider._textTracks.addEventListener("change", () => {
24796        const enabled = provider._captionsEnabled;
24797        captionsBtn.setAttribute("aria-checked", enabled);
24798        if (enabled) {
24799          controller.setAttribute("mediasubtitlesshowing", "on");
24800        } else {
24801          controller.removeAttribute("mediasubtitlesshowing");
24802        }
24803      });
24804
24805      provider.addEventListener("qualitychange", (e) => {
24806        qualityBtn.textContent = e.detail.quality;
24807      });
24808    }
24809
24810    function initLazyPlayers() {
24811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
24812        if (details.dataset.lazyInit) return;
24813        details.dataset.lazyInit = "true";
24814        details.addEventListener("toggle", () => {
24815          if (details.open && !details.querySelector(".player-container")) {
24816            createPlayer(details);
24817          } else if (!details.open) {
24818            const provider = details.querySelector("youtube-media-provider");
24819            if (provider && typeof provider.pause === "function") {
24820              provider.pause();
24821            }
24822          }
24823        });
24824      });
24825    }
24826
24827    if (document.readyState === "loading") {
24828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
24829    } else {
24830      initLazyPlayers();
24831    }
24832  })();
24833</script>
24833<script>
24834  (() => {
24835    if (window.__courseCompletionsInitialized) return;
24836    window.__courseCompletionsInitialized = true;
24837
24838    const STORAGE_KEY = "course-completions";
24839
24840    function slugify(text) {
24841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24842    }
24843
24844    function getCompletions() {
24845      try {
24846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
24847      } catch {
24848        return [];
24849      }
24850    }
24851
24852    function saveCompletions(completions) {
24853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
24854    }
24855
24856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
24857    function canonicalFromStored(storedId) {
24858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24859      if (!m) return slugify(storedId);
24860      const [, slug, p, mv, title] = m;
24861      return `${slug}-${p}-${mv}-${slugify(title)}`;
24862    }
24863
24864    function canonicalPlusOne(storedId) {
24865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
24866      if (!m) return null;
24867      const [, slug, p, mv, title] = m;
24868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
24869    }
24870
24871    function isCompleted(canonicalId, completions) {
24872      if (completions.includes(canonicalId)) return true;
24873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
24874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
24875    }
24876
24877    function migrateCompletions() {
24878      const completions = getCompletions();
24879      let changed = false;
24880      const migrated = completions.map((c) => {
24881        const canon = canonicalFromStored(c);
24882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
24883        const plusOne = canonicalPlusOne(c);
24884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
24885          if (plusOne !== c) changed = true;
24886          return plusOne;
24887        }
24888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
24889          changed = true;
24890          return canon;
24891        }
24892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
24893        if (canon !== c) {
24894          changed = true;
24895          return canon;
24896        }
24897        return c;
24898      });
24899      // Elimina duplicados tras migración
24900      const deduped = [...new Set(migrated)];
24901      if (changed || deduped.length !== completions.length) {
24902        saveCompletions(deduped);
24903        return deduped;
24904      }
24905      return completions;
24906    }
24907
24908    function updateButtonState(btn, completions) {
24909      const id = btn.dataset.completionId;
24910      const list = completions ?? getCompletions();
24911      btn.classList.toggle("completed", isCompleted(id, list));
24912    }
24913
24914    function checkModuleCompletion(completions) {
24915      const list = completions ?? getCompletions();
24916      document.querySelectorAll(".course-video").forEach((module) => {
24917        const btns = module.querySelectorAll(".complete-btn");
24918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
24919        const check = module.querySelector(".module-check");
24920        if (check) check.classList.toggle("visible", allCompleted);
24921      });
24922    }
24923
24924    function initCompletionButtons() {
24925      const completions = migrateCompletions();
24926
24927      document.querySelectorAll(".complete-btn").forEach((btn) => {
24928        if (btn.dataset.completionInit) return;
24929        btn.dataset.completionInit = "true";
24930        updateButtonState(btn, completions);
24931
24932        btn.addEventListener("click", (e) => {
24933          e.preventDefault();
24934          e.stopPropagation();
24935
24936          const id = btn.dataset.completionId;
24937          let list = getCompletions();
24938          const completed = isCompleted(id, list);
24939
24940          if (completed) {
24941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
24942            list = list.filter((c) =>
24942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
24943          } else {
24944            list.push(id);
24945          }
24946
24947          saveCompletions(list);
24948          updateButtonState(btn, list);
24949          checkModuleCompletion(list);
24950        });
24951      });
24952
24953      checkModuleCompletion(completions);
24954    }
24955
24956    if (document.readyState === "loading") {
24957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
24958    } else {
24959      initCompletionButtons();
24960    }
24961  })();
24962</script>
24962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="1550" data-end="2212" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Colores derivados</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-colores-derivados" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
24962<script>
24963  (() => {
24964    if (window.__courseLazyPlayersInitialized) return;
24965    window.__courseLazyPlayersInitialized = true;
24966
24967    function createPlayer(details) {
24968      const videoId = details.dataset.videoId;
24969      const start = details.dataset.start;
24970      const end = details.dataset.end;
24971
24972      const container = document.createElement("div");
24973      container.className = "player-container";
24974
24975      const spinner = document.createElement("div");
24976      spinner.className = "spinner";
24977      container.appendChild(spinner);
24978
24979      const controller = document.createElement("media-controller");
24980      controller.setAttribute("defaultsubtitles", "");
24981
24982      const provider = document.createElement("youtube-media-provider");
24983      provider.setAttribute("slot", "media");
24984      provider.setAttribute("video-id", videoId);
24985      provider.setAttribute("start", start);
24986      provider.setAttribute("end", end);
24987
24988      const controlBar = document.createElement("media-control-bar");
24989
24990      const playBtn = document.createElement("media-play-button");
24991      const timeDisplay = document.createElement("media-time-display");
24992      timeDisplay.setAttribute("showduration", "");
24993      const timeRange = document.createElement("media-time-range");
24994      const playbackRate = document.createElement("media-playback-rate-button");
24995      const muteBtn = document.createElement("media-mute-button");
24996      const volumeRange = document.createElement("media-volume-range");
24997
24998      const captionsBtn = document.createElement("media-captions-button");
24999      captionsBtn.addEventListener("click", (e) => {
25000        e.stopPropagation();
25001        const provider = container.querySelector("youtube-media-provider");
25002        if (provider) provider.toggleCaptions();
25003      });
25004
25005      const qualityBtn = document.createElement("media-chrome-button");
25006      qualityBtn.textContent = "--";
25007      qualityBtn.addEventListener("click", () => {
25008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
25009      });
25010
25011      const fullscreenBtn = document.createElement("media-fullscreen-button");
25012
25013      controlBar.append(
25014        playBtn, timeDisplay, timeRange,
25015        playbackRate, muteBtn, volumeRange,
25016        captionsBtn, qualityBtn, fullscreenBtn
25017      );
25018
25019      const poster = document.createElement("media-poster-image");
25020      poster.setAttribute("slot", "poster");
25021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
25022
25023      controller.append(provider, poster, controlBar);
25024
25025      captionsBtn.setAttribute("aria-checked", "true");
25026      controller.setAttribute("mediasubtitlesshowing", "on");
25027
25028      container.appendChild(controller);
25029      details.appendChild(container);
25030
25031      provider.addEventListener("waiting", () => container.classList.add("loading"));
25032      provider.addEventListener("playing", () => container.classList.remove("loading"));
25033      provider.addEventListener("play", () => container.classList.remove("loading"));
25034
25035      provider._textTracks.addEventListener("change", () => {
25036        const enabled = provider._captionsEnabled;
25037        captionsBtn.setAttribute("aria-checked", enabled);
25038        if (enabled) {
25039          controller.setAttribute("mediasubtitlesshowing", "on");
25040        } else {
25041          controller.removeAttribute("mediasubtitlesshowing");
25042        }
25043      });
25044
25045      provider.addEventListener("qualitychange", (e) => {
25046        qualityBtn.textContent = e.detail.quality;
25047      });
25048    }
25049
25050    function initLazyPlayers() {
25051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
25052        if (details.dataset.lazyInit) return;
25053        details.dataset.lazyInit = "true";
25054        details.addEventListener("toggle", () => {
25055          if (details.open && !details.querySelector(".player-container")) {
25056            createPlayer(details);
25057          } else if (!details.open) {
25058            const provider = details.querySelector("youtube-media-provider");
25059            if (provider && typeof provider.pause === "function") {
25060              provider.pause();
25061            }
25062          }
25063        });
25064      });
25065    }
25066
25067    if (document.readyState === "loading") {
25068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
25069    } else {
25070      initLazyPlayers();
25071    }
25072  })();
25073</script>
25073<script>
25074  (() => {
25075    if (window.__courseCompletionsInitialized) return;
25076    window.__courseCompletionsInitialized = true;
25077
25078    const STORAGE_KEY = "course-completions";
25079
25080    function slugify(text) {
25081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
25082    }
25083
25084    function getCompletions() {
25085      try {
25086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
25087      } catch {
25088        return [];
25089      }
25090    }
25091
25092    function saveCompletions(completions) {
25093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
25094    }
25095
25096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
25097    function canonicalFromStored(storedId) {
25098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25099      if (!m) return slugify(storedId);
25100      const [, slug, p, mv, title] = m;
25101      return `${slug}-${p}-${mv}-${slugify(title)}`;
25102    }
25103
25104    function canonicalPlusOne(storedId) {
25105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25106      if (!m) return null;
25107      const [, slug, p, mv, title] = m;
25108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
25109    }
25110
25111    function isCompleted(canonicalId, completions) {
25112      if (completions.includes(canonicalId)) return true;
25113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
25114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
25115    }
25116
25117    function migrateCompletions() {
25118      const completions = getCompletions();
25119      let changed = false;
25120      const migrated = completions.map((c) => {
25121        const canon = canonicalFromStored(c);
25122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
25123        const plusOne = canonicalPlusOne(c);
25124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
25125          if (plusOne !== c) changed = true;
25126          return plusOne;
25127        }
25128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
25129          changed = true;
25130          return canon;
25131        }
25132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
25133        if (canon !== c) {
25134          changed = true;
25135          return canon;
25136        }
25137        return c;
25138      });
25139      // Elimina duplicados tras migración
25140      const deduped = [...new Set(migrated)];
25141      if (changed || deduped.length !== completions.length) {
25142        saveCompletions(deduped);
25143        return deduped;
25144      }
25145      return completions;
25146    }
25147
25148    function updateButtonState(btn, completions) {
25149      const id = btn.dataset.completionId;
25150      const list = completions ?? getCompletions();
25151      btn.classList.toggle("completed", isCompleted(id, list));
25152    }
25153
25154    function checkModuleCompletion(completions) {
25155      const list = completions ?? getCompletions();
25156      document.querySelectorAll(".course-video").forEach((module) => {
25157        const btns = module.querySelectorAll(".complete-btn");
25158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
25159        const check = module.querySelector(".module-check");
25160        if (check) check.classList.toggle("visible", allCompleted);
25161      });
25162    }
25163
25164    function initCompletionButtons() {
25165      const completions = migrateCompletions();
25166
25167      document.querySelectorAll(".complete-btn").forEach((btn) => {
25168        if (btn.dataset.completionInit) return;
25169        btn.dataset.completionInit = "true";
25170        updateButtonState(btn, completions);
25171
25172        btn.addEventListener("click", (e) => {
25173          e.preventDefault();
25174          e.stopPropagation();
25175
25176          const id = btn.dataset.completionId;
25177          let list = getCompletions();
25178          const completed = isCompleted(id, list);
25179
25180          if (completed) {
25181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
25182            list = list.filter((c) =>
25182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
25183          } else {
25184            list.push(id);
25185          }
25186
25187          saveCompletions(list);
25188          updateButtonState(btn, list);
25189          checkModuleCompletion(list);
25190        });
25191      });
25192
25193      checkModuleCompletion(completions);
25194    }
25195
25196    if (document.readyState === "loading") {
25197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
25198    } else {
25199      initCompletionButtons();
25200    }
25201  })();
25202</script>
25202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="2212" data-end="2985" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Utilidades para imágenes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-utilidades-para-imagenes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
25202<script>
25203  (() => {
25204    if (window.__courseLazyPlayersInitialized) return;
25205    window.__courseLazyPlayersInitialized = true;
25206
25207    function createPlayer(details) {
25208      const videoId = details.dataset.videoId;
25209      const start = details.dataset.start;
25210      const end = details.dataset.end;
25211
25212      const container = document.createElement("div");
25213      container.className = "player-container";
25214
25215      const spinner = document.createElement("div");
25216      spinner.className = "spinner";
25217      container.appendChild(spinner);
25218
25219      const controller = document.createElement("media-controller");
25220      controller.setAttribute("defaultsubtitles", "");
25221
25222      const provider = document.createElement("youtube-media-provider");
25223      provider.setAttribute("slot", "media");
25224      provider.setAttribute("video-id", videoId);
25225      provider.setAttribute("start", start);
25226      provider.setAttribute("end", end);
25227
25228      const controlBar = document.createElement("media-control-bar");
25229
25230      const playBtn = document.createElement("media-play-button");
25231      const timeDisplay = document.createElement("media-time-display");
25232      timeDisplay.setAttribute("showduration", "");
25233      const timeRange = document.createElement("media-time-range");
25234      const playbackRate = document.createElement("media-playback-rate-button");
25235      const muteBtn = document.createElement("media-mute-button");
25236      const volumeRange = document.createElement("media-volume-range");
25237
25238      const captionsBtn = document.createElement("media-captions-button");
25239      captionsBtn.addEventListener("click", (e) => {
25240        e.stopPropagation();
25241        const provider = container.querySelector("youtube-media-provider");
25242        if (provider) provider.toggleCaptions();
25243      });
25244
25245      const qualityBtn = document.createElement("media-chrome-button");
25246      qualityBtn.textContent = "--";
25247      qualityBtn.addEventListener("click", () => {
25248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
25249      });
25250
25251      const fullscreenBtn = document.createElement("media-fullscreen-button");
25252
25253      controlBar.append(
25254        playBtn, timeDisplay, timeRange,
25255        playbackRate, muteBtn, volumeRange,
25256        captionsBtn, qualityBtn, fullscreenBtn
25257      );
25258
25259      const poster = document.createElement("media-poster-image");
25260      poster.setAttribute("slot", "poster");
25261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
25262
25263      controller.append(provider, poster, controlBar);
25264
25265      captionsBtn.setAttribute("aria-checked", "true");
25266      controller.setAttribute("mediasubtitlesshowing", "on");
25267
25268      container.appendChild(controller);
25269      details.appendChild(container);
25270
25271      provider.addEventListener("waiting", () => container.classList.add("loading"));
25272      provider.addEventListener("playing", () => container.classList.remove("loading"));
25273      provider.addEventListener("play", () => container.classList.remove("loading"));
25274
25275      provider._textTracks.addEventListener("change", () => {
25276        const enabled = provider._captionsEnabled;
25277        captionsBtn.setAttribute("aria-checked", enabled);
25278        if (enabled) {
25279          controller.setAttribute("mediasubtitlesshowing", "on");
25280        } else {
25281          controller.removeAttribute("mediasubtitlesshowing");
25282        }
25283      });
25284
25285      provider.addEventListener("qualitychange", (e) => {
25286        qualityBtn.textContent = e.detail.quality;
25287      });
25288    }
25289
25290    function initLazyPlayers() {
25291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
25292        if (details.dataset.lazyInit) return;
25293        details.dataset.lazyInit = "true";
25294        details.addEventListener("toggle", () => {
25295          if (details.open && !details.querySelector(".player-container")) {
25296            createPlayer(details);
25297          } else if (!details.open) {
25298            const provider = details.querySelector("youtube-media-provider");
25299            if (provider && typeof provider.pause === "function") {
25300              provider.pause();
25301            }
25302          }
25303        });
25304      });
25305    }
25306
25307    if (document.readyState === "loading") {
25308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
25309    } else {
25310      initLazyPlayers();
25311    }
25312  })();
25313</script>
25313<script>
25314  (() => {
25315    if (window.__courseCompletionsInitialized) return;
25316    window.__courseCompletionsInitialized = true;
25317
25318    const STORAGE_KEY = "course-completions";
25319
25320    function slugify(text) {
25321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
25322    }
25323
25324    function getCompletions() {
25325      try {
25326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
25327      } catch {
25328        return [];
25329      }
25330    }
25331
25332    function saveCompletions(completions) {
25333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
25334    }
25335
25336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
25337    function canonicalFromStored(storedId) {
25338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25339      if (!m) return slugify(storedId);
25340      const [, slug, p, mv, title] = m;
25341      return `${slug}-${p}-${mv}-${slugify(title)}`;
25342    }
25343
25344    function canonicalPlusOne(storedId) {
25345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25346      if (!m) return null;
25347      const [, slug, p, mv, title] = m;
25348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
25349    }
25350
25351    function isCompleted(canonicalId, completions) {
25352      if (completions.includes(canonicalId)) return true;
25353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
25354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
25355    }
25356
25357    function migrateCompletions() {
25358      const completions = getCompletions();
25359      let changed = false;
25360      const migrated = completions.map((c) => {
25361        const canon = canonicalFromStored(c);
25362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
25363        const plusOne = canonicalPlusOne(c);
25364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
25365          if (plusOne !== c) changed = true;
25366          return plusOne;
25367        }
25368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
25369          changed = true;
25370          return canon;
25371        }
25372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
25373        if (canon !== c) {
25374          changed = true;
25375          return canon;
25376        }
25377        return c;
25378      });
25379      // Elimina duplicados tras migración
25380      const deduped = [...new Set(migrated)];
25381      if (changed || deduped.length !== completions.length) {
25382        saveCompletions(deduped);
25383        return deduped;
25384      }
25385      return completions;
25386    }
25387
25388    function updateButtonState(btn, completions) {
25389      const id = btn.dataset.completionId;
25390      const list = completions ?? getCompletions();
25391      btn.classList.toggle("completed", isCompleted(id, list));
25392    }
25393
25394    function checkModuleCompletion(completions) {
25395      const list = completions ?? getCompletions();
25396      document.querySelectorAll(".course-video").forEach((module) => {
25397        const btns = module.querySelectorAll(".complete-btn");
25398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
25399        const check = module.querySelector(".module-check");
25400        if (check) check.classList.toggle("visible", allCompleted);
25401      });
25402    }
25403
25404    function initCompletionButtons() {
25405      const completions = migrateCompletions();
25406
25407      document.querySelectorAll(".complete-btn").forEach((btn) => {
25408        if (btn.dataset.completionInit) return;
25409        btn.dataset.completionInit = "true";
25410        updateButtonState(btn, completions);
25411
25412        btn.addEventListener("click", (e) => {
25413          e.preventDefault();
25414          e.stopPropagation();
25415
25416          const id = btn.dataset.completionId;
25417          let list = getCompletions();
25418          const completed = isCompleted(id, list);
25419
25420          if (completed) {
25421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
25422            list = list.filter((c) =>
25422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
25423          } else {
25424            list.push(id);
25425          }
25426
25427          saveCompletions(list);
25428          updateButtonState(btn, list);
25429          checkModuleCompletion(list);
25430        });
25431      });
25432
25433      checkModuleCompletion(completions);
25434    }
25435
25436    if (document.readyState === "loading") {
25437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
25438    } else {
25439      initCompletionButtons();
25440    }
25441  })();
25442</script>
25442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="2985" data-end="3353" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Renderización de imágenes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>6 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-renderizacion-de-imagenes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
25442<script>
25443  (() => {
25444    if (window.__courseLazyPlayersInitialized) return;
25445    window.__courseLazyPlayersInitialized = true;
25446
25447    function createPlayer(details) {
25448      const videoId = details.dataset.videoId;
25449      const start = details.dataset.start;
25450      const end = details.dataset.end;
25451
25452      const container = document.createElement("div");
25453      container.className = "player-container";
25454
25455      const spinner = document.createElement("div");
25456      spinner.className = "spinner";
25457      container.appendChild(spinner);
25458
25459      const controller = document.createElement("media-controller");
25460      controller.setAttribute("defaultsubtitles", "");
25461
25462      const provider = document.createElement("youtube-media-provider");
25463      provider.setAttribute("slot", "media");
25464      provider.setAttribute("video-id", videoId);
25465      provider.setAttribute("start", start);
25466      provider.setAttribute("end", end);
25467
25468      const controlBar = document.createElement("media-control-bar");
25469
25470      const playBtn = document.createElement("media-play-button");
25471      const timeDisplay = document.createElement("media-time-display");
25472      timeDisplay.setAttribute("showduration", "");
25473      const timeRange = document.createElement("media-time-range");
25474      const playbackRate = document.createElement("media-playback-rate-button");
25475      const muteBtn = document.createElement("media-mute-button");
25476      const volumeRange = document.createElement("media-volume-range");
25477
25478      const captionsBtn = document.createElement("media-captions-button");
25479      captionsBtn.addEventListener("click", (e) => {
25480        e.stopPropagation();
25481        const provider = container.querySelector("youtube-media-provider");
25482        if (provider) provider.toggleCaptions();
25483      });
25484
25485      const qualityBtn = document.createElement("media-chrome-button");
25486      qualityBtn.textContent = "--";
25487      qualityBtn.addEventListener("click", () => {
25488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
25489      });
25490
25491      const fullscreenBtn = document.createElement("media-fullscreen-button");
25492
25493      controlBar.append(
25494        playBtn, timeDisplay, timeRange,
25495        playbackRate, muteBtn, volumeRange,
25496        captionsBtn, qualityBtn, fullscreenBtn
25497      );
25498
25499      const poster = document.createElement("media-poster-image");
25500      poster.setAttribute("slot", "poster");
25501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
25502
25503      controller.append(provider, poster, controlBar);
25504
25505      captionsBtn.setAttribute("aria-checked", "true");
25506      controller.setAttribute("mediasubtitlesshowing", "on");
25507
25508      container.appendChild(controller);
25509      details.appendChild(container);
25510
25511      provider.addEventListener("waiting", () => container.classList.add("loading"));
25512      provider.addEventListener("playing", () => container.classList.remove("loading"));
25513      provider.addEventListener("play", () => container.classList.remove("loading"));
25514
25515      provider._textTracks.addEventListener("change", () => {
25516        const enabled = provider._captionsEnabled;
25517        captionsBtn.setAttribute("aria-checked", enabled);
25518        if (enabled) {
25519          controller.setAttribute("mediasubtitlesshowing", "on");
25520        } else {
25521          controller.removeAttribute("mediasubtitlesshowing");
25522        }
25523      });
25524
25525      provider.addEventListener("qualitychange", (e) => {
25526        qualityBtn.textContent = e.detail.quality;
25527      });
25528    }
25529
25530    function initLazyPlayers() {
25531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
25532        if (details.dataset.lazyInit) return;
25533        details.dataset.lazyInit = "true";
25534        details.addEventListener("toggle", () => {
25535          if (details.open && !details.querySelector(".player-container")) {
25536            createPlayer(details);
25537          } else if (!details.open) {
25538            const provider = details.querySelector("youtube-media-provider");
25539            if (provider && typeof provider.pause === "function") {
25540              provider.pause();
25541            }
25542          }
25543        });
25544      });
25545    }
25546
25547    if (document.readyState === "loading") {
25548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
25549    } else {
25550      initLazyPlayers();
25551    }
25552  })();
25553</script>
25553<script>
25554  (() => {
25555    if (window.__courseCompletionsInitialized) return;
25556    window.__courseCompletionsInitialized = true;
25557
25558    const STORAGE_KEY = "course-completions";
25559
25560    function slugify(text) {
25561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
25562    }
25563
25564    function getCompletions() {
25565      try {
25566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
25567      } catch {
25568        return [];
25569      }
25570    }
25571
25572    function saveCompletions(completions) {
25573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
25574    }
25575
25576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
25577    function canonicalFromStored(storedId) {
25578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25579      if (!m) return slugify(storedId);
25580      const [, slug, p, mv, title] = m;
25581      return `${slug}-${p}-${mv}-${slugify(title)}`;
25582    }
25583
25584    function canonicalPlusOne(storedId) {
25585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25586      if (!m) return null;
25587      const [, slug, p, mv, title] = m;
25588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
25589    }
25590
25591    function isCompleted(canonicalId, completions) {
25592      if (completions.includes(canonicalId)) return true;
25593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
25594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
25595    }
25596
25597    function migrateCompletions() {
25598      const completions = getCompletions();
25599      let changed = false;
25600      const migrated = completions.map((c) => {
25601        const canon = canonicalFromStored(c);
25602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
25603        const plusOne = canonicalPlusOne(c);
25604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
25605          if (plusOne !== c) changed = true;
25606          return plusOne;
25607        }
25608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
25609          changed = true;
25610          return canon;
25611        }
25612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
25613        if (canon !== c) {
25614          changed = true;
25615          return canon;
25616        }
25617        return c;
25618      });
25619      // Elimina duplicados tras migración
25620      const deduped = [...new Set(migrated)];
25621      if (changed || deduped.length !== completions.length) {
25622        saveCompletions(deduped);
25623        return deduped;
25624      }
25625      return completions;
25626    }
25627
25628    function updateButtonState(btn, completions) {
25629      const id = btn.dataset.completionId;
25630      const list = completions ?? getCompletions();
25631      btn.classList.toggle("completed", isCompleted(id, list));
25632    }
25633
25634    function checkModuleCompletion(completions) {
25635      const list = completions ?? getCompletions();
25636      document.querySelectorAll(".course-video").forEach((module) => {
25637        const btns = module.querySelectorAll(".complete-btn");
25638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
25639        const check = module.querySelector(".module-check");
25640        if (check) check.classList.toggle("visible", allCompleted);
25641      });
25642    }
25643
25644    function initCompletionButtons() {
25645      const completions = migrateCompletions();
25646
25647      document.querySelectorAll(".complete-btn").forEach((btn) => {
25648        if (btn.dataset.completionInit) return;
25649        btn.dataset.completionInit = "true";
25650        updateButtonState(btn, completions);
25651
25652        btn.addEventListener("click", (e) => {
25653          e.preventDefault();
25654          e.stopPropagation();
25655
25656          const id = btn.dataset.completionId;
25657          let list = getCompletions();
25658          const completed = isCompleted(id, list);
25659
25660          if (completed) {
25661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
25662            list = list.filter((c) =>
25662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
25663          } else {
25664            list.push(id);
25665          }
25666
25667          saveCompletions(list);
25668          updateButtonState(btn, list);
25669          checkModuleCompletion(list);
25670        });
25671      });
25672
25673      checkModuleCompletion(completions);
25674    }
25675
25676    if (document.readyState === "loading") {
25677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
25678    } else {
25679      initCompletionButtons();
25680    }
25681  })();
25682</script>
25682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="3353" data-end="4955" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Filtros y modos de fusión</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>27 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-filtros-y-modos-de-fusion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
25682<script>
25683  (() => {
25684    if (window.__courseLazyPlayersInitialized) return;
25685    window.__courseLazyPlayersInitialized = true;
25686
25687    function createPlayer(details) {
25688      const videoId = details.dataset.videoId;
25689      const start = details.dataset.start;
25690      const end = details.dataset.end;
25691
25692      const container = document.createElement("div");
25693      container.className = "player-container";
25694
25695      const spinner = document.createElement("div");
25696      spinner.className = "spinner";
25697      container.appendChild(spinner);
25698
25699      const controller = document.createElement("media-controller");
25700      controller.setAttribute("defaultsubtitles", "");
25701
25702      const provider = document.createElement("youtube-media-provider");
25703      provider.setAttribute("slot", "media");
25704      provider.setAttribute("video-id", videoId);
25705      provider.setAttribute("start", start);
25706      provider.setAttribute("end", end);
25707
25708      const controlBar = document.createElement("media-control-bar");
25709
25710      const playBtn = document.createElement("media-play-button");
25711      const timeDisplay = document.createElement("media-time-display");
25712      timeDisplay.setAttribute("showduration", "");
25713      const timeRange = document.createElement("media-time-range");
25714      const playbackRate = document.createElement("media-playback-rate-button");
25715      const muteBtn = document.createElement("media-mute-button");
25716      const volumeRange = document.createElement("media-volume-range");
25717
25718      const captionsBtn = document.createElement("media-captions-button");
25719      captionsBtn.addEventListener("click", (e) => {
25720        e.stopPropagation();
25721        const provider = container.querySelector("youtube-media-provider");
25722        if (provider) provider.toggleCaptions();
25723      });
25724
25725      const qualityBtn = document.createElement("media-chrome-button");
25726      qualityBtn.textContent = "--";
25727      qualityBtn.addEventListener("click", () => {
25728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
25729      });
25730
25731      const fullscreenBtn = document.createElement("media-fullscreen-button");
25732
25733      controlBar.append(
25734        playBtn, timeDisplay, timeRange,
25735        playbackRate, muteBtn, volumeRange,
25736        captionsBtn, qualityBtn, fullscreenBtn
25737      );
25738
25739      const poster = document.createElement("media-poster-image");
25740      poster.setAttribute("slot", "poster");
25741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
25742
25743      controller.append(provider, poster, controlBar);
25744
25745      captionsBtn.setAttribute("aria-checked", "true");
25746      controller.setAttribute("mediasubtitlesshowing", "on");
25747
25748      container.appendChild(controller);
25749      details.appendChild(container);
25750
25751      provider.addEventListener("waiting", () => container.classList.add("loading"));
25752      provider.addEventListener("playing", () => container.classList.remove("loading"));
25753      provider.addEventListener("play", () => container.classList.remove("loading"));
25754
25755      provider._textTracks.addEventListener("change", () => {
25756        const enabled = provider._captionsEnabled;
25757        captionsBtn.setAttribute("aria-checked", enabled);
25758        if (enabled) {
25759          controller.setAttribute("mediasubtitlesshowing", "on");
25760        } else {
25761          controller.removeAttribute("mediasubtitlesshowing");
25762        }
25763      });
25764
25765      provider.addEventListener("qualitychange", (e) => {
25766        qualityBtn.textContent = e.detail.quality;
25767      });
25768    }
25769
25770    function initLazyPlayers() {
25771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
25772        if (details.dataset.lazyInit) return;
25773        details.dataset.lazyInit = "true";
25774        details.addEventListener("toggle", () => {
25775          if (details.open && !details.querySelector(".player-container")) {
25776            createPlayer(details);
25777          } else if (!details.open) {
25778            const provider = details.querySelector("youtube-media-provider");
25779            if (provider && typeof provider.pause === "function") {
25780              provider.pause();
25781            }
25782          }
25783        });
25784      });
25785    }
25786
25787    if (document.readyState === "loading") {
25788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
25789    } else {
25790      initLazyPlayers();
25791    }
25792  })();
25793</script>
25793<script>
25794  (() => {
25795    if (window.__courseCompletionsInitialized) return;
25796    window.__courseCompletionsInitialized = true;
25797
25798    const STORAGE_KEY = "course-completions";
25799
25800    function slugify(text) {
25801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
25802    }
25803
25804    function getCompletions() {
25805      try {
25806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
25807      } catch {
25808        return [];
25809      }
25810    }
25811
25812    function saveCompletions(completions) {
25813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
25814    }
25815
25816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
25817    function canonicalFromStored(storedId) {
25818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25819      if (!m) return slugify(storedId);
25820      const [, slug, p, mv, title] = m;
25821      return `${slug}-${p}-${mv}-${slugify(title)}`;
25822    }
25823
25824    function canonicalPlusOne(storedId) {
25825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
25826      if (!m) return null;
25827      const [, slug, p, mv, title] = m;
25828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
25829    }
25830
25831    function isCompleted(canonicalId, completions) {
25832      if (completions.includes(canonicalId)) return true;
25833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
25834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
25835    }
25836
25837    function migrateCompletions() {
25838      const completions = getCompletions();
25839      let changed = false;
25840      const migrated = completions.map((c) => {
25841        const canon = canonicalFromStored(c);
25842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
25843        const plusOne = canonicalPlusOne(c);
25844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
25845          if (plusOne !== c) changed = true;
25846          return plusOne;
25847        }
25848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
25849          changed = true;
25850          return canon;
25851        }
25852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
25853        if (canon !== c) {
25854          changed = true;
25855          return canon;
25856        }
25857        return c;
25858      });
25859      // Elimina duplicados tras migración
25860      const deduped = [...new Set(migrated)];
25861      if (changed || deduped.length !== completions.length) {
25862        saveCompletions(deduped);
25863        return deduped;
25864      }
25865      return completions;
25866    }
25867
25868    function updateButtonState(btn, completions) {
25869      const id = btn.dataset.completionId;
25870      const list = completions ?? getCompletions();
25871      btn.classList.toggle("completed", isCompleted(id, list));
25872    }
25873
25874    function checkModuleCompletion(completions) {
25875      const list = completions ?? getCompletions();
25876      document.querySelectorAll(".course-video").forEach((module) => {
25877        const btns = module.querySelectorAll(".complete-btn");
25878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
25879        const check = module.querySelector(".module-check");
25880        if (check) check.classList.toggle("visible", allCompleted);
25881      });
25882    }
25883
25884    function initCompletionButtons() {
25885      const completions = migrateCompletions();
25886
25887      document.querySelectorAll(".complete-btn").forEach((btn) => {
25888        if (btn.dataset.completionInit) return;
25889        btn.dataset.completionInit = "true";
25890        updateButtonState(btn, completions);
25891
25892        btn.addEventListener("click", (e) => {
25893          e.preventDefault();
25894          e.stopPropagation();
25895
25896          const id = btn.dataset.completionId;
25897          let list = getCompletions();
25898          const completed = isCompleted(id, list);
25899
25900          if (completed) {
25901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
25902            list = list.filter((c) =>
25902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
25903          } else {
25904            list.push(id);
25905          }
25906
25907          saveCompletions(list);
25908          updateButtonState(btn, list);
25909          checkModuleCompletion(list);
25910        });
25911      });
25912
25913      checkModuleCompletion(completions);
25914    }
25915
25916    if (document.readyState === "loading") {
25917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
25918    } else {
25919      initCompletionButtons();
25920    }
25921  })();
25922</script>
25922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="4955" data-end="6100" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Responsive: Media queries</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-responsive-media-queries" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
25922<script>
25923  (() => {
25924    if (window.__courseLazyPlayersInitialized) return;
25925    window.__courseLazyPlayersInitialized = true;
25926
25927    function createPlayer(details) {
25928      const videoId = details.dataset.videoId;
25929      const start = details.dataset.start;
25930      const end = details.dataset.end;
25931
25932      const container = document.createElement("div");
25933      container.className = "player-container";
25934
25935      const spinner = document.createElement("div");
25936      spinner.className = "spinner";
25937      container.appendChild(spinner);
25938
25939      const controller = document.createElement("media-controller");
25940      controller.setAttribute("defaultsubtitles", "");
25941
25942      const provider = document.createElement("youtube-media-provider");
25943      provider.setAttribute("slot", "media");
25944      provider.setAttribute("video-id", videoId);
25945      provider.setAttribute("start", start);
25946      provider.setAttribute("end", end);
25947
25948      const controlBar = document.createElement("media-control-bar");
25949
25950      const playBtn = document.createElement("media-play-button");
25951      const timeDisplay = document.createElement("media-time-display");
25952      timeDisplay.setAttribute("showduration", "");
25953      const timeRange = document.createElement("media-time-range");
25954      const playbackRate = document.createElement("media-playback-rate-button");
25955      const muteBtn = document.createElement("media-mute-button");
25956      const volumeRange = document.createElement("media-volume-range");
25957
25958      const captionsBtn = document.createElement("media-captions-button");
25959      captionsBtn.addEventListener("click", (e) => {
25960        e.stopPropagation();
25961        const provider = container.querySelector("youtube-media-provider");
25962        if (provider) provider.toggleCaptions();
25963      });
25964
25965      const qualityBtn = document.createElement("media-chrome-button");
25966      qualityBtn.textContent = "--";
25967      qualityBtn.addEventListener("click", () => {
25968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
25969      });
25970
25971      const fullscreenBtn = document.createElement("media-fullscreen-button");
25972
25973      controlBar.append(
25974        playBtn, timeDisplay, timeRange,
25975        playbackRate, muteBtn, volumeRange,
25976        captionsBtn, qualityBtn, fullscreenBtn
25977      );
25978
25979      const poster = document.createElement("media-poster-image");
25980      poster.setAttribute("slot", "poster");
25981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
25982
25983      controller.append(provider, poster, controlBar);
25984
25985      captionsBtn.setAttribute("aria-checked", "true");
25986      controller.setAttribute("mediasubtitlesshowing", "on");
25987
25988      container.appendChild(controller);
25989      details.appendChild(container);
25990
25991      provider.addEventListener("waiting", () => container.classList.add("loading"));
25992      provider.addEventListener("playing", () => container.classList.remove("loading"));
25993      provider.addEventListener("play", () => container.classList.remove("loading"));
25994
25995      provider._textTracks.addEventListener("change", () => {
25996        const enabled = provider._captionsEnabled;
25997        captionsBtn.setAttribute("aria-checked", enabled);
25998        if (enabled) {
25999          controller.setAttribute("mediasubtitlesshowing", "on");
26000        } else {
26001          controller.removeAttribute("mediasubtitlesshowing");
26002        }
26003      });
26004
26005      provider.addEventListener("qualitychange", (e) => {
26006        qualityBtn.textContent = e.detail.quality;
26007      });
26008    }
26009
26010    function initLazyPlayers() {
26011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
26012        if (details.dataset.lazyInit) return;
26013        details.dataset.lazyInit = "true";
26014        details.addEventListener("toggle", () => {
26015          if (details.open && !details.querySelector(".player-container")) {
26016            createPlayer(details);
26017          } else if (!details.open) {
26018            const provider = details.querySelector("youtube-media-provider");
26019            if (provider && typeof provider.pause === "function") {
26020              provider.pause();
26021            }
26022          }
26023        });
26024      });
26025    }
26026
26027    if (document.readyState === "loading") {
26028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
26029    } else {
26030      initLazyPlayers();
26031    }
26032  })();
26033</script>
26033<script>
26034  (() => {
26035    if (window.__courseCompletionsInitialized) return;
26036    window.__courseCompletionsInitialized = true;
26037
26038    const STORAGE_KEY = "course-completions";
26039
26040    function slugify(text) {
26041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
26042    }
26043
26044    function getCompletions() {
26045      try {
26046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
26047      } catch {
26048        return [];
26049      }
26050    }
26051
26052    function saveCompletions(completions) {
26053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
26054    }
26055
26056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
26057    function canonicalFromStored(storedId) {
26058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26059      if (!m) return slugify(storedId);
26060      const [, slug, p, mv, title] = m;
26061      return `${slug}-${p}-${mv}-${slugify(title)}`;
26062    }
26063
26064    function canonicalPlusOne(storedId) {
26065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26066      if (!m) return null;
26067      const [, slug, p, mv, title] = m;
26068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
26069    }
26070
26071    function isCompleted(canonicalId, completions) {
26072      if (completions.includes(canonicalId)) return true;
26073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
26074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
26075    }
26076
26077    function migrateCompletions() {
26078      const completions = getCompletions();
26079      let changed = false;
26080      const migrated = completions.map((c) => {
26081        const canon = canonicalFromStored(c);
26082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
26083        const plusOne = canonicalPlusOne(c);
26084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
26085          if (plusOne !== c) changed = true;
26086          return plusOne;
26087        }
26088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
26089          changed = true;
26090          return canon;
26091        }
26092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
26093        if (canon !== c) {
26094          changed = true;
26095          return canon;
26096        }
26097        return c;
26098      });
26099      // Elimina duplicados tras migración
26100      const deduped = [...new Set(migrated)];
26101      if (changed || deduped.length !== completions.length) {
26102        saveCompletions(deduped);
26103        return deduped;
26104      }
26105      return completions;
26106    }
26107
26108    function updateButtonState(btn, completions) {
26109      const id = btn.dataset.completionId;
26110      const list = completions ?? getCompletions();
26111      btn.classList.toggle("completed", isCompleted(id, list));
26112    }
26113
26114    function checkModuleCompletion(completions) {
26115      const list = completions ?? getCompletions();
26116      document.querySelectorAll(".course-video").forEach((module) => {
26117        const btns = module.querySelectorAll(".complete-btn");
26118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
26119        const check = module.querySelector(".module-check");
26120        if (check) check.classList.toggle("visible", allCompleted);
26121      });
26122    }
26123
26124    function initCompletionButtons() {
26125      const completions = migrateCompletions();
26126
26127      document.querySelectorAll(".complete-btn").forEach((btn) => {
26128        if (btn.dataset.completionInit) return;
26129        btn.dataset.completionInit = "true";
26130        updateButtonState(btn, completions);
26131
26132        btn.addEventListener("click", (e) => {
26133          e.preventDefault();
26134          e.stopPropagation();
26135
26136          const id = btn.dataset.completionId;
26137          let list = getCompletions();
26138          const completed = isCompleted(id, list);
26139
26140          if (completed) {
26141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
26142            list = list.filter((c) =>
26142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
26143          } else {
26144            list.push(id);
26145          }
26146
26147          saveCompletions(list);
26148          updateButtonState(btn, list);
26149          checkModuleCompletion(list);
26150        });
26151      });
26152
26153      checkModuleCompletion(completions);
26154    }
26155
26156    if (document.readyState === "loading") {
26157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
26158    } else {
26159      initCompletionButtons();
26160    }
26161  })();
26162</script>
26162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="6100" data-end="6655" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Responsive: Media features</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-responsive-media-features" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
26162<script>
26163  (() => {
26164    if (window.__courseLazyPlayersInitialized) return;
26165    window.__courseLazyPlayersInitialized = true;
26166
26167    function createPlayer(details) {
26168      const videoId = details.dataset.videoId;
26169      const start = details.dataset.start;
26170      const end = details.dataset.end;
26171
26172      const container = document.createElement("div");
26173      container.className = "player-container";
26174
26175      const spinner = document.createElement("div");
26176      spinner.className = "spinner";
26177      container.appendChild(spinner);
26178
26179      const controller = document.createElement("media-controller");
26180      controller.setAttribute("defaultsubtitles", "");
26181
26182      const provider = document.createElement("youtube-media-provider");
26183      provider.setAttribute("slot", "media");
26184      provider.setAttribute("video-id", videoId);
26185      provider.setAttribute("start", start);
26186      provider.setAttribute("end", end);
26187
26188      const controlBar = document.createElement("media-control-bar");
26189
26190      const playBtn = document.createElement("media-play-button");
26191      const timeDisplay = document.createElement("media-time-display");
26192      timeDisplay.setAttribute("showduration", "");
26193      const timeRange = document.createElement("media-time-range");
26194      const playbackRate = document.createElement("media-playback-rate-button");
26195      const muteBtn = document.createElement("media-mute-button");
26196      const volumeRange = document.createElement("media-volume-range");
26197
26198      const captionsBtn = document.createElement("media-captions-button");
26199      captionsBtn.addEventListener("click", (e) => {
26200        e.stopPropagation();
26201        const provider = container.querySelector("youtube-media-provider");
26202        if (provider) provider.toggleCaptions();
26203      });
26204
26205      const qualityBtn = document.createElement("media-chrome-button");
26206      qualityBtn.textContent = "--";
26207      qualityBtn.addEventListener("click", () => {
26208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
26209      });
26210
26211      const fullscreenBtn = document.createElement("media-fullscreen-button");
26212
26213      controlBar.append(
26214        playBtn, timeDisplay, timeRange,
26215        playbackRate, muteBtn, volumeRange,
26216        captionsBtn, qualityBtn, fullscreenBtn
26217      );
26218
26219      const poster = document.createElement("media-poster-image");
26220      poster.setAttribute("slot", "poster");
26221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
26222
26223      controller.append(provider, poster, controlBar);
26224
26225      captionsBtn.setAttribute("aria-checked", "true");
26226      controller.setAttribute("mediasubtitlesshowing", "on");
26227
26228      container.appendChild(controller);
26229      details.appendChild(container);
26230
26231      provider.addEventListener("waiting", () => container.classList.add("loading"));
26232      provider.addEventListener("playing", () => container.classList.remove("loading"));
26233      provider.addEventListener("play", () => container.classList.remove("loading"));
26234
26235      provider._textTracks.addEventListener("change", () => {
26236        const enabled = provider._captionsEnabled;
26237        captionsBtn.setAttribute("aria-checked", enabled);
26238        if (enabled) {
26239          controller.setAttribute("mediasubtitlesshowing", "on");
26240        } else {
26241          controller.removeAttribute("mediasubtitlesshowing");
26242        }
26243      });
26244
26245      provider.addEventListener("qualitychange", (e) => {
26246        qualityBtn.textContent = e.detail.quality;
26247      });
26248    }
26249
26250    function initLazyPlayers() {
26251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
26252        if (details.dataset.lazyInit) return;
26253        details.dataset.lazyInit = "true";
26254        details.addEventListener("toggle", () => {
26255          if (details.open && !details.querySelector(".player-container")) {
26256            createPlayer(details);
26257          } else if (!details.open) {
26258            const provider = details.querySelector("youtube-media-provider");
26259            if (provider && typeof provider.pause === "function") {
26260              provider.pause();
26261            }
26262          }
26263        });
26264      });
26265    }
26266
26267    if (document.readyState === "loading") {
26268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
26269    } else {
26270      initLazyPlayers();
26271    }
26272  })();
26273</script>
26273<script>
26274  (() => {
26275    if (window.__courseCompletionsInitialized) return;
26276    window.__courseCompletionsInitialized = true;
26277
26278    const STORAGE_KEY = "course-completions";
26279
26280    function slugify(text) {
26281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
26282    }
26283
26284    function getCompletions() {
26285      try {
26286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
26287      } catch {
26288        return [];
26289      }
26290    }
26291
26292    function saveCompletions(completions) {
26293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
26294    }
26295
26296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
26297    function canonicalFromStored(storedId) {
26298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26299      if (!m) return slugify(storedId);
26300      const [, slug, p, mv, title] = m;
26301      return `${slug}-${p}-${mv}-${slugify(title)}`;
26302    }
26303
26304    function canonicalPlusOne(storedId) {
26305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26306      if (!m) return null;
26307      const [, slug, p, mv, title] = m;
26308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
26309    }
26310
26311    function isCompleted(canonicalId, completions) {
26312      if (completions.includes(canonicalId)) return true;
26313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
26314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
26315    }
26316
26317    function migrateCompletions() {
26318      const completions = getCompletions();
26319      let changed = false;
26320      const migrated = completions.map((c) => {
26321        const canon = canonicalFromStored(c);
26322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
26323        const plusOne = canonicalPlusOne(c);
26324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
26325          if (plusOne !== c) changed = true;
26326          return plusOne;
26327        }
26328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
26329          changed = true;
26330          return canon;
26331        }
26332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
26333        if (canon !== c) {
26334          changed = true;
26335          return canon;
26336        }
26337        return c;
26338      });
26339      // Elimina duplicados tras migración
26340      const deduped = [...new Set(migrated)];
26341      if (changed || deduped.length !== completions.length) {
26342        saveCompletions(deduped);
26343        return deduped;
26344      }
26345      return completions;
26346    }
26347
26348    function updateButtonState(btn, completions) {
26349      const id = btn.dataset.completionId;
26350      const list = completions ?? getCompletions();
26351      btn.classList.toggle("completed", isCompleted(id, list));
26352    }
26353
26354    function checkModuleCompletion(completions) {
26355      const list = completions ?? getCompletions();
26356      document.querySelectorAll(".course-video").forEach((module) => {
26357        const btns = module.querySelectorAll(".complete-btn");
26358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
26359        const check = module.querySelector(".module-check");
26360        if (check) check.classList.toggle("visible", allCompleted);
26361      });
26362    }
26363
26364    function initCompletionButtons() {
26365      const completions = migrateCompletions();
26366
26367      document.querySelectorAll(".complete-btn").forEach((btn) => {
26368        if (btn.dataset.completionInit) return;
26369        btn.dataset.completionInit = "true";
26370        updateButtonState(btn, completions);
26371
26372        btn.addEventListener("click", (e) => {
26373          e.preventDefault();
26374          e.stopPropagation();
26375
26376          const id = btn.dataset.completionId;
26377          let list = getCompletions();
26378          const completed = isCompleted(id, list);
26379
26380          if (completed) {
26381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
26382            list = list.filter((c) =>
26382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
26383          } else {
26384            list.push(id);
26385          }
26386
26387          saveCompletions(list);
26388          updateButtonState(btn, list);
26389          checkModuleCompletion(list);
26390        });
26391      });
26392
26393      checkModuleCompletion(completions);
26394    }
26395
26396    if (document.readyState === "loading") {
26397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
26398    } else {
26399      initCompletionButtons();
26400    }
26401  })();
26402</script>
26402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="6655" data-end="6894" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Responsive: Preferencias de usuario</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>4 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-responsive-preferencias-de-usuario" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
26402<script>
26403  (() => {
26404    if (window.__courseLazyPlayersInitialized) return;
26405    window.__courseLazyPlayersInitialized = true;
26406
26407    function createPlayer(details) {
26408      const videoId = details.dataset.videoId;
26409      const start = details.dataset.start;
26410      const end = details.dataset.end;
26411
26412      const container = document.createElement("div");
26413      container.className = "player-container";
26414
26415      const spinner = document.createElement("div");
26416      spinner.className = "spinner";
26417      container.appendChild(spinner);
26418
26419      const controller = document.createElement("media-controller");
26420      controller.setAttribute("defaultsubtitles", "");
26421
26422      const provider = document.createElement("youtube-media-provider");
26423      provider.setAttribute("slot", "media");
26424      provider.setAttribute("video-id", videoId);
26425      provider.setAttribute("start", start);
26426      provider.setAttribute("end", end);
26427
26428      const controlBar = document.createElement("media-control-bar");
26429
26430      const playBtn = document.createElement("media-play-button");
26431      const timeDisplay = document.createElement("media-time-display");
26432      timeDisplay.setAttribute("showduration", "");
26433      const timeRange = document.createElement("media-time-range");
26434      const playbackRate = document.createElement("media-playback-rate-button");
26435      const muteBtn = document.createElement("media-mute-button");
26436      const volumeRange = document.createElement("media-volume-range");
26437
26438      const captionsBtn = document.createElement("media-captions-button");
26439      captionsBtn.addEventListener("click", (e) => {
26440        e.stopPropagation();
26441        const provider = container.querySelector("youtube-media-provider");
26442        if (provider) provider.toggleCaptions();
26443      });
26444
26445      const qualityBtn = document.createElement("media-chrome-button");
26446      qualityBtn.textContent = "--";
26447      qualityBtn.addEventListener("click", () => {
26448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
26449      });
26450
26451      const fullscreenBtn = document.createElement("media-fullscreen-button");
26452
26453      controlBar.append(
26454        playBtn, timeDisplay, timeRange,
26455        playbackRate, muteBtn, volumeRange,
26456        captionsBtn, qualityBtn, fullscreenBtn
26457      );
26458
26459      const poster = document.createElement("media-poster-image");
26460      poster.setAttribute("slot", "poster");
26461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
26462
26463      controller.append(provider, poster, controlBar);
26464
26465      captionsBtn.setAttribute("aria-checked", "true");
26466      controller.setAttribute("mediasubtitlesshowing", "on");
26467
26468      container.appendChild(controller);
26469      details.appendChild(container);
26470
26471      provider.addEventListener("waiting", () => container.classList.add("loading"));
26472      provider.addEventListener("playing", () => container.classList.remove("loading"));
26473      provider.addEventListener("play", () => container.classList.remove("loading"));
26474
26475      provider._textTracks.addEventListener("change", () => {
26476        const enabled = provider._captionsEnabled;
26477        captionsBtn.setAttribute("aria-checked", enabled);
26478        if (enabled) {
26479          controller.setAttribute("mediasubtitlesshowing", "on");
26480        } else {
26481          controller.removeAttribute("mediasubtitlesshowing");
26482        }
26483      });
26484
26485      provider.addEventListener("qualitychange", (e) => {
26486        qualityBtn.textContent = e.detail.quality;
26487      });
26488    }
26489
26490    function initLazyPlayers() {
26491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
26492        if (details.dataset.lazyInit) return;
26493        details.dataset.lazyInit = "true";
26494        details.addEventListener("toggle", () => {
26495          if (details.open && !details.querySelector(".player-container")) {
26496            createPlayer(details);
26497          } else if (!details.open) {
26498            const provider = details.querySelector("youtube-media-provider");
26499            if (provider && typeof provider.pause === "function") {
26500              provider.pause();
26501            }
26502          }
26503        });
26504      });
26505    }
26506
26507    if (document.readyState === "loading") {
26508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
26509    } else {
26510      initLazyPlayers();
26511    }
26512  })();
26513</script>
26513<script>
26514  (() => {
26515    if (window.__courseCompletionsInitialized) return;
26516    window.__courseCompletionsInitialized = true;
26517
26518    const STORAGE_KEY = "course-completions";
26519
26520    function slugify(text) {
26521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
26522    }
26523
26524    function getCompletions() {
26525      try {
26526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
26527      } catch {
26528        return [];
26529      }
26530    }
26531
26532    function saveCompletions(completions) {
26533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
26534    }
26535
26536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
26537    function canonicalFromStored(storedId) {
26538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26539      if (!m) return slugify(storedId);
26540      const [, slug, p, mv, title] = m;
26541      return `${slug}-${p}-${mv}-${slugify(title)}`;
26542    }
26543
26544    function canonicalPlusOne(storedId) {
26545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26546      if (!m) return null;
26547      const [, slug, p, mv, title] = m;
26548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
26549    }
26550
26551    function isCompleted(canonicalId, completions) {
26552      if (completions.includes(canonicalId)) return true;
26553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
26554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
26555    }
26556
26557    function migrateCompletions() {
26558      const completions = getCompletions();
26559      let changed = false;
26560      const migrated = completions.map((c) => {
26561        const canon = canonicalFromStored(c);
26562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
26563        const plusOne = canonicalPlusOne(c);
26564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
26565          if (plusOne !== c) changed = true;
26566          return plusOne;
26567        }
26568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
26569          changed = true;
26570          return canon;
26571        }
26572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
26573        if (canon !== c) {
26574          changed = true;
26575          return canon;
26576        }
26577        return c;
26578      });
26579      // Elimina duplicados tras migración
26580      const deduped = [...new Set(migrated)];
26581      if (changed || deduped.length !== completions.length) {
26582        saveCompletions(deduped);
26583        return deduped;
26584      }
26585      return completions;
26586    }
26587
26588    function updateButtonState(btn, completions) {
26589      const id = btn.dataset.completionId;
26590      const list = completions ?? getCompletions();
26591      btn.classList.toggle("completed", isCompleted(id, list));
26592    }
26593
26594    function checkModuleCompletion(completions) {
26595      const list = completions ?? getCompletions();
26596      document.querySelectorAll(".course-video").forEach((module) => {
26597        const btns = module.querySelectorAll(".complete-btn");
26598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
26599        const check = module.querySelector(".module-check");
26600        if (check) check.classList.toggle("visible", allCompleted);
26601      });
26602    }
26603
26604    function initCompletionButtons() {
26605      const completions = migrateCompletions();
26606
26607      document.querySelectorAll(".complete-btn").forEach((btn) => {
26608        if (btn.dataset.completionInit) return;
26609        btn.dataset.completionInit = "true";
26610        updateButtonState(btn, completions);
26611
26612        btn.addEventListener("click", (e) => {
26613          e.preventDefault();
26614          e.stopPropagation();
26615
26616          const id = btn.dataset.completionId;
26617          let list = getCompletions();
26618          const completed = isCompleted(id, list);
26619
26620          if (completed) {
26621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
26622            list = list.filter((c) =>
26622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
26623          } else {
26624            list.push(id);
26625          }
26626
26627          saveCompletions(list);
26628          updateButtonState(btn, list);
26629          checkModuleCompletion(list);
26630        });
26631      });
26632
26633      checkModuleCompletion(completions);
26634    }
26635
26636    if (document.readyState === "loading") {
26637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
26638    } else {
26639      initCompletionButtons();
26640    }
26641  })();
26642</script>
26642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="6894" data-end="7414" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Responsive: Container queries</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-responsive-container-queries" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
26642<script>
26643  (() => {
26644    if (window.__courseLazyPlayersInitialized) return;
26645    window.__courseLazyPlayersInitialized = true;
26646
26647    function createPlayer(details) {
26648      const videoId = details.dataset.videoId;
26649      const start = details.dataset.start;
26650      const end = details.dataset.end;
26651
26652      const container = document.createElement("div");
26653      container.className = "player-container";
26654
26655      const spinner = document.createElement("div");
26656      spinner.className = "spinner";
26657      container.appendChild(spinner);
26658
26659      const controller = document.createElement("media-controller");
26660      controller.setAttribute("defaultsubtitles", "");
26661
26662      const provider = document.createElement("youtube-media-provider");
26663      provider.setAttribute("slot", "media");
26664      provider.setAttribute("video-id", videoId);
26665      provider.setAttribute("start", start);
26666      provider.setAttribute("end", end);
26667
26668      const controlBar = document.createElement("media-control-bar");
26669
26670      const playBtn = document.createElement("media-play-button");
26671      const timeDisplay = document.createElement("media-time-display");
26672      timeDisplay.setAttribute("showduration", "");
26673      const timeRange = document.createElement("media-time-range");
26674      const playbackRate = document.createElement("media-playback-rate-button");
26675      const muteBtn = document.createElement("media-mute-button");
26676      const volumeRange = document.createElement("media-volume-range");
26677
26678      const captionsBtn = document.createElement("media-captions-button");
26679      captionsBtn.addEventListener("click", (e) => {
26680        e.stopPropagation();
26681        const provider = container.querySelector("youtube-media-provider");
26682        if (provider) provider.toggleCaptions();
26683      });
26684
26685      const qualityBtn = document.createElement("media-chrome-button");
26686      qualityBtn.textContent = "--";
26687      qualityBtn.addEventListener("click", () => {
26688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
26689      });
26690
26691      const fullscreenBtn = document.createElement("media-fullscreen-button");
26692
26693      controlBar.append(
26694        playBtn, timeDisplay, timeRange,
26695        playbackRate, muteBtn, volumeRange,
26696        captionsBtn, qualityBtn, fullscreenBtn
26697      );
26698
26699      const poster = document.createElement("media-poster-image");
26700      poster.setAttribute("slot", "poster");
26701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
26702
26703      controller.append(provider, poster, controlBar);
26704
26705      captionsBtn.setAttribute("aria-checked", "true");
26706      controller.setAttribute("mediasubtitlesshowing", "on");
26707
26708      container.appendChild(controller);
26709      details.appendChild(container);
26710
26711      provider.addEventListener("waiting", () => container.classList.add("loading"));
26712      provider.addEventListener("playing", () => container.classList.remove("loading"));
26713      provider.addEventListener("play", () => container.classList.remove("loading"));
26714
26715      provider._textTracks.addEventListener("change", () => {
26716        const enabled = provider._captionsEnabled;
26717        captionsBtn.setAttribute("aria-checked", enabled);
26718        if (enabled) {
26719          controller.setAttribute("mediasubtitlesshowing", "on");
26720        } else {
26721          controller.removeAttribute("mediasubtitlesshowing");
26722        }
26723      });
26724
26725      provider.addEventListener("qualitychange", (e) => {
26726        qualityBtn.textContent = e.detail.quality;
26727      });
26728    }
26729
26730    function initLazyPlayers() {
26731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
26732        if (details.dataset.lazyInit) return;
26733        details.dataset.lazyInit = "true";
26734        details.addEventListener("toggle", () => {
26735          if (details.open && !details.querySelector(".player-container")) {
26736            createPlayer(details);
26737          } else if (!details.open) {
26738            const provider = details.querySelector("youtube-media-provider");
26739            if (provider && typeof provider.pause === "function") {
26740              provider.pause();
26741            }
26742          }
26743        });
26744      });
26745    }
26746
26747    if (document.readyState === "loading") {
26748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
26749    } else {
26750      initLazyPlayers();
26751    }
26752  })();
26753</script>
26753<script>
26754  (() => {
26755    if (window.__courseCompletionsInitialized) return;
26756    window.__courseCompletionsInitialized = true;
26757
26758    const STORAGE_KEY = "course-completions";
26759
26760    function slugify(text) {
26761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
26762    }
26763
26764    function getCompletions() {
26765      try {
26766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
26767      } catch {
26768        return [];
26769      }
26770    }
26771
26772    function saveCompletions(completions) {
26773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
26774    }
26775
26776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
26777    function canonicalFromStored(storedId) {
26778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26779      if (!m) return slugify(storedId);
26780      const [, slug, p, mv, title] = m;
26781      return `${slug}-${p}-${mv}-${slugify(title)}`;
26782    }
26783
26784    function canonicalPlusOne(storedId) {
26785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
26786      if (!m) return null;
26787      const [, slug, p, mv, title] = m;
26788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
26789    }
26790
26791    function isCompleted(canonicalId, completions) {
26792      if (completions.includes(canonicalId)) return true;
26793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
26794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
26795    }
26796
26797    function migrateCompletions() {
26798      const completions = getCompletions();
26799      let changed = false;
26800      const migrated = completions.map((c) => {
26801        const canon = canonicalFromStored(c);
26802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
26803        const plusOne = canonicalPlusOne(c);
26804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
26805          if (plusOne !== c) changed = true;
26806          return plusOne;
26807        }
26808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
26809          changed = true;
26810          return canon;
26811        }
26812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
26813        if (canon !== c) {
26814          changed = true;
26815          return canon;
26816        }
26817        return c;
26818      });
26819      // Elimina duplicados tras migración
26820      const deduped = [...new Set(migrated)];
26821      if (changed || deduped.length !== completions.length) {
26822        saveCompletions(deduped);
26823        return deduped;
26824      }
26825      return completions;
26826    }
26827
26828    function updateButtonState(btn, completions) {
26829      const id = btn.dataset.completionId;
26830      const list = completions ?? getCompletions();
26831      btn.classList.toggle("completed", isCompleted(id, list));
26832    }
26833
26834    function checkModuleCompletion(completions) {
26835      const list = completions ?? getCompletions();
26836      document.querySelectorAll(".course-video").forEach((module) => {
26837        const btns = module.querySelectorAll(".complete-btn");
26838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
26839        const check = module.querySelector(".module-check");
26840        if (check) check.classList.toggle("visible", allCompleted);
26841      });
26842    }
26843
26844    function initCompletionButtons() {
26845      const completions = migrateCompletions();
26846
26847      document.querySelectorAll(".complete-btn").forEach((btn) => {
26848        if (btn.dataset.completionInit) return;
26849        btn.dataset.completionInit = "true";
26850        updateButtonState(btn, completions);
26851
26852        btn.addEventListener("click", (e) => {
26853          e.preventDefault();
26854          e.stopPropagation();
26855
26856          const id = btn.dataset.completionId;
26857          let list = getCompletions();
26858          const completed = isCompleted(id, list);
26859
26860          if (completed) {
26861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
26862            list = list.filter((c) =>
26862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
26863          } else {
26864            list.push(id);
26865          }
26866
26867          saveCompletions(list);
26868          updateButtonState(btn, list);
26869          checkModuleCompletion(list);
26870        });
26871      });
26872
26873      checkModuleCompletion(completions);
26874    }
26875
26876    if (document.readyState === "loading") {
26877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
26878    } else {
26879      initCompletionButtons();
26880    }
26881  })();
26882</script>
26882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="7752" data-end="8139" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. ¿Qué es la cascada CSS?</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>6 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-que-es-la-cascada-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
26882<script>
26883  (() => {
26884    if (window.__courseLazyPlayersInitialized) return;
26885    window.__courseLazyPlayersInitialized = true;
26886
26887    function createPlayer(details) {
26888      const videoId = details.dataset.videoId;
26889      const start = details.dataset.start;
26890      const end = details.dataset.end;
26891
26892      const container = document.createElement("div");
26893      container.className = "player-container";
26894
26895      const spinner = document.createElement("div");
26896      spinner.className = "spinner";
26897      container.appendChild(spinner);
26898
26899      const controller = document.createElement("media-controller");
26900      controller.setAttribute("defaultsubtitles", "");
26901
26902      const provider = document.createElement("youtube-media-provider");
26903      provider.setAttribute("slot", "media");
26904      provider.setAttribute("video-id", videoId);
26905      provider.setAttribute("start", start);
26906      provider.setAttribute("end", end);
26907
26908      const controlBar = document.createElement("media-control-bar");
26909
26910      const playBtn = document.createElement("media-play-button");
26911      const timeDisplay = document.createElement("media-time-display");
26912      timeDisplay.setAttribute("showduration", "");
26913      const timeRange = document.createElement("media-time-range");
26914      const playbackRate = document.createElement("media-playback-rate-button");
26915      const muteBtn = document.createElement("media-mute-button");
26916      const volumeRange = document.createElement("media-volume-range");
26917
26918      const captionsBtn = document.createElement("media-captions-button");
26919      captionsBtn.addEventListener("click", (e) => {
26920        e.stopPropagation();
26921        const provider = container.querySelector("youtube-media-provider");
26922        if (provider) provider.toggleCaptions();
26923      });
26924
26925      const qualityBtn = document.createElement("media-chrome-button");
26926      qualityBtn.textContent = "--";
26927      qualityBtn.addEventListener("click", () => {
26928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
26929      });
26930
26931      const fullscreenBtn = document.createElement("media-fullscreen-button");
26932
26933      controlBar.append(
26934        playBtn, timeDisplay, timeRange,
26935        playbackRate, muteBtn, volumeRange,
26936        captionsBtn, qualityBtn, fullscreenBtn
26937      );
26938
26939      const poster = document.createElement("media-poster-image");
26940      poster.setAttribute("slot", "poster");
26941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
26942
26943      controller.append(provider, poster, controlBar);
26944
26945      captionsBtn.setAttribute("aria-checked", "true");
26946      controller.setAttribute("mediasubtitlesshowing", "on");
26947
26948      container.appendChild(controller);
26949      details.appendChild(container);
26950
26951      provider.addEventListener("waiting", () => container.classList.add("loading"));
26952      provider.addEventListener("playing", () => container.classList.remove("loading"));
26953      provider.addEventListener("play", () => container.classList.remove("loading"));
26954
26955      provider._textTracks.addEventListener("change", () => {
26956        const enabled = provider._captionsEnabled;
26957        captionsBtn.setAttribute("aria-checked", enabled);
26958        if (enabled) {
26959          controller.setAttribute("mediasubtitlesshowing", "on");
26960        } else {
26961          controller.removeAttribute("mediasubtitlesshowing");
26962        }
26963      });
26964
26965      provider.addEventListener("qualitychange", (e) => {
26966        qualityBtn.textContent = e.detail.quality;
26967      });
26968    }
26969
26970    function initLazyPlayers() {
26971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
26972        if (details.dataset.lazyInit) return;
26973        details.dataset.lazyInit = "true";
26974        details.addEventListener("toggle", () => {
26975          if (details.open && !details.querySelector(".player-container")) {
26976            createPlayer(details);
26977          } else if (!details.open) {
26978            const provider = details.querySelector("youtube-media-provider");
26979            if (provider && typeof provider.pause === "function") {
26980              provider.pause();
26981            }
26982          }
26983        });
26984      });
26985    }
26986
26987    if (document.readyState === "loading") {
26988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
26989    } else {
26990      initLazyPlayers();
26991    }
26992  })();
26993</script>
26993<script>
26994  (() => {
26995    if (window.__courseCompletionsInitialized) return;
26996    window.__courseCompletionsInitialized = true;
26997
26998    const STORAGE_KEY = "course-completions";
26999
27000    function slugify(text) {
27001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27002    }
27003
27004    function getCompletions() {
27005      try {
27006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
27007      } catch {
27008        return [];
27009      }
27010    }
27011
27012    function saveCompletions(completions) {
27013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
27014    }
27015
27016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
27017    function canonicalFromStored(storedId) {
27018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27019      if (!m) return slugify(storedId);
27020      const [, slug, p, mv, title] = m;
27021      return `${slug}-${p}-${mv}-${slugify(title)}`;
27022    }
27023
27024    function canonicalPlusOne(storedId) {
27025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27026      if (!m) return null;
27027      const [, slug, p, mv, title] = m;
27028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
27029    }
27030
27031    function isCompleted(canonicalId, completions) {
27032      if (completions.includes(canonicalId)) return true;
27033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
27034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
27035    }
27036
27037    function migrateCompletions() {
27038      const completions = getCompletions();
27039      let changed = false;
27040      const migrated = completions.map((c) => {
27041        const canon = canonicalFromStored(c);
27042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
27043        const plusOne = canonicalPlusOne(c);
27044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
27045          if (plusOne !== c) changed = true;
27046          return plusOne;
27047        }
27048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
27049          changed = true;
27050          return canon;
27051        }
27052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
27053        if (canon !== c) {
27054          changed = true;
27055          return canon;
27056        }
27057        return c;
27058      });
27059      // Elimina duplicados tras migración
27060      const deduped = [...new Set(migrated)];
27061      if (changed || deduped.length !== completions.length) {
27062        saveCompletions(deduped);
27063        return deduped;
27064      }
27065      return completions;
27066    }
27067
27068    function updateButtonState(btn, completions) {
27069      const id = btn.dataset.completionId;
27070      const list = completions ?? getCompletions();
27071      btn.classList.toggle("completed", isCompleted(id, list));
27072    }
27073
27074    function checkModuleCompletion(completions) {
27075      const list = completions ?? getCompletions();
27076      document.querySelectorAll(".course-video").forEach((module) => {
27077        const btns = module.querySelectorAll(".complete-btn");
27078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
27079        const check = module.querySelector(".module-check");
27080        if (check) check.classList.toggle("visible", allCompleted);
27081      });
27082    }
27083
27084    function initCompletionButtons() {
27085      const completions = migrateCompletions();
27086
27087      document.querySelectorAll(".complete-btn").forEach((btn) => {
27088        if (btn.dataset.completionInit) return;
27089        btn.dataset.completionInit = "true";
27090        updateButtonState(btn, completions);
27091
27092        btn.addEventListener("click", (e) => {
27093          e.preventDefault();
27094          e.stopPropagation();
27095
27096          const id = btn.dataset.completionId;
27097          let list = getCompletions();
27098          const completed = isCompleted(id, list);
27099
27100          if (completed) {
27101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
27102            list = list.filter((c) =>
27102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
27103          } else {
27104            list.push(id);
27105          }
27106
27107          saveCompletions(list);
27108          updateButtonState(btn, list);
27109          checkModuleCompletion(list);
27110        });
27111      });
27112
27113      checkModuleCompletion(completions);
27114    }
27115
27116    if (document.readyState === "loading") {
27117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
27118    } else {
27119      initCompletionButtons();
27120    }
27121  })();
27122</script>
27122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="8139" data-end="8711" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Cascada: Herencia</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>10 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-cascada-herencia" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
27122<script>
27123  (() => {
27124    if (window.__courseLazyPlayersInitialized) return;
27125    window.__courseLazyPlayersInitialized = true;
27126
27127    function createPlayer(details) {
27128      const videoId = details.dataset.videoId;
27129      const start = details.dataset.start;
27130      const end = details.dataset.end;
27131
27132      const container = document.createElement("div");
27133      container.className = "player-container";
27134
27135      const spinner = document.createElement("div");
27136      spinner.className = "spinner";
27137      container.appendChild(spinner);
27138
27139      const controller = document.createElement("media-controller");
27140      controller.setAttribute("defaultsubtitles", "");
27141
27142      const provider = document.createElement("youtube-media-provider");
27143      provider.setAttribute("slot", "media");
27144      provider.setAttribute("video-id", videoId);
27145      provider.setAttribute("start", start);
27146      provider.setAttribute("end", end);
27147
27148      const controlBar = document.createElement("media-control-bar");
27149
27150      const playBtn = document.createElement("media-play-button");
27151      const timeDisplay = document.createElement("media-time-display");
27152      timeDisplay.setAttribute("showduration", "");
27153      const timeRange = document.createElement("media-time-range");
27154      const playbackRate = document.createElement("media-playback-rate-button");
27155      const muteBtn = document.createElement("media-mute-button");
27156      const volumeRange = document.createElement("media-volume-range");
27157
27158      const captionsBtn = document.createElement("media-captions-button");
27159      captionsBtn.addEventListener("click", (e) => {
27160        e.stopPropagation();
27161        const provider = container.querySelector("youtube-media-provider");
27162        if (provider) provider.toggleCaptions();
27163      });
27164
27165      const qualityBtn = document.createElement("media-chrome-button");
27166      qualityBtn.textContent = "--";
27167      qualityBtn.addEventListener("click", () => {
27168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
27169      });
27170
27171      const fullscreenBtn = document.createElement("media-fullscreen-button");
27172
27173      controlBar.append(
27174        playBtn, timeDisplay, timeRange,
27175        playbackRate, muteBtn, volumeRange,
27176        captionsBtn, qualityBtn, fullscreenBtn
27177      );
27178
27179      const poster = document.createElement("media-poster-image");
27180      poster.setAttribute("slot", "poster");
27181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
27182
27183      controller.append(provider, poster, controlBar);
27184
27185      captionsBtn.setAttribute("aria-checked", "true");
27186      controller.setAttribute("mediasubtitlesshowing", "on");
27187
27188      container.appendChild(controller);
27189      details.appendChild(container);
27190
27191      provider.addEventListener("waiting", () => container.classList.add("loading"));
27192      provider.addEventListener("playing", () => container.classList.remove("loading"));
27193      provider.addEventListener("play", () => container.classList.remove("loading"));
27194
27195      provider._textTracks.addEventListener("change", () => {
27196        const enabled = provider._captionsEnabled;
27197        captionsBtn.setAttribute("aria-checked", enabled);
27198        if (enabled) {
27199          controller.setAttribute("mediasubtitlesshowing", "on");
27200        } else {
27201          controller.removeAttribute("mediasubtitlesshowing");
27202        }
27203      });
27204
27205      provider.addEventListener("qualitychange", (e) => {
27206        qualityBtn.textContent = e.detail.quality;
27207      });
27208    }
27209
27210    function initLazyPlayers() {
27211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
27212        if (details.dataset.lazyInit) return;
27213        details.dataset.lazyInit = "true";
27214        details.addEventListener("toggle", () => {
27215          if (details.open && !details.querySelector(".player-container")) {
27216            createPlayer(details);
27217          } else if (!details.open) {
27218            const provider = details.querySelector("youtube-media-provider");
27219            if (provider && typeof provider.pause === "function") {
27220              provider.pause();
27221            }
27222          }
27223        });
27224      });
27225    }
27226
27227    if (document.readyState === "loading") {
27228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
27229    } else {
27230      initLazyPlayers();
27231    }
27232  })();
27233</script>
27233<script>
27234  (() => {
27235    if (window.__courseCompletionsInitialized) return;
27236    window.__courseCompletionsInitialized = true;
27237
27238    const STORAGE_KEY = "course-completions";
27239
27240    function slugify(text) {
27241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27242    }
27243
27244    function getCompletions() {
27245      try {
27246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
27247      } catch {
27248        return [];
27249      }
27250    }
27251
27252    function saveCompletions(completions) {
27253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
27254    }
27255
27256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
27257    function canonicalFromStored(storedId) {
27258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27259      if (!m) return slugify(storedId);
27260      const [, slug, p, mv, title] = m;
27261      return `${slug}-${p}-${mv}-${slugify(title)}`;
27262    }
27263
27264    function canonicalPlusOne(storedId) {
27265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27266      if (!m) return null;
27267      const [, slug, p, mv, title] = m;
27268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
27269    }
27270
27271    function isCompleted(canonicalId, completions) {
27272      if (completions.includes(canonicalId)) return true;
27273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
27274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
27275    }
27276
27277    function migrateCompletions() {
27278      const completions = getCompletions();
27279      let changed = false;
27280      const migrated = completions.map((c) => {
27281        const canon = canonicalFromStored(c);
27282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
27283        const plusOne = canonicalPlusOne(c);
27284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
27285          if (plusOne !== c) changed = true;
27286          return plusOne;
27287        }
27288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
27289          changed = true;
27290          return canon;
27291        }
27292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
27293        if (canon !== c) {
27294          changed = true;
27295          return canon;
27296        }
27297        return c;
27298      });
27299      // Elimina duplicados tras migración
27300      const deduped = [...new Set(migrated)];
27301      if (changed || deduped.length !== completions.length) {
27302        saveCompletions(deduped);
27303        return deduped;
27304      }
27305      return completions;
27306    }
27307
27308    function updateButtonState(btn, completions) {
27309      const id = btn.dataset.completionId;
27310      const list = completions ?? getCompletions();
27311      btn.classList.toggle("completed", isCompleted(id, list));
27312    }
27313
27314    function checkModuleCompletion(completions) {
27315      const list = completions ?? getCompletions();
27316      document.querySelectorAll(".course-video").forEach((module) => {
27317        const btns = module.querySelectorAll(".complete-btn");
27318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
27319        const check = module.querySelector(".module-check");
27320        if (check) check.classList.toggle("visible", allCompleted);
27321      });
27322    }
27323
27324    function initCompletionButtons() {
27325      const completions = migrateCompletions();
27326
27327      document.querySelectorAll(".complete-btn").forEach((btn) => {
27328        if (btn.dataset.completionInit) return;
27329        btn.dataset.completionInit = "true";
27330        updateButtonState(btn, completions);
27331
27332        btn.addEventListener("click", (e) => {
27333          e.preventDefault();
27334          e.stopPropagation();
27335
27336          const id = btn.dataset.completionId;
27337          let list = getCompletions();
27338          const completed = isCompleted(id, list);
27339
27340          if (completed) {
27341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
27342            list = list.filter((c) =>
27342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
27343          } else {
27344            list.push(id);
27345          }
27346
27347          saveCompletions(list);
27348          updateButtonState(btn, list);
27349          checkModuleCompletion(list);
27350        });
27351      });
27352
27353      checkModuleCompletion(completions);
27354    }
27355
27356    if (document.readyState === "loading") {
27357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
27358    } else {
27359      initCompletionButtons();
27360    }
27361  })();
27362</script>
27362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="8711" data-end="9540" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Cascada: Estructura tradicional</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-cascada-estructura-tradicional" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
27362<script>
27363  (() => {
27364    if (window.__courseLazyPlayersInitialized) return;
27365    window.__courseLazyPlayersInitialized = true;
27366
27367    function createPlayer(details) {
27368      const videoId = details.dataset.videoId;
27369      const start = details.dataset.start;
27370      const end = details.dataset.end;
27371
27372      const container = document.createElement("div");
27373      container.className = "player-container";
27374
27375      const spinner = document.createElement("div");
27376      spinner.className = "spinner";
27377      container.appendChild(spinner);
27378
27379      const controller = document.createElement("media-controller");
27380      controller.setAttribute("defaultsubtitles", "");
27381
27382      const provider = document.createElement("youtube-media-provider");
27383      provider.setAttribute("slot", "media");
27384      provider.setAttribute("video-id", videoId);
27385      provider.setAttribute("start", start);
27386      provider.setAttribute("end", end);
27387
27388      const controlBar = document.createElement("media-control-bar");
27389
27390      const playBtn = document.createElement("media-play-button");
27391      const timeDisplay = document.createElement("media-time-display");
27392      timeDisplay.setAttribute("showduration", "");
27393      const timeRange = document.createElement("media-time-range");
27394      const playbackRate = document.createElement("media-playback-rate-button");
27395      const muteBtn = document.createElement("media-mute-button");
27396      const volumeRange = document.createElement("media-volume-range");
27397
27398      const captionsBtn = document.createElement("media-captions-button");
27399      captionsBtn.addEventListener("click", (e) => {
27400        e.stopPropagation();
27401        const provider = container.querySelector("youtube-media-provider");
27402        if (provider) provider.toggleCaptions();
27403      });
27404
27405      const qualityBtn = document.createElement("media-chrome-button");
27406      qualityBtn.textContent = "--";
27407      qualityBtn.addEventListener("click", () => {
27408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
27409      });
27410
27411      const fullscreenBtn = document.createElement("media-fullscreen-button");
27412
27413      controlBar.append(
27414        playBtn, timeDisplay, timeRange,
27415        playbackRate, muteBtn, volumeRange,
27416        captionsBtn, qualityBtn, fullscreenBtn
27417      );
27418
27419      const poster = document.createElement("media-poster-image");
27420      poster.setAttribute("slot", "poster");
27421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
27422
27423      controller.append(provider, poster, controlBar);
27424
27425      captionsBtn.setAttribute("aria-checked", "true");
27426      controller.setAttribute("mediasubtitlesshowing", "on");
27427
27428      container.appendChild(controller);
27429      details.appendChild(container);
27430
27431      provider.addEventListener("waiting", () => container.classList.add("loading"));
27432      provider.addEventListener("playing", () => container.classList.remove("loading"));
27433      provider.addEventListener("play", () => container.classList.remove("loading"));
27434
27435      provider._textTracks.addEventListener("change", () => {
27436        const enabled = provider._captionsEnabled;
27437        captionsBtn.setAttribute("aria-checked", enabled);
27438        if (enabled) {
27439          controller.setAttribute("mediasubtitlesshowing", "on");
27440        } else {
27441          controller.removeAttribute("mediasubtitlesshowing");
27442        }
27443      });
27444
27445      provider.addEventListener("qualitychange", (e) => {
27446        qualityBtn.textContent = e.detail.quality;
27447      });
27448    }
27449
27450    function initLazyPlayers() {
27451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
27452        if (details.dataset.lazyInit) return;
27453        details.dataset.lazyInit = "true";
27454        details.addEventListener("toggle", () => {
27455          if (details.open && !details.querySelector(".player-container")) {
27456            createPlayer(details);
27457          } else if (!details.open) {
27458            const provider = details.querySelector("youtube-media-provider");
27459            if (provider && typeof provider.pause === "function") {
27460              provider.pause();
27461            }
27462          }
27463        });
27464      });
27465    }
27466
27467    if (document.readyState === "loading") {
27468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
27469    } else {
27470      initLazyPlayers();
27471    }
27472  })();
27473</script>
27473<script>
27474  (() => {
27475    if (window.__courseCompletionsInitialized) return;
27476    window.__courseCompletionsInitialized = true;
27477
27478    const STORAGE_KEY = "course-completions";
27479
27480    function slugify(text) {
27481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27482    }
27483
27484    function getCompletions() {
27485      try {
27486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
27487      } catch {
27488        return [];
27489      }
27490    }
27491
27492    function saveCompletions(completions) {
27493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
27494    }
27495
27496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
27497    function canonicalFromStored(storedId) {
27498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27499      if (!m) return slugify(storedId);
27500      const [, slug, p, mv, title] = m;
27501      return `${slug}-${p}-${mv}-${slugify(title)}`;
27502    }
27503
27504    function canonicalPlusOne(storedId) {
27505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27506      if (!m) return null;
27507      const [, slug, p, mv, title] = m;
27508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
27509    }
27510
27511    function isCompleted(canonicalId, completions) {
27512      if (completions.includes(canonicalId)) return true;
27513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
27514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
27515    }
27516
27517    function migrateCompletions() {
27518      const completions = getCompletions();
27519      let changed = false;
27520      const migrated = completions.map((c) => {
27521        const canon = canonicalFromStored(c);
27522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
27523        const plusOne = canonicalPlusOne(c);
27524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
27525          if (plusOne !== c) changed = true;
27526          return plusOne;
27527        }
27528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
27529          changed = true;
27530          return canon;
27531        }
27532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
27533        if (canon !== c) {
27534          changed = true;
27535          return canon;
27536        }
27537        return c;
27538      });
27539      // Elimina duplicados tras migración
27540      const deduped = [...new Set(migrated)];
27541      if (changed || deduped.length !== completions.length) {
27542        saveCompletions(deduped);
27543        return deduped;
27544      }
27545      return completions;
27546    }
27547
27548    function updateButtonState(btn, completions) {
27549      const id = btn.dataset.completionId;
27550      const list = completions ?? getCompletions();
27551      btn.classList.toggle("completed", isCompleted(id, list));
27552    }
27553
27554    function checkModuleCompletion(completions) {
27555      const list = completions ?? getCompletions();
27556      document.querySelectorAll(".course-video").forEach((module) => {
27557        const btns = module.querySelectorAll(".complete-btn");
27558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
27559        const check = module.querySelector(".module-check");
27560        if (check) check.classList.toggle("visible", allCompleted);
27561      });
27562    }
27563
27564    function initCompletionButtons() {
27565      const completions = migrateCompletions();
27566
27567      document.querySelectorAll(".complete-btn").forEach((btn) => {
27568        if (btn.dataset.completionInit) return;
27569        btn.dataset.completionInit = "true";
27570        updateButtonState(btn, completions);
27571
27572        btn.addEventListener("click", (e) => {
27573          e.preventDefault();
27574          e.stopPropagation();
27575
27576          const id = btn.dataset.completionId;
27577          let list = getCompletions();
27578          const completed = isCompleted(id, list);
27579
27580          if (completed) {
27581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
27582            list = list.filter((c) =>
27582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
27583          } else {
27584            list.push(id);
27585          }
27586
27587          saveCompletions(list);
27588          updateButtonState(btn, list);
27589          checkModuleCompletion(list);
27590        });
27591      });
27592
27593      checkModuleCompletion(completions);
27594    }
27595
27596    if (document.readyState === "loading") {
27597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
27598    } else {
27599      initCompletionButtons();
27600    }
27601  })();
27602</script>
27602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="9540" data-end="11175" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. Cascada: Estructura moderna</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>27 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-cascada-estructura-moderna" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
27602<script>
27603  (() => {
27604    if (window.__courseLazyPlayersInitialized) return;
27605    window.__courseLazyPlayersInitialized = true;
27606
27607    function createPlayer(details) {
27608      const videoId = details.dataset.videoId;
27609      const start = details.dataset.start;
27610      const end = details.dataset.end;
27611
27612      const container = document.createElement("div");
27613      container.className = "player-container";
27614
27615      const spinner = document.createElement("div");
27616      spinner.className = "spinner";
27617      container.appendChild(spinner);
27618
27619      const controller = document.createElement("media-controller");
27620      controller.setAttribute("defaultsubtitles", "");
27621
27622      const provider = document.createElement("youtube-media-provider");
27623      provider.setAttribute("slot", "media");
27624      provider.setAttribute("video-id", videoId);
27625      provider.setAttribute("start", start);
27626      provider.setAttribute("end", end);
27627
27628      const controlBar = document.createElement("media-control-bar");
27629
27630      const playBtn = document.createElement("media-play-button");
27631      const timeDisplay = document.createElement("media-time-display");
27632      timeDisplay.setAttribute("showduration", "");
27633      const timeRange = document.createElement("media-time-range");
27634      const playbackRate = document.createElement("media-playback-rate-button");
27635      const muteBtn = document.createElement("media-mute-button");
27636      const volumeRange = document.createElement("media-volume-range");
27637
27638      const captionsBtn = document.createElement("media-captions-button");
27639      captionsBtn.addEventListener("click", (e) => {
27640        e.stopPropagation();
27641        const provider = container.querySelector("youtube-media-provider");
27642        if (provider) provider.toggleCaptions();
27643      });
27644
27645      const qualityBtn = document.createElement("media-chrome-button");
27646      qualityBtn.textContent = "--";
27647      qualityBtn.addEventListener("click", () => {
27648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
27649      });
27650
27651      const fullscreenBtn = document.createElement("media-fullscreen-button");
27652
27653      controlBar.append(
27654        playBtn, timeDisplay, timeRange,
27655        playbackRate, muteBtn, volumeRange,
27656        captionsBtn, qualityBtn, fullscreenBtn
27657      );
27658
27659      const poster = document.createElement("media-poster-image");
27660      poster.setAttribute("slot", "poster");
27661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
27662
27663      controller.append(provider, poster, controlBar);
27664
27665      captionsBtn.setAttribute("aria-checked", "true");
27666      controller.setAttribute("mediasubtitlesshowing", "on");
27667
27668      container.appendChild(controller);
27669      details.appendChild(container);
27670
27671      provider.addEventListener("waiting", () => container.classList.add("loading"));
27672      provider.addEventListener("playing", () => container.classList.remove("loading"));
27673      provider.addEventListener("play", () => container.classList.remove("loading"));
27674
27675      provider._textTracks.addEventListener("change", () => {
27676        const enabled = provider._captionsEnabled;
27677        captionsBtn.setAttribute("aria-checked", enabled);
27678        if (enabled) {
27679          controller.setAttribute("mediasubtitlesshowing", "on");
27680        } else {
27681          controller.removeAttribute("mediasubtitlesshowing");
27682        }
27683      });
27684
27685      provider.addEventListener("qualitychange", (e) => {
27686        qualityBtn.textContent = e.detail.quality;
27687      });
27688    }
27689
27690    function initLazyPlayers() {
27691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
27692        if (details.dataset.lazyInit) return;
27693        details.dataset.lazyInit = "true";
27694        details.addEventListener("toggle", () => {
27695          if (details.open && !details.querySelector(".player-container")) {
27696            createPlayer(details);
27697          } else if (!details.open) {
27698            const provider = details.querySelector("youtube-media-provider");
27699            if (provider && typeof provider.pause === "function") {
27700              provider.pause();
27701            }
27702          }
27703        });
27704      });
27705    }
27706
27707    if (document.readyState === "loading") {
27708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
27709    } else {
27710      initLazyPlayers();
27711    }
27712  })();
27713</script>
27713<script>
27714  (() => {
27715    if (window.__courseCompletionsInitialized) return;
27716    window.__courseCompletionsInitialized = true;
27717
27718    const STORAGE_KEY = "course-completions";
27719
27720    function slugify(text) {
27721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27722    }
27723
27724    function getCompletions() {
27725      try {
27726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
27727      } catch {
27728        return [];
27729      }
27730    }
27731
27732    function saveCompletions(completions) {
27733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
27734    }
27735
27736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
27737    function canonicalFromStored(storedId) {
27738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27739      if (!m) return slugify(storedId);
27740      const [, slug, p, mv, title] = m;
27741      return `${slug}-${p}-${mv}-${slugify(title)}`;
27742    }
27743
27744    function canonicalPlusOne(storedId) {
27745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27746      if (!m) return null;
27747      const [, slug, p, mv, title] = m;
27748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
27749    }
27750
27751    function isCompleted(canonicalId, completions) {
27752      if (completions.includes(canonicalId)) return true;
27753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
27754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
27755    }
27756
27757    function migrateCompletions() {
27758      const completions = getCompletions();
27759      let changed = false;
27760      const migrated = completions.map((c) => {
27761        const canon = canonicalFromStored(c);
27762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
27763        const plusOne = canonicalPlusOne(c);
27764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
27765          if (plusOne !== c) changed = true;
27766          return plusOne;
27767        }
27768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
27769          changed = true;
27770          return canon;
27771        }
27772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
27773        if (canon !== c) {
27774          changed = true;
27775          return canon;
27776        }
27777        return c;
27778      });
27779      // Elimina duplicados tras migración
27780      const deduped = [...new Set(migrated)];
27781      if (changed || deduped.length !== completions.length) {
27782        saveCompletions(deduped);
27783        return deduped;
27784      }
27785      return completions;
27786    }
27787
27788    function updateButtonState(btn, completions) {
27789      const id = btn.dataset.completionId;
27790      const list = completions ?? getCompletions();
27791      btn.classList.toggle("completed", isCompleted(id, list));
27792    }
27793
27794    function checkModuleCompletion(completions) {
27795      const list = completions ?? getCompletions();
27796      document.querySelectorAll(".course-video").forEach((module) => {
27797        const btns = module.querySelectorAll(".complete-btn");
27798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
27799        const check = module.querySelector(".module-check");
27800        if (check) check.classList.toggle("visible", allCompleted);
27801      });
27802    }
27803
27804    function initCompletionButtons() {
27805      const completions = migrateCompletions();
27806
27807      document.querySelectorAll(".complete-btn").forEach((btn) => {
27808        if (btn.dataset.completionInit) return;
27809        btn.dataset.completionInit = "true";
27810        updateButtonState(btn, completions);
27811
27812        btn.addEventListener("click", (e) => {
27813          e.preventDefault();
27814          e.stopPropagation();
27815
27816          const id = btn.dataset.completionId;
27817          let list = getCompletions();
27818          const completed = isCompleted(id, list);
27819
27820          if (completed) {
27821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
27822            list = list.filter((c) =>
27822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
27823          } else {
27824            list.push(id);
27825          }
27826
27827          saveCompletions(list);
27828          updateButtonState(btn, list);
27829          checkModuleCompletion(list);
27830        });
27831      });
27832
27833      checkModuleCompletion(completions);
27834    }
27835
27836    if (document.readyState === "loading") {
27837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
27838    } else {
27839      initCompletionButtons();
27840    }
27841  })();
27842</script>
27842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="11175" data-end="12262" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>14. Cascada: Alcance (scope)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-cascada-alcance-scope" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
27842<script>
27843  (() => {
27844    if (window.__courseLazyPlayersInitialized) return;
27845    window.__courseLazyPlayersInitialized = true;
27846
27847    function createPlayer(details) {
27848      const videoId = details.dataset.videoId;
27849      const start = details.dataset.start;
27850      const end = details.dataset.end;
27851
27852      const container = document.createElement("div");
27853      container.className = "player-container";
27854
27855      const spinner = document.createElement("div");
27856      spinner.className = "spinner";
27857      container.appendChild(spinner);
27858
27859      const controller = document.createElement("media-controller");
27860      controller.setAttribute("defaultsubtitles", "");
27861
27862      const provider = document.createElement("youtube-media-provider");
27863      provider.setAttribute("slot", "media");
27864      provider.setAttribute("video-id", videoId);
27865      provider.setAttribute("start", start);
27866      provider.setAttribute("end", end);
27867
27868      const controlBar = document.createElement("media-control-bar");
27869
27870      const playBtn = document.createElement("media-play-button");
27871      const timeDisplay = document.createElement("media-time-display");
27872      timeDisplay.setAttribute("showduration", "");
27873      const timeRange = document.createElement("media-time-range");
27874      const playbackRate = document.createElement("media-playback-rate-button");
27875      const muteBtn = document.createElement("media-mute-button");
27876      const volumeRange = document.createElement("media-volume-range");
27877
27878      const captionsBtn = document.createElement("media-captions-button");
27879      captionsBtn.addEventListener("click", (e) => {
27880        e.stopPropagation();
27881        const provider = container.querySelector("youtube-media-provider");
27882        if (provider) provider.toggleCaptions();
27883      });
27884
27885      const qualityBtn = document.createElement("media-chrome-button");
27886      qualityBtn.textContent = "--";
27887      qualityBtn.addEventListener("click", () => {
27888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
27889      });
27890
27891      const fullscreenBtn = document.createElement("media-fullscreen-button");
27892
27893      controlBar.append(
27894        playBtn, timeDisplay, timeRange,
27895        playbackRate, muteBtn, volumeRange,
27896        captionsBtn, qualityBtn, fullscreenBtn
27897      );
27898
27899      const poster = document.createElement("media-poster-image");
27900      poster.setAttribute("slot", "poster");
27901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
27902
27903      controller.append(provider, poster, controlBar);
27904
27905      captionsBtn.setAttribute("aria-checked", "true");
27906      controller.setAttribute("mediasubtitlesshowing", "on");
27907
27908      container.appendChild(controller);
27909      details.appendChild(container);
27910
27911      provider.addEventListener("waiting", () => container.classList.add("loading"));
27912      provider.addEventListener("playing", () => container.classList.remove("loading"));
27913      provider.addEventListener("play", () => container.classList.remove("loading"));
27914
27915      provider._textTracks.addEventListener("change", () => {
27916        const enabled = provider._captionsEnabled;
27917        captionsBtn.setAttribute("aria-checked", enabled);
27918        if (enabled) {
27919          controller.setAttribute("mediasubtitlesshowing", "on");
27920        } else {
27921          controller.removeAttribute("mediasubtitlesshowing");
27922        }
27923      });
27924
27925      provider.addEventListener("qualitychange", (e) => {
27926        qualityBtn.textContent = e.detail.quality;
27927      });
27928    }
27929
27930    function initLazyPlayers() {
27931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
27932        if (details.dataset.lazyInit) return;
27933        details.dataset.lazyInit = "true";
27934        details.addEventListener("toggle", () => {
27935          if (details.open && !details.querySelector(".player-container")) {
27936            createPlayer(details);
27937          } else if (!details.open) {
27938            const provider = details.querySelector("youtube-media-provider");
27939            if (provider && typeof provider.pause === "function") {
27940              provider.pause();
27941            }
27942          }
27943        });
27944      });
27945    }
27946
27947    if (document.readyState === "loading") {
27948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
27949    } else {
27950      initLazyPlayers();
27951    }
27952  })();
27953</script>
27953<script>
27954  (() => {
27955    if (window.__courseCompletionsInitialized) return;
27956    window.__courseCompletionsInitialized = true;
27957
27958    const STORAGE_KEY = "course-completions";
27959
27960    function slugify(text) {
27961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27962    }
27963
27964    function getCompletions() {
27965      try {
27966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
27967      } catch {
27968        return [];
27969      }
27970    }
27971
27972    function saveCompletions(completions) {
27973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
27974    }
27975
27976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
27977    function canonicalFromStored(storedId) {
27978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27979      if (!m) return slugify(storedId);
27980      const [, slug, p, mv, title] = m;
27981      return `${slug}-${p}-${mv}-${slugify(title)}`;
27982    }
27983
27984    function canonicalPlusOne(storedId) {
27985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
27986      if (!m) return null;
27987      const [, slug, p, mv, title] = m;
27988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
27989    }
27990
27991    function isCompleted(canonicalId, completions) {
27992      if (completions.includes(canonicalId)) return true;
27993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
27994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
27995    }
27996
27997    function migrateCompletions() {
27998      const completions = getCompletions();
27999      let changed = false;
28000      const migrated = completions.map((c) => {
28001        const canon = canonicalFromStored(c);
28002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
28003        const plusOne = canonicalPlusOne(c);
28004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
28005          if (plusOne !== c) changed = true;
28006          return plusOne;
28007        }
28008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
28009          changed = true;
28010          return canon;
28011        }
28012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
28013        if (canon !== c) {
28014          changed = true;
28015          return canon;
28016        }
28017        return c;
28018      });
28019      // Elimina duplicados tras migración
28020      const deduped = [...new Set(migrated)];
28021      if (changed || deduped.length !== completions.length) {
28022        saveCompletions(deduped);
28023        return deduped;
28024      }
28025      return completions;
28026    }
28027
28028    function updateButtonState(btn, completions) {
28029      const id = btn.dataset.completionId;
28030      const list = completions ?? getCompletions();
28031      btn.classList.toggle("completed", isCompleted(id, list));
28032    }
28033
28034    function checkModuleCompletion(completions) {
28035      const list = completions ?? getCompletions();
28036      document.querySelectorAll(".course-video").forEach((module) => {
28037        const btns = module.querySelectorAll(".complete-btn");
28038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
28039        const check = module.querySelector(".module-check");
28040        if (check) check.classList.toggle("visible", allCompleted);
28041      });
28042    }
28043
28044    function initCompletionButtons() {
28045      const completions = migrateCompletions();
28046
28047      document.querySelectorAll(".complete-btn").forEach((btn) => {
28048        if (btn.dataset.completionInit) return;
28049        btn.dataset.completionInit = "true";
28050        updateButtonState(btn, completions);
28051
28052        btn.addEventListener("click", (e) => {
28053          e.preventDefault();
28054          e.stopPropagation();
28055
28056          const id = btn.dataset.completionId;
28057          let list = getCompletions();
28058          const completed = isCompleted(id, list);
28059
28060          if (completed) {
28061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
28062            list = list.filter((c) =>
28062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
28063          } else {
28064            list.push(id);
28065          }
28066
28067          saveCompletions(list);
28068          updateButtonState(btn, list);
28069          checkModuleCompletion(list);
28070        });
28071      });
28072
28073      checkModuleCompletion(completions);
28074    }
28075
28076    if (document.readyState === "loading") {
28077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
28078    } else {
28079      initCompletionButtons();
28080    }
28081  })();
28082</script>
28082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="12636" data-end="14890" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>15. Máscaras y recortes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>38 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-mascaras-y-recortes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
28082<script>
28083  (() => {
28084    if (window.__courseLazyPlayersInitialized) return;
28085    window.__courseLazyPlayersInitialized = true;
28086
28087    function createPlayer(details) {
28088      const videoId = details.dataset.videoId;
28089      const start = details.dataset.start;
28090      const end = details.dataset.end;
28091
28092      const container = document.createElement("div");
28093      container.className = "player-container";
28094
28095      const spinner = document.createElement("div");
28096      spinner.className = "spinner";
28097      container.appendChild(spinner);
28098
28099      const controller = document.createElement("media-controller");
28100      controller.setAttribute("defaultsubtitles", "");
28101
28102      const provider = document.createElement("youtube-media-provider");
28103      provider.setAttribute("slot", "media");
28104      provider.setAttribute("video-id", videoId);
28105      provider.setAttribute("start", start);
28106      provider.setAttribute("end", end);
28107
28108      const controlBar = document.createElement("media-control-bar");
28109
28110      const playBtn = document.createElement("media-play-button");
28111      const timeDisplay = document.createElement("media-time-display");
28112      timeDisplay.setAttribute("showduration", "");
28113      const timeRange = document.createElement("media-time-range");
28114      const playbackRate = document.createElement("media-playback-rate-button");
28115      const muteBtn = document.createElement("media-mute-button");
28116      const volumeRange = document.createElement("media-volume-range");
28117
28118      const captionsBtn = document.createElement("media-captions-button");
28119      captionsBtn.addEventListener("click", (e) => {
28120        e.stopPropagation();
28121        const provider = container.querySelector("youtube-media-provider");
28122        if (provider) provider.toggleCaptions();
28123      });
28124
28125      const qualityBtn = document.createElement("media-chrome-button");
28126      qualityBtn.textContent = "--";
28127      qualityBtn.addEventListener("click", () => {
28128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
28129      });
28130
28131      const fullscreenBtn = document.createElement("media-fullscreen-button");
28132
28133      controlBar.append(
28134        playBtn, timeDisplay, timeRange,
28135        playbackRate, muteBtn, volumeRange,
28136        captionsBtn, qualityBtn, fullscreenBtn
28137      );
28138
28139      const poster = document.createElement("media-poster-image");
28140      poster.setAttribute("slot", "poster");
28141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
28142
28143      controller.append(provider, poster, controlBar);
28144
28145      captionsBtn.setAttribute("aria-checked", "true");
28146      controller.setAttribute("mediasubtitlesshowing", "on");
28147
28148      container.appendChild(controller);
28149      details.appendChild(container);
28150
28151      provider.addEventListener("waiting", () => container.classList.add("loading"));
28152      provider.addEventListener("playing", () => container.classList.remove("loading"));
28153      provider.addEventListener("play", () => container.classList.remove("loading"));
28154
28155      provider._textTracks.addEventListener("change", () => {
28156        const enabled = provider._captionsEnabled;
28157        captionsBtn.setAttribute("aria-checked", enabled);
28158        if (enabled) {
28159          controller.setAttribute("mediasubtitlesshowing", "on");
28160        } else {
28161          controller.removeAttribute("mediasubtitlesshowing");
28162        }
28163      });
28164
28165      provider.addEventListener("qualitychange", (e) => {
28166        qualityBtn.textContent = e.detail.quality;
28167      });
28168    }
28169
28170    function initLazyPlayers() {
28171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
28172        if (details.dataset.lazyInit) return;
28173        details.dataset.lazyInit = "true";
28174        details.addEventListener("toggle", () => {
28175          if (details.open && !details.querySelector(".player-container")) {
28176            createPlayer(details);
28177          } else if (!details.open) {
28178            const provider = details.querySelector("youtube-media-provider");
28179            if (provider && typeof provider.pause === "function") {
28180              provider.pause();
28181            }
28182          }
28183        });
28184      });
28185    }
28186
28187    if (document.readyState === "loading") {
28188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
28189    } else {
28190      initLazyPlayers();
28191    }
28192  })();
28193</script>
28193<script>
28194  (() => {
28195    if (window.__courseCompletionsInitialized) return;
28196    window.__courseCompletionsInitialized = true;
28197
28198    const STORAGE_KEY = "course-completions";
28199
28200    function slugify(text) {
28201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
28202    }
28203
28204    function getCompletions() {
28205      try {
28206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
28207      } catch {
28208        return [];
28209      }
28210    }
28211
28212    function saveCompletions(completions) {
28213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
28214    }
28215
28216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
28217    function canonicalFromStored(storedId) {
28218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28219      if (!m) return slugify(storedId);
28220      const [, slug, p, mv, title] = m;
28221      return `${slug}-${p}-${mv}-${slugify(title)}`;
28222    }
28223
28224    function canonicalPlusOne(storedId) {
28225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28226      if (!m) return null;
28227      const [, slug, p, mv, title] = m;
28228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
28229    }
28230
28231    function isCompleted(canonicalId, completions) {
28232      if (completions.includes(canonicalId)) return true;
28233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
28234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
28235    }
28236
28237    function migrateCompletions() {
28238      const completions = getCompletions();
28239      let changed = false;
28240      const migrated = completions.map((c) => {
28241        const canon = canonicalFromStored(c);
28242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
28243        const plusOne = canonicalPlusOne(c);
28244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
28245          if (plusOne !== c) changed = true;
28246          return plusOne;
28247        }
28248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
28249          changed = true;
28250          return canon;
28251        }
28252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
28253        if (canon !== c) {
28254          changed = true;
28255          return canon;
28256        }
28257        return c;
28258      });
28259      // Elimina duplicados tras migración
28260      const deduped = [...new Set(migrated)];
28261      if (changed || deduped.length !== completions.length) {
28262        saveCompletions(deduped);
28263        return deduped;
28264      }
28265      return completions;
28266    }
28267
28268    function updateButtonState(btn, completions) {
28269      const id = btn.dataset.completionId;
28270      const list = completions ?? getCompletions();
28271      btn.classList.toggle("completed", isCompleted(id, list));
28272    }
28273
28274    function checkModuleCompletion(completions) {
28275      const list = completions ?? getCompletions();
28276      document.querySelectorAll(".course-video").forEach((module) => {
28277        const btns = module.querySelectorAll(".complete-btn");
28278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
28279        const check = module.querySelector(".module-check");
28280        if (check) check.classList.toggle("visible", allCompleted);
28281      });
28282    }
28283
28284    function initCompletionButtons() {
28285      const completions = migrateCompletions();
28286
28287      document.querySelectorAll(".complete-btn").forEach((btn) => {
28288        if (btn.dataset.completionInit) return;
28289        btn.dataset.completionInit = "true";
28290        updateButtonState(btn, completions);
28291
28292        btn.addEventListener("click", (e) => {
28293          e.preventDefault();
28294          e.stopPropagation();
28295
28296          const id = btn.dataset.completionId;
28297          let list = getCompletions();
28298          const completed = isCompleted(id, list);
28299
28300          if (completed) {
28301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
28302            list = list.filter((c) =>
28302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
28303          } else {
28304            list.push(id);
28305          }
28306
28307          saveCompletions(list);
28308          updateButtonState(btn, list);
28309          checkModuleCompletion(list);
28310        });
28311      });
28312
28313      checkModuleCompletion(completions);
28314    }
28315
28316    if (document.readyState === "loading") {
28317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
28318    } else {
28319      initCompletionButtons();
28320    }
28321  })();
28322</script>
28322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="14890" data-end="16766" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>16. Lógica CSS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>31 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-logica-css" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
28322<script>
28323  (() => {
28324    if (window.__courseLazyPlayersInitialized) return;
28325    window.__courseLazyPlayersInitialized = true;
28326
28327    function createPlayer(details) {
28328      const videoId = details.dataset.videoId;
28329      const start = details.dataset.start;
28330      const end = details.dataset.end;
28331
28332      const container = document.createElement("div");
28333      container.className = "player-container";
28334
28335      const spinner = document.createElement("div");
28336      spinner.className = "spinner";
28337      container.appendChild(spinner);
28338
28339      const controller = document.createElement("media-controller");
28340      controller.setAttribute("defaultsubtitles", "");
28341
28342      const provider = document.createElement("youtube-media-provider");
28343      provider.setAttribute("slot", "media");
28344      provider.setAttribute("video-id", videoId);
28345      provider.setAttribute("start", start);
28346      provider.setAttribute("end", end);
28347
28348      const controlBar = document.createElement("media-control-bar");
28349
28350      const playBtn = document.createElement("media-play-button");
28351      const timeDisplay = document.createElement("media-time-display");
28352      timeDisplay.setAttribute("showduration", "");
28353      const timeRange = document.createElement("media-time-range");
28354      const playbackRate = document.createElement("media-playback-rate-button");
28355      const muteBtn = document.createElement("media-mute-button");
28356      const volumeRange = document.createElement("media-volume-range");
28357
28358      const captionsBtn = document.createElement("media-captions-button");
28359      captionsBtn.addEventListener("click", (e) => {
28360        e.stopPropagation();
28361        const provider = container.querySelector("youtube-media-provider");
28362        if (provider) provider.toggleCaptions();
28363      });
28364
28365      const qualityBtn = document.createElement("media-chrome-button");
28366      qualityBtn.textContent = "--";
28367      qualityBtn.addEventListener("click", () => {
28368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
28369      });
28370
28371      const fullscreenBtn = document.createElement("media-fullscreen-button");
28372
28373      controlBar.append(
28374        playBtn, timeDisplay, timeRange,
28375        playbackRate, muteBtn, volumeRange,
28376        captionsBtn, qualityBtn, fullscreenBtn
28377      );
28378
28379      const poster = document.createElement("media-poster-image");
28380      poster.setAttribute("slot", "poster");
28381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
28382
28383      controller.append(provider, poster, controlBar);
28384
28385      captionsBtn.setAttribute("aria-checked", "true");
28386      controller.setAttribute("mediasubtitlesshowing", "on");
28387
28388      container.appendChild(controller);
28389      details.appendChild(container);
28390
28391      provider.addEventListener("waiting", () => container.classList.add("loading"));
28392      provider.addEventListener("playing", () => container.classList.remove("loading"));
28393      provider.addEventListener("play", () => container.classList.remove("loading"));
28394
28395      provider._textTracks.addEventListener("change", () => {
28396        const enabled = provider._captionsEnabled;
28397        captionsBtn.setAttribute("aria-checked", enabled);
28398        if (enabled) {
28399          controller.setAttribute("mediasubtitlesshowing", "on");
28400        } else {
28401          controller.removeAttribute("mediasubtitlesshowing");
28402        }
28403      });
28404
28405      provider.addEventListener("qualitychange", (e) => {
28406        qualityBtn.textContent = e.detail.quality;
28407      });
28408    }
28409
28410    function initLazyPlayers() {
28411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
28412        if (details.dataset.lazyInit) return;
28413        details.dataset.lazyInit = "true";
28414        details.addEventListener("toggle", () => {
28415          if (details.open && !details.querySelector(".player-container")) {
28416            createPlayer(details);
28417          } else if (!details.open) {
28418            const provider = details.querySelector("youtube-media-provider");
28419            if (provider && typeof provider.pause === "function") {
28420              provider.pause();
28421            }
28422          }
28423        });
28424      });
28425    }
28426
28427    if (document.readyState === "loading") {
28428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
28429    } else {
28430      initLazyPlayers();
28431    }
28432  })();
28433</script>
28433<script>
28434  (() => {
28435    if (window.__courseCompletionsInitialized) return;
28436    window.__courseCompletionsInitialized = true;
28437
28438    const STORAGE_KEY = "course-completions";
28439
28440    function slugify(text) {
28441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
28442    }
28443
28444    function getCompletions() {
28445      try {
28446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
28447      } catch {
28448        return [];
28449      }
28450    }
28451
28452    function saveCompletions(completions) {
28453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
28454    }
28455
28456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
28457    function canonicalFromStored(storedId) {
28458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28459      if (!m) return slugify(storedId);
28460      const [, slug, p, mv, title] = m;
28461      return `${slug}-${p}-${mv}-${slugify(title)}`;
28462    }
28463
28464    function canonicalPlusOne(storedId) {
28465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28466      if (!m) return null;
28467      const [, slug, p, mv, title] = m;
28468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
28469    }
28470
28471    function isCompleted(canonicalId, completions) {
28472      if (completions.includes(canonicalId)) return true;
28473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
28474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
28475    }
28476
28477    function migrateCompletions() {
28478      const completions = getCompletions();
28479      let changed = false;
28480      const migrated = completions.map((c) => {
28481        const canon = canonicalFromStored(c);
28482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
28483        const plusOne = canonicalPlusOne(c);
28484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
28485          if (plusOne !== c) changed = true;
28486          return plusOne;
28487        }
28488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
28489          changed = true;
28490          return canon;
28491        }
28492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
28493        if (canon !== c) {
28494          changed = true;
28495          return canon;
28496        }
28497        return c;
28498      });
28499      // Elimina duplicados tras migración
28500      const deduped = [...new Set(migrated)];
28501      if (changed || deduped.length !== completions.length) {
28502        saveCompletions(deduped);
28503        return deduped;
28504      }
28505      return completions;
28506    }
28507
28508    function updateButtonState(btn, completions) {
28509      const id = btn.dataset.completionId;
28510      const list = completions ?? getCompletions();
28511      btn.classList.toggle("completed", isCompleted(id, list));
28512    }
28513
28514    function checkModuleCompletion(completions) {
28515      const list = completions ?? getCompletions();
28516      document.querySelectorAll(".course-video").forEach((module) => {
28517        const btns = module.querySelectorAll(".complete-btn");
28518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
28519        const check = module.querySelector(".module-check");
28520        if (check) check.classList.toggle("visible", allCompleted);
28521      });
28522    }
28523
28524    function initCompletionButtons() {
28525      const completions = migrateCompletions();
28526
28527      document.querySelectorAll(".complete-btn").forEach((btn) => {
28528        if (btn.dataset.completionInit) return;
28529        btn.dataset.completionInit = "true";
28530        updateButtonState(btn, completions);
28531
28532        btn.addEventListener("click", (e) => {
28533          e.preventDefault();
28534          e.stopPropagation();
28535
28536          const id = btn.dataset.completionId;
28537          let list = getCompletions();
28538          const completed = isCompleted(id, list);
28539
28540          if (completed) {
28541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
28542            list = list.filter((c) =>
28542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
28543          } else {
28544            list.push(id);
28545          }
28546
28547          saveCompletions(list);
28548          updateButtonState(btn, list);
28549          checkModuleCompletion(list);
28550        });
28551      });
28552
28553      checkModuleCompletion(completions);
28554    }
28555
28556    if (document.readyState === "loading") {
28557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
28558    } else {
28559      initCompletionButtons();
28560    }
28561  })();
28562</script>
28562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="w63OqoBzNxk" data-start="16766" data-end="17999" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>17. Preguntas finales</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-2-4-preguntas-finales" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
28562<script>
28563  (() => {
28564    if (window.__courseLazyPlayersInitialized) return;
28565    window.__courseLazyPlayersInitialized = true;
28566
28567    function createPlayer(details) {
28568      const videoId = details.dataset.videoId;
28569      const start = details.dataset.start;
28570      const end = details.dataset.end;
28571
28572      const container = document.createElement("div");
28573      container.className = "player-container";
28574
28575      const spinner = document.createElement("div");
28576      spinner.className = "spinner";
28577      container.appendChild(spinner);
28578
28579      const controller = document.createElement("media-controller");
28580      controller.setAttribute("defaultsubtitles", "");
28581
28582      const provider = document.createElement("youtube-media-provider");
28583      provider.setAttribute("slot", "media");
28584      provider.setAttribute("video-id", videoId);
28585      provider.setAttribute("start", start);
28586      provider.setAttribute("end", end);
28587
28588      const controlBar = document.createElement("media-control-bar");
28589
28590      const playBtn = document.createElement("media-play-button");
28591      const timeDisplay = document.createElement("media-time-display");
28592      timeDisplay.setAttribute("showduration", "");
28593      const timeRange = document.createElement("media-time-range");
28594      const playbackRate = document.createElement("media-playback-rate-button");
28595      const muteBtn = document.createElement("media-mute-button");
28596      const volumeRange = document.createElement("media-volume-range");
28597
28598      const captionsBtn = document.createElement("media-captions-button");
28599      captionsBtn.addEventListener("click", (e) => {
28600        e.stopPropagation();
28601        const provider = container.querySelector("youtube-media-provider");
28602        if (provider) provider.toggleCaptions();
28603      });
28604
28605      const qualityBtn = document.createElement("media-chrome-button");
28606      qualityBtn.textContent = "--";
28607      qualityBtn.addEventListener("click", () => {
28608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
28609      });
28610
28611      const fullscreenBtn = document.createElement("media-fullscreen-button");
28612
28613      controlBar.append(
28614        playBtn, timeDisplay, timeRange,
28615        playbackRate, muteBtn, volumeRange,
28616        captionsBtn, qualityBtn, fullscreenBtn
28617      );
28618
28619      const poster = document.createElement("media-poster-image");
28620      poster.setAttribute("slot", "poster");
28621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
28622
28623      controller.append(provider, poster, controlBar);
28624
28625      captionsBtn.setAttribute("aria-checked", "true");
28626      controller.setAttribute("mediasubtitlesshowing", "on");
28627
28628      container.appendChild(controller);
28629      details.appendChild(container);
28630
28631      provider.addEventListener("waiting", () => container.classList.add("loading"));
28632      provider.addEventListener("playing", () => container.classList.remove("loading"));
28633      provider.addEventListener("play", () => container.classList.remove("loading"));
28634
28635      provider._textTracks.addEventListener("change", () => {
28636        const enabled = provider._captionsEnabled;
28637        captionsBtn.setAttribute("aria-checked", enabled);
28638        if (enabled) {
28639          controller.setAttribute("mediasubtitlesshowing", "on");
28640        } else {
28641          controller.removeAttribute("mediasubtitlesshowing");
28642        }
28643      });
28644
28645      provider.addEventListener("qualitychange", (e) => {
28646        qualityBtn.textContent = e.detail.quality;
28647      });
28648    }
28649
28650    function initLazyPlayers() {
28651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
28652        if (details.dataset.lazyInit) return;
28653        details.dataset.lazyInit = "true";
28654        details.addEventListener("toggle", () => {
28655          if (details.open && !details.querySelector(".player-container")) {
28656            createPlayer(details);
28657          } else if (!details.open) {
28658            const provider = details.querySelector("youtube-media-provider");
28659            if (provider && typeof provider.pause === "function") {
28660              provider.pause();
28661            }
28662          }
28663        });
28664      });
28665    }
28666
28667    if (document.readyState === "loading") {
28668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
28669    } else {
28670      initLazyPlayers();
28671    }
28672  })();
28673</script>
28673<script>
28674  (() => {
28675    if (window.__courseCompletionsInitialized) return;
28676    window.__courseCompletionsInitialized = true;
28677
28678    const STORAGE_KEY = "course-completions";
28679
28680    function slugify(text) {
28681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
28682    }
28683
28684    function getCompletions() {
28685      try {
28686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
28687      } catch {
28688        return [];
28689      }
28690    }
28691
28692    function saveCompletions(completions) {
28693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
28694    }
28695
28696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
28697    function canonicalFromStored(storedId) {
28698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28699      if (!m) return slugify(storedId);
28700      const [, slug, p, mv, title] = m;
28701      return `${slug}-${p}-${mv}-${slugify(title)}`;
28702    }
28703
28704    function canonicalPlusOne(storedId) {
28705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28706      if (!m) return null;
28707      const [, slug, p, mv, title] = m;
28708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
28709    }
28710
28711    function isCompleted(canonicalId, completions) {
28712      if (completions.includes(canonicalId)) return true;
28713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
28714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
28715    }
28716
28717    function migrateCompletions() {
28718      const completions = getCompletions();
28719      let changed = false;
28720      const migrated = completions.map((c) => {
28721        const canon = canonicalFromStored(c);
28722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
28723        const plusOne = canonicalPlusOne(c);
28724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
28725          if (plusOne !== c) changed = true;
28726          return plusOne;
28727        }
28728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
28729          changed = true;
28730          return canon;
28731        }
28732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
28733        if (canon !== c) {
28734          changed = true;
28735          return canon;
28736        }
28737        return c;
28738      });
28739      // Elimina duplicados tras migración
28740      const deduped = [...new Set(migrated)];
28741      if (changed || deduped.length !== completions.length) {
28742        saveCompletions(deduped);
28743        return deduped;
28744      }
28745      return completions;
28746    }
28747
28748    function updateButtonState(btn, completions) {
28749      const id = btn.dataset.completionId;
28750      const list = completions ?? getCompletions();
28751      btn.classList.toggle("completed", isCompleted(id, list));
28752    }
28753
28754    function checkModuleCompletion(completions) {
28755      const list = completions ?? getCompletions();
28756      document.querySelectorAll(".course-video").forEach((module) => {
28757        const btns = module.querySelectorAll(".complete-btn");
28758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
28759        const check = module.querySelector(".module-check");
28760        if (check) check.classList.toggle("visible", allCompleted);
28761      });
28762    }
28763
28764    function initCompletionButtons() {
28765      const completions = migrateCompletions();
28766
28767      document.querySelectorAll(".complete-btn").forEach((btn) => {
28768        if (btn.dataset.completionInit) return;
28769        btn.dataset.completionInit = "true";
28770        updateButtonState(btn, completions);
28771
28772        btn.addEventListener("click", (e) => {
28773          e.preventDefault();
28774          e.stopPropagation();
28775
28776          const id = btn.dataset.completionId;
28777          let list = getCompletions();
28778          const completed = isCompleted(id, list);
28779
28780          if (completed) {
28781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
28782            list = list.filter((c) =>
28782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
28783          } else {
28784            list.push(id);
28785          }
28786
28787          saveCompletions(list);
28788          updateButtonState(btn, list);
28789          checkModuleCompletion(list);
28790        });
28791      });
28792
28793      checkModuleCompletion(completions);
28794    }
28795
28796    if (document.readyState === "loading") {
28797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
28798    } else {
28799      initCompletionButtons();
28800    }
28801  })();
28802</script>
28802</div></details></div></details><details class="phase" open data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>FASE 3: Javascript</summary><div class="phase-content" data-astro-cid-3yfbwi5j><div class="description" data-astro-cid-3yfbwi5j><p data-astro-cid-3yfbwi5j>Esta fase te va a ayudar a comprender como utilizar Javascript correctamente. Desde el uso del lenguaje, hasta comprender y estructurar el DOM, comunicarse mediante eventos y otros aspectos avanzados de Javascript del lado del cliente (navegador).</p></div><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 9 📒 Javascript<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="1560" data-end="3294" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Métodos básicos de String</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>29 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-metodos-basicos-de-string" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
28802<script>
28803  (() => {
28804    if (window.__courseLazyPlayersInitialized) return;
28805    window.__courseLazyPlayersInitialized = true;
28806
28807    function createPlayer(details) {
28808      const videoId = details.dataset.videoId;
28809      const start = details.dataset.start;
28810      const end = details.dataset.end;
28811
28812      const container = document.createElement("div");
28813      container.className = "player-container";
28814
28815      const spinner = document.createElement("div");
28816      spinner.className = "spinner";
28817      container.appendChild(spinner);
28818
28819      const controller = document.createElement("media-controller");
28820      controller.setAttribute("defaultsubtitles", "");
28821
28822      const provider = document.createElement("youtube-media-provider");
28823      provider.setAttribute("slot", "media");
28824      provider.setAttribute("video-id", videoId);
28825      provider.setAttribute("start", start);
28826      provider.setAttribute("end", end);
28827
28828      const controlBar = document.createElement("media-control-bar");
28829
28830      const playBtn = document.createElement("media-play-button");
28831      const timeDisplay = document.createElement("media-time-display");
28832      timeDisplay.setAttribute("showduration", "");
28833      const timeRange = document.createElement("media-time-range");
28834      const playbackRate = document.createElement("media-playback-rate-button");
28835      const muteBtn = document.createElement("media-mute-button");
28836      const volumeRange = document.createElement("media-volume-range");
28837
28838      const captionsBtn = document.createElement("media-captions-button");
28839      captionsBtn.addEventListener("click", (e) => {
28840        e.stopPropagation();
28841        const provider = container.querySelector("youtube-media-provider");
28842        if (provider) provider.toggleCaptions();
28843      });
28844
28845      const qualityBtn = document.createElement("media-chrome-button");
28846      qualityBtn.textContent = "--";
28847      qualityBtn.addEventListener("click", () => {
28848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
28849      });
28850
28851      const fullscreenBtn = document.createElement("media-fullscreen-button");
28852
28853      controlBar.append(
28854        playBtn, timeDisplay, timeRange,
28855        playbackRate, muteBtn, volumeRange,
28856        captionsBtn, qualityBtn, fullscreenBtn
28857      );
28858
28859      const poster = document.createElement("media-poster-image");
28860      poster.setAttribute("slot", "poster");
28861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
28862
28863      controller.append(provider, poster, controlBar);
28864
28865      captionsBtn.setAttribute("aria-checked", "true");
28866      controller.setAttribute("mediasubtitlesshowing", "on");
28867
28868      container.appendChild(controller);
28869      details.appendChild(container);
28870
28871      provider.addEventListener("waiting", () => container.classList.add("loading"));
28872      provider.addEventListener("playing", () => container.classList.remove("loading"));
28873      provider.addEventListener("play", () => container.classList.remove("loading"));
28874
28875      provider._textTracks.addEventListener("change", () => {
28876        const enabled = provider._captionsEnabled;
28877        captionsBtn.setAttribute("aria-checked", enabled);
28878        if (enabled) {
28879          controller.setAttribute("mediasubtitlesshowing", "on");
28880        } else {
28881          controller.removeAttribute("mediasubtitlesshowing");
28882        }
28883      });
28884
28885      provider.addEventListener("qualitychange", (e) => {
28886        qualityBtn.textContent = e.detail.quality;
28887      });
28888    }
28889
28890    function initLazyPlayers() {
28891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
28892        if (details.dataset.lazyInit) return;
28893        details.dataset.lazyInit = "true";
28894        details.addEventListener("toggle", () => {
28895          if (details.open && !details.querySelector(".player-container")) {
28896            createPlayer(details);
28897          } else if (!details.open) {
28898            const provider = details.querySelector("youtube-media-provider");
28899            if (provider && typeof provider.pause === "function") {
28900              provider.pause();
28901            }
28902          }
28903        });
28904      });
28905    }
28906
28907    if (document.readyState === "loading") {
28908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
28909    } else {
28910      initLazyPlayers();
28911    }
28912  })();
28913</script>
28913<script>
28914  (() => {
28915    if (window.__courseCompletionsInitialized) return;
28916    window.__courseCompletionsInitialized = true;
28917
28918    const STORAGE_KEY = "course-completions";
28919
28920    function slugify(text) {
28921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
28922    }
28923
28924    function getCompletions() {
28925      try {
28926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
28927      } catch {
28928        return [];
28929      }
28930    }
28931
28932    function saveCompletions(completions) {
28933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
28934    }
28935
28936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
28937    function canonicalFromStored(storedId) {
28938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28939      if (!m) return slugify(storedId);
28940      const [, slug, p, mv, title] = m;
28941      return `${slug}-${p}-${mv}-${slugify(title)}`;
28942    }
28943
28944    function canonicalPlusOne(storedId) {
28945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
28946      if (!m) return null;
28947      const [, slug, p, mv, title] = m;
28948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
28949    }
28950
28951    function isCompleted(canonicalId, completions) {
28952      if (completions.includes(canonicalId)) return true;
28953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
28954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
28955    }
28956
28957    function migrateCompletions() {
28958      const completions = getCompletions();
28959      let changed = false;
28960      const migrated = completions.map((c) => {
28961        const canon = canonicalFromStored(c);
28962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
28963        const plusOne = canonicalPlusOne(c);
28964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
28965          if (plusOne !== c) changed = true;
28966          return plusOne;
28967        }
28968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
28969          changed = true;
28970          return canon;
28971        }
28972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
28973        if (canon !== c) {
28974          changed = true;
28975          return canon;
28976        }
28977        return c;
28978      });
28979      // Elimina duplicados tras migración
28980      const deduped = [...new Set(migrated)];
28981      if (changed || deduped.length !== completions.length) {
28982        saveCompletions(deduped);
28983        return deduped;
28984      }
28985      return completions;
28986    }
28987
28988    function updateButtonState(btn, completions) {
28989      const id = btn.dataset.completionId;
28990      const list = completions ?? getCompletions();
28991      btn.classList.toggle("completed", isCompleted(id, list));
28992    }
28993
28994    function checkModuleCompletion(completions) {
28995      const list = completions ?? getCompletions();
28996      document.querySelectorAll(".course-video").forEach((module) => {
28997        const btns = module.querySelectorAll(".complete-btn");
28998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
28999        const check = module.querySelector(".module-check");
29000        if (check) check.classList.toggle("visible", allCompleted);
29001      });
29002    }
29003
29004    function initCompletionButtons() {
29005      const completions = migrateCompletions();
29006
29007      document.querySelectorAll(".complete-btn").forEach((btn) => {
29008        if (btn.dataset.completionInit) return;
29009        btn.dataset.completionInit = "true";
29010        updateButtonState(btn, completions);
29011
29012        btn.addEventListener("click", (e) => {
29013          e.preventDefault();
29014          e.stopPropagation();
29015
29016          const id = btn.dataset.completionId;
29017          let list = getCompletions();
29018          const completed = isCompleted(id, list);
29019
29020          if (completed) {
29021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
29022            list = list.filter((c) =>
29022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
29023          } else {
29024            list.push(id);
29025          }
29026
29027          saveCompletions(list);
29028          updateButtonState(btn, list);
29029          checkModuleCompletion(list);
29030        });
29031      });
29032
29033      checkModuleCompletion(completions);
29034    }
29035
29036    if (document.readyState === "loading") {
29037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
29038    } else {
29039      initCompletionButtons();
29040    }
29041  })();
29042</script>
29042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="3294" data-end="6000" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Manipulación de Strings</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>45 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-manipulacion-de-strings" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
29042<script>
29043  (() => {
29044    if (window.__courseLazyPlayersInitialized) return;
29045    window.__courseLazyPlayersInitialized = true;
29046
29047    function createPlayer(details) {
29048      const videoId = details.dataset.videoId;
29049      const start = details.dataset.start;
29050      const end = details.dataset.end;
29051
29052      const container = document.createElement("div");
29053      container.className = "player-container";
29054
29055      const spinner = document.createElement("div");
29056      spinner.className = "spinner";
29057      container.appendChild(spinner);
29058
29059      const controller = document.createElement("media-controller");
29060      controller.setAttribute("defaultsubtitles", "");
29061
29062      const provider = document.createElement("youtube-media-provider");
29063      provider.setAttribute("slot", "media");
29064      provider.setAttribute("video-id", videoId);
29065      provider.setAttribute("start", start);
29066      provider.setAttribute("end", end);
29067
29068      const controlBar = document.createElement("media-control-bar");
29069
29070      const playBtn = document.createElement("media-play-button");
29071      const timeDisplay = document.createElement("media-time-display");
29072      timeDisplay.setAttribute("showduration", "");
29073      const timeRange = document.createElement("media-time-range");
29074      const playbackRate = document.createElement("media-playback-rate-button");
29075      const muteBtn = document.createElement("media-mute-button");
29076      const volumeRange = document.createElement("media-volume-range");
29077
29078      const captionsBtn = document.createElement("media-captions-button");
29079      captionsBtn.addEventListener("click", (e) => {
29080        e.stopPropagation();
29081        const provider = container.querySelector("youtube-media-provider");
29082        if (provider) provider.toggleCaptions();
29083      });
29084
29085      const qualityBtn = document.createElement("media-chrome-button");
29086      qualityBtn.textContent = "--";
29087      qualityBtn.addEventListener("click", () => {
29088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
29089      });
29090
29091      const fullscreenBtn = document.createElement("media-fullscreen-button");
29092
29093      controlBar.append(
29094        playBtn, timeDisplay, timeRange,
29095        playbackRate, muteBtn, volumeRange,
29096        captionsBtn, qualityBtn, fullscreenBtn
29097      );
29098
29099      const poster = document.createElement("media-poster-image");
29100      poster.setAttribute("slot", "poster");
29101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
29102
29103      controller.append(provider, poster, controlBar);
29104
29105      captionsBtn.setAttribute("aria-checked", "true");
29106      controller.setAttribute("mediasubtitlesshowing", "on");
29107
29108      container.appendChild(controller);
29109      details.appendChild(container);
29110
29111      provider.addEventListener("waiting", () => container.classList.add("loading"));
29112      provider.addEventListener("playing", () => container.classList.remove("loading"));
29113      provider.addEventListener("play", () => container.classList.remove("loading"));
29114
29115      provider._textTracks.addEventListener("change", () => {
29116        const enabled = provider._captionsEnabled;
29117        captionsBtn.setAttribute("aria-checked", enabled);
29118        if (enabled) {
29119          controller.setAttribute("mediasubtitlesshowing", "on");
29120        } else {
29121          controller.removeAttribute("mediasubtitlesshowing");
29122        }
29123      });
29124
29125      provider.addEventListener("qualitychange", (e) => {
29126        qualityBtn.textContent = e.detail.quality;
29127      });
29128    }
29129
29130    function initLazyPlayers() {
29131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
29132        if (details.dataset.lazyInit) return;
29133        details.dataset.lazyInit = "true";
29134        details.addEventListener("toggle", () => {
29135          if (details.open && !details.querySelector(".player-container")) {
29136            createPlayer(details);
29137          } else if (!details.open) {
29138            const provider = details.querySelector("youtube-media-provider");
29139            if (provider && typeof provider.pause === "function") {
29140              provider.pause();
29141            }
29142          }
29143        });
29144      });
29145    }
29146
29147    if (document.readyState === "loading") {
29148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
29149    } else {
29150      initLazyPlayers();
29151    }
29152  })();
29153</script>
29153<script>
29154  (() => {
29155    if (window.__courseCompletionsInitialized) return;
29156    window.__courseCompletionsInitialized = true;
29157
29158    const STORAGE_KEY = "course-completions";
29159
29160    function slugify(text) {
29161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
29162    }
29163
29164    function getCompletions() {
29165      try {
29166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
29167      } catch {
29168        return [];
29169      }
29170    }
29171
29172    function saveCompletions(completions) {
29173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
29174    }
29175
29176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
29177    function canonicalFromStored(storedId) {
29178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29179      if (!m) return slugify(storedId);
29180      const [, slug, p, mv, title] = m;
29181      return `${slug}-${p}-${mv}-${slugify(title)}`;
29182    }
29183
29184    function canonicalPlusOne(storedId) {
29185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29186      if (!m) return null;
29187      const [, slug, p, mv, title] = m;
29188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
29189    }
29190
29191    function isCompleted(canonicalId, completions) {
29192      if (completions.includes(canonicalId)) return true;
29193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
29194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
29195    }
29196
29197    function migrateCompletions() {
29198      const completions = getCompletions();
29199      let changed = false;
29200      const migrated = completions.map((c) => {
29201        const canon = canonicalFromStored(c);
29202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
29203        const plusOne = canonicalPlusOne(c);
29204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
29205          if (plusOne !== c) changed = true;
29206          return plusOne;
29207        }
29208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
29209          changed = true;
29210          return canon;
29211        }
29212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
29213        if (canon !== c) {
29214          changed = true;
29215          return canon;
29216        }
29217        return c;
29218      });
29219      // Elimina duplicados tras migración
29220      const deduped = [...new Set(migrated)];
29221      if (changed || deduped.length !== completions.length) {
29222        saveCompletions(deduped);
29223        return deduped;
29224      }
29225      return completions;
29226    }
29227
29228    function updateButtonState(btn, completions) {
29229      const id = btn.dataset.completionId;
29230      const list = completions ?? getCompletions();
29231      btn.classList.toggle("completed", isCompleted(id, list));
29232    }
29233
29234    function checkModuleCompletion(completions) {
29235      const list = completions ?? getCompletions();
29236      document.querySelectorAll(".course-video").forEach((module) => {
29237        const btns = module.querySelectorAll(".complete-btn");
29238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
29239        const check = module.querySelector(".module-check");
29240        if (check) check.classList.toggle("visible", allCompleted);
29241      });
29242    }
29243
29244    function initCompletionButtons() {
29245      const completions = migrateCompletions();
29246
29247      document.querySelectorAll(".complete-btn").forEach((btn) => {
29248        if (btn.dataset.completionInit) return;
29249        btn.dataset.completionInit = "true";
29250        updateButtonState(btn, completions);
29251
29252        btn.addEventListener("click", (e) => {
29253          e.preventDefault();
29254          e.stopPropagation();
29255
29256          const id = btn.dataset.completionId;
29257          let list = getCompletions();
29258          const completed = isCompleted(id, list);
29259
29260          if (completed) {
29261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
29262            list = list.filter((c) =>
29262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
29263          } else {
29264            list.push(id);
29265          }
29266
29267          saveCompletions(list);
29268          updateButtonState(btn, list);
29269          checkModuleCompletion(list);
29270        });
29271      });
29272
29273      checkModuleCompletion(completions);
29274    }
29275
29276    if (document.readyState === "loading") {
29277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
29278    } else {
29279      initCompletionButtons();
29280    }
29281  })();
29282</script>
29282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="6000" data-end="6803" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Métodos de Math</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-metodos-de-math" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
29282<script>
29283  (() => {
29284    if (window.__courseLazyPlayersInitialized) return;
29285    window.__courseLazyPlayersInitialized = true;
29286
29287    function createPlayer(details) {
29288      const videoId = details.dataset.videoId;
29289      const start = details.dataset.start;
29290      const end = details.dataset.end;
29291
29292      const container = document.createElement("div");
29293      container.className = "player-container";
29294
29295      const spinner = document.createElement("div");
29296      spinner.className = "spinner";
29297      container.appendChild(spinner);
29298
29299      const controller = document.createElement("media-controller");
29300      controller.setAttribute("defaultsubtitles", "");
29301
29302      const provider = document.createElement("youtube-media-provider");
29303      provider.setAttribute("slot", "media");
29304      provider.setAttribute("video-id", videoId);
29305      provider.setAttribute("start", start);
29306      provider.setAttribute("end", end);
29307
29308      const controlBar = document.createElement("media-control-bar");
29309
29310      const playBtn = document.createElement("media-play-button");
29311      const timeDisplay = document.createElement("media-time-display");
29312      timeDisplay.setAttribute("showduration", "");
29313      const timeRange = document.createElement("media-time-range");
29314      const playbackRate = document.createElement("media-playback-rate-button");
29315      const muteBtn = document.createElement("media-mute-button");
29316      const volumeRange = document.createElement("media-volume-range");
29317
29318      const captionsBtn = document.createElement("media-captions-button");
29319      captionsBtn.addEventListener("click", (e) => {
29320        e.stopPropagation();
29321        const provider = container.querySelector("youtube-media-provider");
29322        if (provider) provider.toggleCaptions();
29323      });
29324
29325      const qualityBtn = document.createElement("media-chrome-button");
29326      qualityBtn.textContent = "--";
29327      qualityBtn.addEventListener("click", () => {
29328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
29329      });
29330
29331      const fullscreenBtn = document.createElement("media-fullscreen-button");
29332
29333      controlBar.append(
29334        playBtn, timeDisplay, timeRange,
29335        playbackRate, muteBtn, volumeRange,
29336        captionsBtn, qualityBtn, fullscreenBtn
29337      );
29338
29339      const poster = document.createElement("media-poster-image");
29340      poster.setAttribute("slot", "poster");
29341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
29342
29343      controller.append(provider, poster, controlBar);
29344
29345      captionsBtn.setAttribute("aria-checked", "true");
29346      controller.setAttribute("mediasubtitlesshowing", "on");
29347
29348      container.appendChild(controller);
29349      details.appendChild(container);
29350
29351      provider.addEventListener("waiting", () => container.classList.add("loading"));
29352      provider.addEventListener("playing", () => container.classList.remove("loading"));
29353      provider.addEventListener("play", () => container.classList.remove("loading"));
29354
29355      provider._textTracks.addEventListener("change", () => {
29356        const enabled = provider._captionsEnabled;
29357        captionsBtn.setAttribute("aria-checked", enabled);
29358        if (enabled) {
29359          controller.setAttribute("mediasubtitlesshowing", "on");
29360        } else {
29361          controller.removeAttribute("mediasubtitlesshowing");
29362        }
29363      });
29364
29365      provider.addEventListener("qualitychange", (e) => {
29366        qualityBtn.textContent = e.detail.quality;
29367      });
29368    }
29369
29370    function initLazyPlayers() {
29371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
29372        if (details.dataset.lazyInit) return;
29373        details.dataset.lazyInit = "true";
29374        details.addEventListener("toggle", () => {
29375          if (details.open && !details.querySelector(".player-container")) {
29376            createPlayer(details);
29377          } else if (!details.open) {
29378            const provider = details.querySelector("youtube-media-provider");
29379            if (provider && typeof provider.pause === "function") {
29380              provider.pause();
29381            }
29382          }
29383        });
29384      });
29385    }
29386
29387    if (document.readyState === "loading") {
29388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
29389    } else {
29390      initLazyPlayers();
29391    }
29392  })();
29393</script>
29393<script>
29394  (() => {
29395    if (window.__courseCompletionsInitialized) return;
29396    window.__courseCompletionsInitialized = true;
29397
29398    const STORAGE_KEY = "course-completions";
29399
29400    function slugify(text) {
29401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
29402    }
29403
29404    function getCompletions() {
29405      try {
29406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
29407      } catch {
29408        return [];
29409      }
29410    }
29411
29412    function saveCompletions(completions) {
29413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
29414    }
29415
29416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
29417    function canonicalFromStored(storedId) {
29418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29419      if (!m) return slugify(storedId);
29420      const [, slug, p, mv, title] = m;
29421      return `${slug}-${p}-${mv}-${slugify(title)}`;
29422    }
29423
29424    function canonicalPlusOne(storedId) {
29425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29426      if (!m) return null;
29427      const [, slug, p, mv, title] = m;
29428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
29429    }
29430
29431    function isCompleted(canonicalId, completions) {
29432      if (completions.includes(canonicalId)) return true;
29433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
29434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
29435    }
29436
29437    function migrateCompletions() {
29438      const completions = getCompletions();
29439      let changed = false;
29440      const migrated = completions.map((c) => {
29441        const canon = canonicalFromStored(c);
29442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
29443        const plusOne = canonicalPlusOne(c);
29444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
29445          if (plusOne !== c) changed = true;
29446          return plusOne;
29447        }
29448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
29449          changed = true;
29450          return canon;
29451        }
29452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
29453        if (canon !== c) {
29454          changed = true;
29455          return canon;
29456        }
29457        return c;
29458      });
29459      // Elimina duplicados tras migración
29460      const deduped = [...new Set(migrated)];
29461      if (changed || deduped.length !== completions.length) {
29462        saveCompletions(deduped);
29463        return deduped;
29464      }
29465      return completions;
29466    }
29467
29468    function updateButtonState(btn, completions) {
29469      const id = btn.dataset.completionId;
29470      const list = completions ?? getCompletions();
29471      btn.classList.toggle("completed", isCompleted(id, list));
29472    }
29473
29474    function checkModuleCompletion(completions) {
29475      const list = completions ?? getCompletions();
29476      document.querySelectorAll(".course-video").forEach((module) => {
29477        const btns = module.querySelectorAll(".complete-btn");
29478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
29479        const check = module.querySelector(".module-check");
29480        if (check) check.classList.toggle("visible", allCompleted);
29481      });
29482    }
29483
29484    function initCompletionButtons() {
29485      const completions = migrateCompletions();
29486
29487      document.querySelectorAll(".complete-btn").forEach((btn) => {
29488        if (btn.dataset.completionInit) return;
29489        btn.dataset.completionInit = "true";
29490        updateButtonState(btn, completions);
29491
29492        btn.addEventListener("click", (e) => {
29493          e.preventDefault();
29494          e.stopPropagation();
29495
29496          const id = btn.dataset.completionId;
29497          let list = getCompletions();
29498          const completed = isCompleted(id, list);
29499
29500          if (completed) {
29501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
29502            list = list.filter((c) =>
29502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
29503          } else {
29504            list.push(id);
29505          }
29506
29507          saveCompletions(list);
29508          updateButtonState(btn, list);
29509          checkModuleCompletion(list);
29510        });
29511      });
29512
29513      checkModuleCompletion(completions);
29514    }
29515
29516    if (document.readyState === "loading") {
29517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
29518    } else {
29519      initCompletionButtons();
29520    }
29521  })();
29522</script>
29522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="6803" data-end="7170" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Métodos de Array</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>6 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-metodos-de-array" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
29522<script>
29523  (() => {
29524    if (window.__courseLazyPlayersInitialized) return;
29525    window.__courseLazyPlayersInitialized = true;
29526
29527    function createPlayer(details) {
29528      const videoId = details.dataset.videoId;
29529      const start = details.dataset.start;
29530      const end = details.dataset.end;
29531
29532      const container = document.createElement("div");
29533      container.className = "player-container";
29534
29535      const spinner = document.createElement("div");
29536      spinner.className = "spinner";
29537      container.appendChild(spinner);
29538
29539      const controller = document.createElement("media-controller");
29540      controller.setAttribute("defaultsubtitles", "");
29541
29542      const provider = document.createElement("youtube-media-provider");
29543      provider.setAttribute("slot", "media");
29544      provider.setAttribute("video-id", videoId);
29545      provider.setAttribute("start", start);
29546      provider.setAttribute("end", end);
29547
29548      const controlBar = document.createElement("media-control-bar");
29549
29550      const playBtn = document.createElement("media-play-button");
29551      const timeDisplay = document.createElement("media-time-display");
29552      timeDisplay.setAttribute("showduration", "");
29553      const timeRange = document.createElement("media-time-range");
29554      const playbackRate = document.createElement("media-playback-rate-button");
29555      const muteBtn = document.createElement("media-mute-button");
29556      const volumeRange = document.createElement("media-volume-range");
29557
29558      const captionsBtn = document.createElement("media-captions-button");
29559      captionsBtn.addEventListener("click", (e) => {
29560        e.stopPropagation();
29561        const provider = container.querySelector("youtube-media-provider");
29562        if (provider) provider.toggleCaptions();
29563      });
29564
29565      const qualityBtn = document.createElement("media-chrome-button");
29566      qualityBtn.textContent = "--";
29567      qualityBtn.addEventListener("click", () => {
29568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
29569      });
29570
29571      const fullscreenBtn = document.createElement("media-fullscreen-button");
29572
29573      controlBar.append(
29574        playBtn, timeDisplay, timeRange,
29575        playbackRate, muteBtn, volumeRange,
29576        captionsBtn, qualityBtn, fullscreenBtn
29577      );
29578
29579      const poster = document.createElement("media-poster-image");
29580      poster.setAttribute("slot", "poster");
29581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
29582
29583      controller.append(provider, poster, controlBar);
29584
29585      captionsBtn.setAttribute("aria-checked", "true");
29586      controller.setAttribute("mediasubtitlesshowing", "on");
29587
29588      container.appendChild(controller);
29589      details.appendChild(container);
29590
29591      provider.addEventListener("waiting", () => container.classList.add("loading"));
29592      provider.addEventListener("playing", () => container.classList.remove("loading"));
29593      provider.addEventListener("play", () => container.classList.remove("loading"));
29594
29595      provider._textTracks.addEventListener("change", () => {
29596        const enabled = provider._captionsEnabled;
29597        captionsBtn.setAttribute("aria-checked", enabled);
29598        if (enabled) {
29599          controller.setAttribute("mediasubtitlesshowing", "on");
29600        } else {
29601          controller.removeAttribute("mediasubtitlesshowing");
29602        }
29603      });
29604
29605      provider.addEventListener("qualitychange", (e) => {
29606        qualityBtn.textContent = e.detail.quality;
29607      });
29608    }
29609
29610    function initLazyPlayers() {
29611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
29612        if (details.dataset.lazyInit) return;
29613        details.dataset.lazyInit = "true";
29614        details.addEventListener("toggle", () => {
29615          if (details.open && !details.querySelector(".player-container")) {
29616            createPlayer(details);
29617          } else if (!details.open) {
29618            const provider = details.querySelector("youtube-media-provider");
29619            if (provider && typeof provider.pause === "function") {
29620              provider.pause();
29621            }
29622          }
29623        });
29624      });
29625    }
29626
29627    if (document.readyState === "loading") {
29628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
29629    } else {
29630      initLazyPlayers();
29631    }
29632  })();
29633</script>
29633<script>
29634  (() => {
29635    if (window.__courseCompletionsInitialized) return;
29636    window.__courseCompletionsInitialized = true;
29637
29638    const STORAGE_KEY = "course-completions";
29639
29640    function slugify(text) {
29641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
29642    }
29643
29644    function getCompletions() {
29645      try {
29646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
29647      } catch {
29648        return [];
29649      }
29650    }
29651
29652    function saveCompletions(completions) {
29653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
29654    }
29655
29656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
29657    function canonicalFromStored(storedId) {
29658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29659      if (!m) return slugify(storedId);
29660      const [, slug, p, mv, title] = m;
29661      return `${slug}-${p}-${mv}-${slugify(title)}`;
29662    }
29663
29664    function canonicalPlusOne(storedId) {
29665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29666      if (!m) return null;
29667      const [, slug, p, mv, title] = m;
29668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
29669    }
29670
29671    function isCompleted(canonicalId, completions) {
29672      if (completions.includes(canonicalId)) return true;
29673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
29674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
29675    }
29676
29677    function migrateCompletions() {
29678      const completions = getCompletions();
29679      let changed = false;
29680      const migrated = completions.map((c) => {
29681        const canon = canonicalFromStored(c);
29682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
29683        const plusOne = canonicalPlusOne(c);
29684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
29685          if (plusOne !== c) changed = true;
29686          return plusOne;
29687        }
29688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
29689          changed = true;
29690          return canon;
29691        }
29692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
29693        if (canon !== c) {
29694          changed = true;
29695          return canon;
29696        }
29697        return c;
29698      });
29699      // Elimina duplicados tras migración
29700      const deduped = [...new Set(migrated)];
29701      if (changed || deduped.length !== completions.length) {
29702        saveCompletions(deduped);
29703        return deduped;
29704      }
29705      return completions;
29706    }
29707
29708    function updateButtonState(btn, completions) {
29709      const id = btn.dataset.completionId;
29710      const list = completions ?? getCompletions();
29711      btn.classList.toggle("completed", isCompleted(id, list));
29712    }
29713
29714    function checkModuleCompletion(completions) {
29715      const list = completions ?? getCompletions();
29716      document.querySelectorAll(".course-video").forEach((module) => {
29717        const btns = module.querySelectorAll(".complete-btn");
29718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
29719        const check = module.querySelector(".module-check");
29720        if (check) check.classList.toggle("visible", allCompleted);
29721      });
29722    }
29723
29724    function initCompletionButtons() {
29725      const completions = migrateCompletions();
29726
29727      document.querySelectorAll(".complete-btn").forEach((btn) => {
29728        if (btn.dataset.completionInit) return;
29729        btn.dataset.completionInit = "true";
29730        updateButtonState(btn, completions);
29731
29732        btn.addEventListener("click", (e) => {
29733          e.preventDefault();
29734          e.stopPropagation();
29735
29736          const id = btn.dataset.completionId;
29737          let list = getCompletions();
29738          const completed = isCompleted(id, list);
29739
29740          if (completed) {
29741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
29742            list = list.filter((c) =>
29742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
29743          } else {
29744            list.push(id);
29745          }
29746
29747          saveCompletions(list);
29748          updateButtonState(btn, list);
29749          checkModuleCompletion(list);
29750        });
29751      });
29752
29753      checkModuleCompletion(completions);
29754    }
29755
29756    if (document.readyState === "loading") {
29757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
29758    } else {
29759      initCompletionButtons();
29760    }
29761  })();
29762</script>
29762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="7170" data-end="7849" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Mutaciones</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-mutaciones" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
29762<script>
29763  (() => {
29764    if (window.__courseLazyPlayersInitialized) return;
29765    window.__courseLazyPlayersInitialized = true;
29766
29767    function createPlayer(details) {
29768      const videoId = details.dataset.videoId;
29769      const start = details.dataset.start;
29770      const end = details.dataset.end;
29771
29772      const container = document.createElement("div");
29773      container.className = "player-container";
29774
29775      const spinner = document.createElement("div");
29776      spinner.className = "spinner";
29777      container.appendChild(spinner);
29778
29779      const controller = document.createElement("media-controller");
29780      controller.setAttribute("defaultsubtitles", "");
29781
29782      const provider = document.createElement("youtube-media-provider");
29783      provider.setAttribute("slot", "media");
29784      provider.setAttribute("video-id", videoId);
29785      provider.setAttribute("start", start);
29786      provider.setAttribute("end", end);
29787
29788      const controlBar = document.createElement("media-control-bar");
29789
29790      const playBtn = document.createElement("media-play-button");
29791      const timeDisplay = document.createElement("media-time-display");
29792      timeDisplay.setAttribute("showduration", "");
29793      const timeRange = document.createElement("media-time-range");
29794      const playbackRate = document.createElement("media-playback-rate-button");
29795      const muteBtn = document.createElement("media-mute-button");
29796      const volumeRange = document.createElement("media-volume-range");
29797
29798      const captionsBtn = document.createElement("media-captions-button");
29799      captionsBtn.addEventListener("click", (e) => {
29800        e.stopPropagation();
29801        const provider = container.querySelector("youtube-media-provider");
29802        if (provider) provider.toggleCaptions();
29803      });
29804
29805      const qualityBtn = document.createElement("media-chrome-button");
29806      qualityBtn.textContent = "--";
29807      qualityBtn.addEventListener("click", () => {
29808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
29809      });
29810
29811      const fullscreenBtn = document.createElement("media-fullscreen-button");
29812
29813      controlBar.append(
29814        playBtn, timeDisplay, timeRange,
29815        playbackRate, muteBtn, volumeRange,
29816        captionsBtn, qualityBtn, fullscreenBtn
29817      );
29818
29819      const poster = document.createElement("media-poster-image");
29820      poster.setAttribute("slot", "poster");
29821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
29822
29823      controller.append(provider, poster, controlBar);
29824
29825      captionsBtn.setAttribute("aria-checked", "true");
29826      controller.setAttribute("mediasubtitlesshowing", "on");
29827
29828      container.appendChild(controller);
29829      details.appendChild(container);
29830
29831      provider.addEventListener("waiting", () => container.classList.add("loading"));
29832      provider.addEventListener("playing", () => container.classList.remove("loading"));
29833      provider.addEventListener("play", () => container.classList.remove("loading"));
29834
29835      provider._textTracks.addEventListener("change", () => {
29836        const enabled = provider._captionsEnabled;
29837        captionsBtn.setAttribute("aria-checked", enabled);
29838        if (enabled) {
29839          controller.setAttribute("mediasubtitlesshowing", "on");
29840        } else {
29841          controller.removeAttribute("mediasubtitlesshowing");
29842        }
29843      });
29844
29845      provider.addEventListener("qualitychange", (e) => {
29846        qualityBtn.textContent = e.detail.quality;
29847      });
29848    }
29849
29850    function initLazyPlayers() {
29851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
29852        if (details.dataset.lazyInit) return;
29853        details.dataset.lazyInit = "true";
29854        details.addEventListener("toggle", () => {
29855          if (details.open && !details.querySelector(".player-container")) {
29856            createPlayer(details);
29857          } else if (!details.open) {
29858            const provider = details.querySelector("youtube-media-provider");
29859            if (provider && typeof provider.pause === "function") {
29860              provider.pause();
29861            }
29862          }
29863        });
29864      });
29865    }
29866
29867    if (document.readyState === "loading") {
29868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
29869    } else {
29870      initLazyPlayers();
29871    }
29872  })();
29873</script>
29873<script>
29874  (() => {
29875    if (window.__courseCompletionsInitialized) return;
29876    window.__courseCompletionsInitialized = true;
29877
29878    const STORAGE_KEY = "course-completions";
29879
29880    function slugify(text) {
29881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
29882    }
29883
29884    function getCompletions() {
29885      try {
29886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
29887      } catch {
29888        return [];
29889      }
29890    }
29891
29892    function saveCompletions(completions) {
29893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
29894    }
29895
29896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
29897    function canonicalFromStored(storedId) {
29898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29899      if (!m) return slugify(storedId);
29900      const [, slug, p, mv, title] = m;
29901      return `${slug}-${p}-${mv}-${slugify(title)}`;
29902    }
29903
29904    function canonicalPlusOne(storedId) {
29905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
29906      if (!m) return null;
29907      const [, slug, p, mv, title] = m;
29908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
29909    }
29910
29911    function isCompleted(canonicalId, completions) {
29912      if (completions.includes(canonicalId)) return true;
29913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
29914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
29915    }
29916
29917    function migrateCompletions() {
29918      const completions = getCompletions();
29919      let changed = false;
29920      const migrated = completions.map((c) => {
29921        const canon = canonicalFromStored(c);
29922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
29923        const plusOne = canonicalPlusOne(c);
29924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
29925          if (plusOne !== c) changed = true;
29926          return plusOne;
29927        }
29928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
29929          changed = true;
29930          return canon;
29931        }
29932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
29933        if (canon !== c) {
29934          changed = true;
29935          return canon;
29936        }
29937        return c;
29938      });
29939      // Elimina duplicados tras migración
29940      const deduped = [...new Set(migrated)];
29941      if (changed || deduped.length !== completions.length) {
29942        saveCompletions(deduped);
29943        return deduped;
29944      }
29945      return completions;
29946    }
29947
29948    function updateButtonState(btn, completions) {
29949      const id = btn.dataset.completionId;
29950      const list = completions ?? getCompletions();
29951      btn.classList.toggle("completed", isCompleted(id, list));
29952    }
29953
29954    function checkModuleCompletion(completions) {
29955      const list = completions ?? getCompletions();
29956      document.querySelectorAll(".course-video").forEach((module) => {
29957        const btns = module.querySelectorAll(".complete-btn");
29958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
29959        const check = module.querySelector(".module-check");
29960        if (check) check.classList.toggle("visible", allCompleted);
29961      });
29962    }
29963
29964    function initCompletionButtons() {
29965      const completions = migrateCompletions();
29966
29967      document.querySelectorAll(".complete-btn").forEach((btn) => {
29968        if (btn.dataset.completionInit) return;
29969        btn.dataset.completionInit = "true";
29970        updateButtonState(btn, completions);
29971
29972        btn.addEventListener("click", (e) => {
29973          e.preventDefault();
29974          e.stopPropagation();
29975
29976          const id = btn.dataset.completionId;
29977          let list = getCompletions();
29978          const completed = isCompleted(id, list);
29979
29980          if (completed) {
29981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
29982            list = list.filter((c) =>
29982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
29983          } else {
29984            list.push(id);
29985          }
29986
29987          saveCompletions(list);
29988          updateButtonState(btn, list);
29989          checkModuleCompletion(list);
29990        });
29991      });
29992
29993      checkModuleCompletion(completions);
29994    }
29995
29996    if (document.readyState === "loading") {
29997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
29998    } else {
29999      initCompletionButtons();
30000    }
30001  })();
30002</script>
30002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="7849" data-end="8980" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Objetos + JSON</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-objetos-json" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
30002<script>
30003  (() => {
30004    if (window.__courseLazyPlayersInitialized) return;
30005    window.__courseLazyPlayersInitialized = true;
30006
30007    function createPlayer(details) {
30008      const videoId = details.dataset.videoId;
30009      const start = details.dataset.start;
30010      const end = details.dataset.end;
30011
30012      const container = document.createElement("div");
30013      container.className = "player-container";
30014
30015      const spinner = document.createElement("div");
30016      spinner.className = "spinner";
30017      container.appendChild(spinner);
30018
30019      const controller = document.createElement("media-controller");
30020      controller.setAttribute("defaultsubtitles", "");
30021
30022      const provider = document.createElement("youtube-media-provider");
30023      provider.setAttribute("slot", "media");
30024      provider.setAttribute("video-id", videoId);
30025      provider.setAttribute("start", start);
30026      provider.setAttribute("end", end);
30027
30028      const controlBar = document.createElement("media-control-bar");
30029
30030      const playBtn = document.createElement("media-play-button");
30031      const timeDisplay = document.createElement("media-time-display");
30032      timeDisplay.setAttribute("showduration", "");
30033      const timeRange = document.createElement("media-time-range");
30034      const playbackRate = document.createElement("media-playback-rate-button");
30035      const muteBtn = document.createElement("media-mute-button");
30036      const volumeRange = document.createElement("media-volume-range");
30037
30038      const captionsBtn = document.createElement("media-captions-button");
30039      captionsBtn.addEventListener("click", (e) => {
30040        e.stopPropagation();
30041        const provider = container.querySelector("youtube-media-provider");
30042        if (provider) provider.toggleCaptions();
30043      });
30044
30045      const qualityBtn = document.createElement("media-chrome-button");
30046      qualityBtn.textContent = "--";
30047      qualityBtn.addEventListener("click", () => {
30048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
30049      });
30050
30051      const fullscreenBtn = document.createElement("media-fullscreen-button");
30052
30053      controlBar.append(
30054        playBtn, timeDisplay, timeRange,
30055        playbackRate, muteBtn, volumeRange,
30056        captionsBtn, qualityBtn, fullscreenBtn
30057      );
30058
30059      const poster = document.createElement("media-poster-image");
30060      poster.setAttribute("slot", "poster");
30061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
30062
30063      controller.append(provider, poster, controlBar);
30064
30065      captionsBtn.setAttribute("aria-checked", "true");
30066      controller.setAttribute("mediasubtitlesshowing", "on");
30067
30068      container.appendChild(controller);
30069      details.appendChild(container);
30070
30071      provider.addEventListener("waiting", () => container.classList.add("loading"));
30072      provider.addEventListener("playing", () => container.classList.remove("loading"));
30073      provider.addEventListener("play", () => container.classList.remove("loading"));
30074
30075      provider._textTracks.addEventListener("change", () => {
30076        const enabled = provider._captionsEnabled;
30077        captionsBtn.setAttribute("aria-checked", enabled);
30078        if (enabled) {
30079          controller.setAttribute("mediasubtitlesshowing", "on");
30080        } else {
30081          controller.removeAttribute("mediasubtitlesshowing");
30082        }
30083      });
30084
30085      provider.addEventListener("qualitychange", (e) => {
30086        qualityBtn.textContent = e.detail.quality;
30087      });
30088    }
30089
30090    function initLazyPlayers() {
30091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
30092        if (details.dataset.lazyInit) return;
30093        details.dataset.lazyInit = "true";
30094        details.addEventListener("toggle", () => {
30095          if (details.open && !details.querySelector(".player-container")) {
30096            createPlayer(details);
30097          } else if (!details.open) {
30098            const provider = details.querySelector("youtube-media-provider");
30099            if (provider && typeof provider.pause === "function") {
30100              provider.pause();
30101            }
30102          }
30103        });
30104      });
30105    }
30106
30107    if (document.readyState === "loading") {
30108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
30109    } else {
30110      initLazyPlayers();
30111    }
30112  })();
30113</script>
30113<script>
30114  (() => {
30115    if (window.__courseCompletionsInitialized) return;
30116    window.__courseCompletionsInitialized = true;
30117
30118    const STORAGE_KEY = "course-completions";
30119
30120    function slugify(text) {
30121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
30122    }
30123
30124    function getCompletions() {
30125      try {
30126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
30127      } catch {
30128        return [];
30129      }
30130    }
30131
30132    function saveCompletions(completions) {
30133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
30134    }
30135
30136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
30137    function canonicalFromStored(storedId) {
30138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30139      if (!m) return slugify(storedId);
30140      const [, slug, p, mv, title] = m;
30141      return `${slug}-${p}-${mv}-${slugify(title)}`;
30142    }
30143
30144    function canonicalPlusOne(storedId) {
30145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30146      if (!m) return null;
30147      const [, slug, p, mv, title] = m;
30148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
30149    }
30150
30151    function isCompleted(canonicalId, completions) {
30152      if (completions.includes(canonicalId)) return true;
30153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
30154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
30155    }
30156
30157    function migrateCompletions() {
30158      const completions = getCompletions();
30159      let changed = false;
30160      const migrated = completions.map((c) => {
30161        const canon = canonicalFromStored(c);
30162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
30163        const plusOne = canonicalPlusOne(c);
30164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
30165          if (plusOne !== c) changed = true;
30166          return plusOne;
30167        }
30168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
30169          changed = true;
30170          return canon;
30171        }
30172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
30173        if (canon !== c) {
30174          changed = true;
30175          return canon;
30176        }
30177        return c;
30178      });
30179      // Elimina duplicados tras migración
30180      const deduped = [...new Set(migrated)];
30181      if (changed || deduped.length !== completions.length) {
30182        saveCompletions(deduped);
30183        return deduped;
30184      }
30185      return completions;
30186    }
30187
30188    function updateButtonState(btn, completions) {
30189      const id = btn.dataset.completionId;
30190      const list = completions ?? getCompletions();
30191      btn.classList.toggle("completed", isCompleted(id, list));
30192    }
30193
30194    function checkModuleCompletion(completions) {
30195      const list = completions ?? getCompletions();
30196      document.querySelectorAll(".course-video").forEach((module) => {
30197        const btns = module.querySelectorAll(".complete-btn");
30198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
30199        const check = module.querySelector(".module-check");
30200        if (check) check.classList.toggle("visible", allCompleted);
30201      });
30202    }
30203
30204    function initCompletionButtons() {
30205      const completions = migrateCompletions();
30206
30207      document.querySelectorAll(".complete-btn").forEach((btn) => {
30208        if (btn.dataset.completionInit) return;
30209        btn.dataset.completionInit = "true";
30210        updateButtonState(btn, completions);
30211
30212        btn.addEventListener("click", (e) => {
30213          e.preventDefault();
30214          e.stopPropagation();
30215
30216          const id = btn.dataset.completionId;
30217          let list = getCompletions();
30218          const completed = isCompleted(id, list);
30219
30220          if (completed) {
30221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
30222            list = list.filter((c) =>
30222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
30223          } else {
30224            list.push(id);
30225          }
30226
30227          saveCompletions(list);
30228          updateButtonState(btn, list);
30229          checkModuleCompletion(list);
30230        });
30231      });
30232
30233      checkModuleCompletion(completions);
30234    }
30235
30236    if (document.readyState === "loading") {
30237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
30238    } else {
30239      initCompletionButtons();
30240    }
30241  })();
30242</script>
30242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="8980" data-end="9480" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Acertijo</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-acertijo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
30242<script>
30243  (() => {
30244    if (window.__courseLazyPlayersInitialized) return;
30245    window.__courseLazyPlayersInitialized = true;
30246
30247    function createPlayer(details) {
30248      const videoId = details.dataset.videoId;
30249      const start = details.dataset.start;
30250      const end = details.dataset.end;
30251
30252      const container = document.createElement("div");
30253      container.className = "player-container";
30254
30255      const spinner = document.createElement("div");
30256      spinner.className = "spinner";
30257      container.appendChild(spinner);
30258
30259      const controller = document.createElement("media-controller");
30260      controller.setAttribute("defaultsubtitles", "");
30261
30262      const provider = document.createElement("youtube-media-provider");
30263      provider.setAttribute("slot", "media");
30264      provider.setAttribute("video-id", videoId);
30265      provider.setAttribute("start", start);
30266      provider.setAttribute("end", end);
30267
30268      const controlBar = document.createElement("media-control-bar");
30269
30270      const playBtn = document.createElement("media-play-button");
30271      const timeDisplay = document.createElement("media-time-display");
30272      timeDisplay.setAttribute("showduration", "");
30273      const timeRange = document.createElement("media-time-range");
30274      const playbackRate = document.createElement("media-playback-rate-button");
30275      const muteBtn = document.createElement("media-mute-button");
30276      const volumeRange = document.createElement("media-volume-range");
30277
30278      const captionsBtn = document.createElement("media-captions-button");
30279      captionsBtn.addEventListener("click", (e) => {
30280        e.stopPropagation();
30281        const provider = container.querySelector("youtube-media-provider");
30282        if (provider) provider.toggleCaptions();
30283      });
30284
30285      const qualityBtn = document.createElement("media-chrome-button");
30286      qualityBtn.textContent = "--";
30287      qualityBtn.addEventListener("click", () => {
30288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
30289      });
30290
30291      const fullscreenBtn = document.createElement("media-fullscreen-button");
30292
30293      controlBar.append(
30294        playBtn, timeDisplay, timeRange,
30295        playbackRate, muteBtn, volumeRange,
30296        captionsBtn, qualityBtn, fullscreenBtn
30297      );
30298
30299      const poster = document.createElement("media-poster-image");
30300      poster.setAttribute("slot", "poster");
30301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
30302
30303      controller.append(provider, poster, controlBar);
30304
30305      captionsBtn.setAttribute("aria-checked", "true");
30306      controller.setAttribute("mediasubtitlesshowing", "on");
30307
30308      container.appendChild(controller);
30309      details.appendChild(container);
30310
30311      provider.addEventListener("waiting", () => container.classList.add("loading"));
30312      provider.addEventListener("playing", () => container.classList.remove("loading"));
30313      provider.addEventListener("play", () => container.classList.remove("loading"));
30314
30315      provider._textTracks.addEventListener("change", () => {
30316        const enabled = provider._captionsEnabled;
30317        captionsBtn.setAttribute("aria-checked", enabled);
30318        if (enabled) {
30319          controller.setAttribute("mediasubtitlesshowing", "on");
30320        } else {
30321          controller.removeAttribute("mediasubtitlesshowing");
30322        }
30323      });
30324
30325      provider.addEventListener("qualitychange", (e) => {
30326        qualityBtn.textContent = e.detail.quality;
30327      });
30328    }
30329
30330    function initLazyPlayers() {
30331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
30332        if (details.dataset.lazyInit) return;
30333        details.dataset.lazyInit = "true";
30334        details.addEventListener("toggle", () => {
30335          if (details.open && !details.querySelector(".player-container")) {
30336            createPlayer(details);
30337          } else if (!details.open) {
30338            const provider = details.querySelector("youtube-media-provider");
30339            if (provider && typeof provider.pause === "function") {
30340              provider.pause();
30341            }
30342          }
30343        });
30344      });
30345    }
30346
30347    if (document.readyState === "loading") {
30348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
30349    } else {
30350      initLazyPlayers();
30351    }
30352  })();
30353</script>
30353<script>
30354  (() => {
30355    if (window.__courseCompletionsInitialized) return;
30356    window.__courseCompletionsInitialized = true;
30357
30358    const STORAGE_KEY = "course-completions";
30359
30360    function slugify(text) {
30361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
30362    }
30363
30364    function getCompletions() {
30365      try {
30366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
30367      } catch {
30368        return [];
30369      }
30370    }
30371
30372    function saveCompletions(completions) {
30373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
30374    }
30375
30376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
30377    function canonicalFromStored(storedId) {
30378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30379      if (!m) return slugify(storedId);
30380      const [, slug, p, mv, title] = m;
30381      return `${slug}-${p}-${mv}-${slugify(title)}`;
30382    }
30383
30384    function canonicalPlusOne(storedId) {
30385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30386      if (!m) return null;
30387      const [, slug, p, mv, title] = m;
30388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
30389    }
30390
30391    function isCompleted(canonicalId, completions) {
30392      if (completions.includes(canonicalId)) return true;
30393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
30394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
30395    }
30396
30397    function migrateCompletions() {
30398      const completions = getCompletions();
30399      let changed = false;
30400      const migrated = completions.map((c) => {
30401        const canon = canonicalFromStored(c);
30402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
30403        const plusOne = canonicalPlusOne(c);
30404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
30405          if (plusOne !== c) changed = true;
30406          return plusOne;
30407        }
30408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
30409          changed = true;
30410          return canon;
30411        }
30412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
30413        if (canon !== c) {
30414          changed = true;
30415          return canon;
30416        }
30417        return c;
30418      });
30419      // Elimina duplicados tras migración
30420      const deduped = [...new Set(migrated)];
30421      if (changed || deduped.length !== completions.length) {
30422        saveCompletions(deduped);
30423        return deduped;
30424      }
30425      return completions;
30426    }
30427
30428    function updateButtonState(btn, completions) {
30429      const id = btn.dataset.completionId;
30430      const list = completions ?? getCompletions();
30431      btn.classList.toggle("completed", isCompleted(id, list));
30432    }
30433
30434    function checkModuleCompletion(completions) {
30435      const list = completions ?? getCompletions();
30436      document.querySelectorAll(".course-video").forEach((module) => {
30437        const btns = module.querySelectorAll(".complete-btn");
30438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
30439        const check = module.querySelector(".module-check");
30440        if (check) check.classList.toggle("visible", allCompleted);
30441      });
30442    }
30443
30444    function initCompletionButtons() {
30445      const completions = migrateCompletions();
30446
30447      document.querySelectorAll(".complete-btn").forEach((btn) => {
30448        if (btn.dataset.completionInit) return;
30449        btn.dataset.completionInit = "true";
30450        updateButtonState(btn, completions);
30451
30452        btn.addEventListener("click", (e) => {
30453          e.preventDefault();
30454          e.stopPropagation();
30455
30456          const id = btn.dataset.completionId;
30457          let list = getCompletions();
30458          const completed = isCompleted(id, list);
30459
30460          if (completed) {
30461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
30462            list = list.filter((c) =>
30462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
30463          } else {
30464            list.push(id);
30465          }
30466
30467          saveCompletions(list);
30468          updateButtonState(btn, list);
30469          checkModuleCompletion(list);
30470        });
30471      });
30472
30473      checkModuleCompletion(completions);
30474    }
30475
30476    if (document.readyState === "loading") {
30477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
30478    } else {
30479      initCompletionButtons();
30480    }
30481  })();
30482</script>
30482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="9480" data-end="11470" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Desestructuración</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>33 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-desestructuracion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
30482<script>
30483  (() => {
30484    if (window.__courseLazyPlayersInitialized) return;
30485    window.__courseLazyPlayersInitialized = true;
30486
30487    function createPlayer(details) {
30488      const videoId = details.dataset.videoId;
30489      const start = details.dataset.start;
30490      const end = details.dataset.end;
30491
30492      const container = document.createElement("div");
30493      container.className = "player-container";
30494
30495      const spinner = document.createElement("div");
30496      spinner.className = "spinner";
30497      container.appendChild(spinner);
30498
30499      const controller = document.createElement("media-controller");
30500      controller.setAttribute("defaultsubtitles", "");
30501
30502      const provider = document.createElement("youtube-media-provider");
30503      provider.setAttribute("slot", "media");
30504      provider.setAttribute("video-id", videoId);
30505      provider.setAttribute("start", start);
30506      provider.setAttribute("end", end);
30507
30508      const controlBar = document.createElement("media-control-bar");
30509
30510      const playBtn = document.createElement("media-play-button");
30511      const timeDisplay = document.createElement("media-time-display");
30512      timeDisplay.setAttribute("showduration", "");
30513      const timeRange = document.createElement("media-time-range");
30514      const playbackRate = document.createElement("media-playback-rate-button");
30515      const muteBtn = document.createElement("media-mute-button");
30516      const volumeRange = document.createElement("media-volume-range");
30517
30518      const captionsBtn = document.createElement("media-captions-button");
30519      captionsBtn.addEventListener("click", (e) => {
30520        e.stopPropagation();
30521        const provider = container.querySelector("youtube-media-provider");
30522        if (provider) provider.toggleCaptions();
30523      });
30524
30525      const qualityBtn = document.createElement("media-chrome-button");
30526      qualityBtn.textContent = "--";
30527      qualityBtn.addEventListener("click", () => {
30528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
30529      });
30530
30531      const fullscreenBtn = document.createElement("media-fullscreen-button");
30532
30533      controlBar.append(
30534        playBtn, timeDisplay, timeRange,
30535        playbackRate, muteBtn, volumeRange,
30536        captionsBtn, qualityBtn, fullscreenBtn
30537      );
30538
30539      const poster = document.createElement("media-poster-image");
30540      poster.setAttribute("slot", "poster");
30541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
30542
30543      controller.append(provider, poster, controlBar);
30544
30545      captionsBtn.setAttribute("aria-checked", "true");
30546      controller.setAttribute("mediasubtitlesshowing", "on");
30547
30548      container.appendChild(controller);
30549      details.appendChild(container);
30550
30551      provider.addEventListener("waiting", () => container.classList.add("loading"));
30552      provider.addEventListener("playing", () => container.classList.remove("loading"));
30553      provider.addEventListener("play", () => container.classList.remove("loading"));
30554
30555      provider._textTracks.addEventListener("change", () => {
30556        const enabled = provider._captionsEnabled;
30557        captionsBtn.setAttribute("aria-checked", enabled);
30558        if (enabled) {
30559          controller.setAttribute("mediasubtitlesshowing", "on");
30560        } else {
30561          controller.removeAttribute("mediasubtitlesshowing");
30562        }
30563      });
30564
30565      provider.addEventListener("qualitychange", (e) => {
30566        qualityBtn.textContent = e.detail.quality;
30567      });
30568    }
30569
30570    function initLazyPlayers() {
30571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
30572        if (details.dataset.lazyInit) return;
30573        details.dataset.lazyInit = "true";
30574        details.addEventListener("toggle", () => {
30575          if (details.open && !details.querySelector(".player-container")) {
30576            createPlayer(details);
30577          } else if (!details.open) {
30578            const provider = details.querySelector("youtube-media-provider");
30579            if (provider && typeof provider.pause === "function") {
30580              provider.pause();
30581            }
30582          }
30583        });
30584      });
30585    }
30586
30587    if (document.readyState === "loading") {
30588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
30589    } else {
30590      initLazyPlayers();
30591    }
30592  })();
30593</script>
30593<script>
30594  (() => {
30595    if (window.__courseCompletionsInitialized) return;
30596    window.__courseCompletionsInitialized = true;
30597
30598    const STORAGE_KEY = "course-completions";
30599
30600    function slugify(text) {
30601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
30602    }
30603
30604    function getCompletions() {
30605      try {
30606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
30607      } catch {
30608        return [];
30609      }
30610    }
30611
30612    function saveCompletions(completions) {
30613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
30614    }
30615
30616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
30617    function canonicalFromStored(storedId) {
30618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30619      if (!m) return slugify(storedId);
30620      const [, slug, p, mv, title] = m;
30621      return `${slug}-${p}-${mv}-${slugify(title)}`;
30622    }
30623
30624    function canonicalPlusOne(storedId) {
30625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30626      if (!m) return null;
30627      const [, slug, p, mv, title] = m;
30628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
30629    }
30630
30631    function isCompleted(canonicalId, completions) {
30632      if (completions.includes(canonicalId)) return true;
30633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
30634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
30635    }
30636
30637    function migrateCompletions() {
30638      const completions = getCompletions();
30639      let changed = false;
30640      const migrated = completions.map((c) => {
30641        const canon = canonicalFromStored(c);
30642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
30643        const plusOne = canonicalPlusOne(c);
30644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
30645          if (plusOne !== c) changed = true;
30646          return plusOne;
30647        }
30648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
30649          changed = true;
30650          return canon;
30651        }
30652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
30653        if (canon !== c) {
30654          changed = true;
30655          return canon;
30656        }
30657        return c;
30658      });
30659      // Elimina duplicados tras migración
30660      const deduped = [...new Set(migrated)];
30661      if (changed || deduped.length !== completions.length) {
30662        saveCompletions(deduped);
30663        return deduped;
30664      }
30665      return completions;
30666    }
30667
30668    function updateButtonState(btn, completions) {
30669      const id = btn.dataset.completionId;
30670      const list = completions ?? getCompletions();
30671      btn.classList.toggle("completed", isCompleted(id, list));
30672    }
30673
30674    function checkModuleCompletion(completions) {
30675      const list = completions ?? getCompletions();
30676      document.querySelectorAll(".course-video").forEach((module) => {
30677        const btns = module.querySelectorAll(".complete-btn");
30678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
30679        const check = module.querySelector(".module-check");
30680        if (check) check.classList.toggle("visible", allCompleted);
30681      });
30682    }
30683
30684    function initCompletionButtons() {
30685      const completions = migrateCompletions();
30686
30687      document.querySelectorAll(".complete-btn").forEach((btn) => {
30688        if (btn.dataset.completionInit) return;
30689        btn.dataset.completionInit = "true";
30690        updateButtonState(btn, completions);
30691
30692        btn.addEventListener("click", (e) => {
30693          e.preventDefault();
30694          e.stopPropagation();
30695
30696          const id = btn.dataset.completionId;
30697          let list = getCompletions();
30698          const completed = isCompleted(id, list);
30699
30700          if (completed) {
30701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
30702            list = list.filter((c) =>
30702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
30703          } else {
30704            list.push(id);
30705          }
30706
30707          saveCompletions(list);
30708          updateButtonState(btn, list);
30709          checkModuleCompletion(list);
30710        });
30711      });
30712
30713      checkModuleCompletion(completions);
30714    }
30715
30716    if (document.readyState === "loading") {
30717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
30718    } else {
30719      initCompletionButtons();
30720    }
30721  })();
30722</script>
30722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="11470" data-end="13070" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Ordenación y clonación</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>27 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-ordenacion-y-clonacion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
30722<script>
30723  (() => {
30724    if (window.__courseLazyPlayersInitialized) return;
30725    window.__courseLazyPlayersInitialized = true;
30726
30727    function createPlayer(details) {
30728      const videoId = details.dataset.videoId;
30729      const start = details.dataset.start;
30730      const end = details.dataset.end;
30731
30732      const container = document.createElement("div");
30733      container.className = "player-container";
30734
30735      const spinner = document.createElement("div");
30736      spinner.className = "spinner";
30737      container.appendChild(spinner);
30738
30739      const controller = document.createElement("media-controller");
30740      controller.setAttribute("defaultsubtitles", "");
30741
30742      const provider = document.createElement("youtube-media-provider");
30743      provider.setAttribute("slot", "media");
30744      provider.setAttribute("video-id", videoId);
30745      provider.setAttribute("start", start);
30746      provider.setAttribute("end", end);
30747
30748      const controlBar = document.createElement("media-control-bar");
30749
30750      const playBtn = document.createElement("media-play-button");
30751      const timeDisplay = document.createElement("media-time-display");
30752      timeDisplay.setAttribute("showduration", "");
30753      const timeRange = document.createElement("media-time-range");
30754      const playbackRate = document.createElement("media-playback-rate-button");
30755      const muteBtn = document.createElement("media-mute-button");
30756      const volumeRange = document.createElement("media-volume-range");
30757
30758      const captionsBtn = document.createElement("media-captions-button");
30759      captionsBtn.addEventListener("click", (e) => {
30760        e.stopPropagation();
30761        const provider = container.querySelector("youtube-media-provider");
30762        if (provider) provider.toggleCaptions();
30763      });
30764
30765      const qualityBtn = document.createElement("media-chrome-button");
30766      qualityBtn.textContent = "--";
30767      qualityBtn.addEventListener("click", () => {
30768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
30769      });
30770
30771      const fullscreenBtn = document.createElement("media-fullscreen-button");
30772
30773      controlBar.append(
30774        playBtn, timeDisplay, timeRange,
30775        playbackRate, muteBtn, volumeRange,
30776        captionsBtn, qualityBtn, fullscreenBtn
30777      );
30778
30779      const poster = document.createElement("media-poster-image");
30780      poster.setAttribute("slot", "poster");
30781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
30782
30783      controller.append(provider, poster, controlBar);
30784
30785      captionsBtn.setAttribute("aria-checked", "true");
30786      controller.setAttribute("mediasubtitlesshowing", "on");
30787
30788      container.appendChild(controller);
30789      details.appendChild(container);
30790
30791      provider.addEventListener("waiting", () => container.classList.add("loading"));
30792      provider.addEventListener("playing", () => container.classList.remove("loading"));
30793      provider.addEventListener("play", () => container.classList.remove("loading"));
30794
30795      provider._textTracks.addEventListener("change", () => {
30796        const enabled = provider._captionsEnabled;
30797        captionsBtn.setAttribute("aria-checked", enabled);
30798        if (enabled) {
30799          controller.setAttribute("mediasubtitlesshowing", "on");
30800        } else {
30801          controller.removeAttribute("mediasubtitlesshowing");
30802        }
30803      });
30804
30805      provider.addEventListener("qualitychange", (e) => {
30806        qualityBtn.textContent = e.detail.quality;
30807      });
30808    }
30809
30810    function initLazyPlayers() {
30811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
30812        if (details.dataset.lazyInit) return;
30813        details.dataset.lazyInit = "true";
30814        details.addEventListener("toggle", () => {
30815          if (details.open && !details.querySelector(".player-container")) {
30816            createPlayer(details);
30817          } else if (!details.open) {
30818            const provider = details.querySelector("youtube-media-provider");
30819            if (provider && typeof provider.pause === "function") {
30820              provider.pause();
30821            }
30822          }
30823        });
30824      });
30825    }
30826
30827    if (document.readyState === "loading") {
30828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
30829    } else {
30830      initLazyPlayers();
30831    }
30832  })();
30833</script>
30833<script>
30834  (() => {
30835    if (window.__courseCompletionsInitialized) return;
30836    window.__courseCompletionsInitialized = true;
30837
30838    const STORAGE_KEY = "course-completions";
30839
30840    function slugify(text) {
30841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
30842    }
30843
30844    function getCompletions() {
30845      try {
30846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
30847      } catch {
30848        return [];
30849      }
30850    }
30851
30852    function saveCompletions(completions) {
30853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
30854    }
30855
30856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
30857    function canonicalFromStored(storedId) {
30858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30859      if (!m) return slugify(storedId);
30860      const [, slug, p, mv, title] = m;
30861      return `${slug}-${p}-${mv}-${slugify(title)}`;
30862    }
30863
30864    function canonicalPlusOne(storedId) {
30865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
30866      if (!m) return null;
30867      const [, slug, p, mv, title] = m;
30868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
30869    }
30870
30871    function isCompleted(canonicalId, completions) {
30872      if (completions.includes(canonicalId)) return true;
30873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
30874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
30875    }
30876
30877    function migrateCompletions() {
30878      const completions = getCompletions();
30879      let changed = false;
30880      const migrated = completions.map((c) => {
30881        const canon = canonicalFromStored(c);
30882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
30883        const plusOne = canonicalPlusOne(c);
30884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
30885          if (plusOne !== c) changed = true;
30886          return plusOne;
30887        }
30888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
30889          changed = true;
30890          return canon;
30891        }
30892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
30893        if (canon !== c) {
30894          changed = true;
30895          return canon;
30896        }
30897        return c;
30898      });
30899      // Elimina duplicados tras migración
30900      const deduped = [...new Set(migrated)];
30901      if (changed || deduped.length !== completions.length) {
30902        saveCompletions(deduped);
30903        return deduped;
30904      }
30905      return completions;
30906    }
30907
30908    function updateButtonState(btn, completions) {
30909      const id = btn.dataset.completionId;
30910      const list = completions ?? getCompletions();
30911      btn.classList.toggle("completed", isCompleted(id, list));
30912    }
30913
30914    function checkModuleCompletion(completions) {
30915      const list = completions ?? getCompletions();
30916      document.querySelectorAll(".course-video").forEach((module) => {
30917        const btns = module.querySelectorAll(".complete-btn");
30918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
30919        const check = module.querySelector(".module-check");
30920        if (check) check.classList.toggle("visible", allCompleted);
30921      });
30922    }
30923
30924    function initCompletionButtons() {
30925      const completions = migrateCompletions();
30926
30927      document.querySelectorAll(".complete-btn").forEach((btn) => {
30928        if (btn.dataset.completionInit) return;
30929        btn.dataset.completionInit = "true";
30930        updateButtonState(btn, completions);
30931
30932        btn.addEventListener("click", (e) => {
30933          e.preventDefault();
30934          e.stopPropagation();
30935
30936          const id = btn.dataset.completionId;
30937          let list = getCompletions();
30938          const completed = isCompleted(id, list);
30939
30940          if (completed) {
30941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
30942            list = list.filter((c) =>
30942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
30943          } else {
30944            list.push(id);
30945          }
30946
30947          saveCompletions(list);
30948          updateButtonState(btn, list);
30949          checkModuleCompletion(list);
30950        });
30951      });
30952
30953      checkModuleCompletion(completions);
30954    }
30955
30956    if (document.readyState === "loading") {
30957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
30958    } else {
30959      initCompletionButtons();
30960    }
30961  })();
30962</script>
30962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="13070" data-end="13445" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Iteradores de objetos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>6 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-iteradores-de-objetos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
30962<script>
30963  (() => {
30964    if (window.__courseLazyPlayersInitialized) return;
30965    window.__courseLazyPlayersInitialized = true;
30966
30967    function createPlayer(details) {
30968      const videoId = details.dataset.videoId;
30969      const start = details.dataset.start;
30970      const end = details.dataset.end;
30971
30972      const container = document.createElement("div");
30973      container.className = "player-container";
30974
30975      const spinner = document.createElement("div");
30976      spinner.className = "spinner";
30977      container.appendChild(spinner);
30978
30979      const controller = document.createElement("media-controller");
30980      controller.setAttribute("defaultsubtitles", "");
30981
30982      const provider = document.createElement("youtube-media-provider");
30983      provider.setAttribute("slot", "media");
30984      provider.setAttribute("video-id", videoId);
30985      provider.setAttribute("start", start);
30986      provider.setAttribute("end", end);
30987
30988      const controlBar = document.createElement("media-control-bar");
30989
30990      const playBtn = document.createElement("media-play-button");
30991      const timeDisplay = document.createElement("media-time-display");
30992      timeDisplay.setAttribute("showduration", "");
30993      const timeRange = document.createElement("media-time-range");
30994      const playbackRate = document.createElement("media-playback-rate-button");
30995      const muteBtn = document.createElement("media-mute-button");
30996      const volumeRange = document.createElement("media-volume-range");
30997
30998      const captionsBtn = document.createElement("media-captions-button");
30999      captionsBtn.addEventListener("click", (e) => {
31000        e.stopPropagation();
31001        const provider = container.querySelector("youtube-media-provider");
31002        if (provider) provider.toggleCaptions();
31003      });
31004
31005      const qualityBtn = document.createElement("media-chrome-button");
31006      qualityBtn.textContent = "--";
31007      qualityBtn.addEventListener("click", () => {
31008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
31009      });
31010
31011      const fullscreenBtn = document.createElement("media-fullscreen-button");
31012
31013      controlBar.append(
31014        playBtn, timeDisplay, timeRange,
31015        playbackRate, muteBtn, volumeRange,
31016        captionsBtn, qualityBtn, fullscreenBtn
31017      );
31018
31019      const poster = document.createElement("media-poster-image");
31020      poster.setAttribute("slot", "poster");
31021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
31022
31023      controller.append(provider, poster, controlBar);
31024
31025      captionsBtn.setAttribute("aria-checked", "true");
31026      controller.setAttribute("mediasubtitlesshowing", "on");
31027
31028      container.appendChild(controller);
31029      details.appendChild(container);
31030
31031      provider.addEventListener("waiting", () => container.classList.add("loading"));
31032      provider.addEventListener("playing", () => container.classList.remove("loading"));
31033      provider.addEventListener("play", () => container.classList.remove("loading"));
31034
31035      provider._textTracks.addEventListener("change", () => {
31036        const enabled = provider._captionsEnabled;
31037        captionsBtn.setAttribute("aria-checked", enabled);
31038        if (enabled) {
31039          controller.setAttribute("mediasubtitlesshowing", "on");
31040        } else {
31041          controller.removeAttribute("mediasubtitlesshowing");
31042        }
31043      });
31044
31045      provider.addEventListener("qualitychange", (e) => {
31046        qualityBtn.textContent = e.detail.quality;
31047      });
31048    }
31049
31050    function initLazyPlayers() {
31051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
31052        if (details.dataset.lazyInit) return;
31053        details.dataset.lazyInit = "true";
31054        details.addEventListener("toggle", () => {
31055          if (details.open && !details.querySelector(".player-container")) {
31056            createPlayer(details);
31057          } else if (!details.open) {
31058            const provider = details.querySelector("youtube-media-provider");
31059            if (provider && typeof provider.pause === "function") {
31060              provider.pause();
31061            }
31062          }
31063        });
31064      });
31065    }
31066
31067    if (document.readyState === "loading") {
31068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
31069    } else {
31070      initLazyPlayers();
31071    }
31072  })();
31073</script>
31073<script>
31074  (() => {
31075    if (window.__courseCompletionsInitialized) return;
31076    window.__courseCompletionsInitialized = true;
31077
31078    const STORAGE_KEY = "course-completions";
31079
31080    function slugify(text) {
31081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
31082    }
31083
31084    function getCompletions() {
31085      try {
31086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
31087      } catch {
31088        return [];
31089      }
31090    }
31091
31092    function saveCompletions(completions) {
31093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
31094    }
31095
31096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
31097    function canonicalFromStored(storedId) {
31098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31099      if (!m) return slugify(storedId);
31100      const [, slug, p, mv, title] = m;
31101      return `${slug}-${p}-${mv}-${slugify(title)}`;
31102    }
31103
31104    function canonicalPlusOne(storedId) {
31105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31106      if (!m) return null;
31107      const [, slug, p, mv, title] = m;
31108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
31109    }
31110
31111    function isCompleted(canonicalId, completions) {
31112      if (completions.includes(canonicalId)) return true;
31113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
31114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
31115    }
31116
31117    function migrateCompletions() {
31118      const completions = getCompletions();
31119      let changed = false;
31120      const migrated = completions.map((c) => {
31121        const canon = canonicalFromStored(c);
31122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
31123        const plusOne = canonicalPlusOne(c);
31124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
31125          if (plusOne !== c) changed = true;
31126          return plusOne;
31127        }
31128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
31129          changed = true;
31130          return canon;
31131        }
31132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
31133        if (canon !== c) {
31134          changed = true;
31135          return canon;
31136        }
31137        return c;
31138      });
31139      // Elimina duplicados tras migración
31140      const deduped = [...new Set(migrated)];
31141      if (changed || deduped.length !== completions.length) {
31142        saveCompletions(deduped);
31143        return deduped;
31144      }
31145      return completions;
31146    }
31147
31148    function updateButtonState(btn, completions) {
31149      const id = btn.dataset.completionId;
31150      const list = completions ?? getCompletions();
31151      btn.classList.toggle("completed", isCompleted(id, list));
31152    }
31153
31154    function checkModuleCompletion(completions) {
31155      const list = completions ?? getCompletions();
31156      document.querySelectorAll(".course-video").forEach((module) => {
31157        const btns = module.querySelectorAll(".complete-btn");
31158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
31159        const check = module.querySelector(".module-check");
31160        if (check) check.classList.toggle("visible", allCompleted);
31161      });
31162    }
31163
31164    function initCompletionButtons() {
31165      const completions = migrateCompletions();
31166
31167      document.querySelectorAll(".complete-btn").forEach((btn) => {
31168        if (btn.dataset.completionInit) return;
31169        btn.dataset.completionInit = "true";
31170        updateButtonState(btn, completions);
31171
31172        btn.addEventListener("click", (e) => {
31173          e.preventDefault();
31174          e.stopPropagation();
31175
31176          const id = btn.dataset.completionId;
31177          let list = getCompletions();
31178          const completed = isCompleted(id, list);
31179
31180          if (completed) {
31181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
31182            list = list.filter((c) =>
31182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
31183          } else {
31184            list.push(id);
31185          }
31186
31187          saveCompletions(list);
31188          updateButtonState(btn, list);
31189          checkModuleCompletion(list);
31190        });
31191      });
31192
31193      checkModuleCompletion(completions);
31194    }
31195
31196    if (document.readyState === "loading") {
31197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
31198    } else {
31199      initCompletionButtons();
31200    }
31201  })();
31202</script>
31202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="13445" data-end="14542" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Introducción a Array functions</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-introduccion-a-array-functions" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
31202<script>
31203  (() => {
31204    if (window.__courseLazyPlayersInitialized) return;
31205    window.__courseLazyPlayersInitialized = true;
31206
31207    function createPlayer(details) {
31208      const videoId = details.dataset.videoId;
31209      const start = details.dataset.start;
31210      const end = details.dataset.end;
31211
31212      const container = document.createElement("div");
31213      container.className = "player-container";
31214
31215      const spinner = document.createElement("div");
31216      spinner.className = "spinner";
31217      container.appendChild(spinner);
31218
31219      const controller = document.createElement("media-controller");
31220      controller.setAttribute("defaultsubtitles", "");
31221
31222      const provider = document.createElement("youtube-media-provider");
31223      provider.setAttribute("slot", "media");
31224      provider.setAttribute("video-id", videoId);
31225      provider.setAttribute("start", start);
31226      provider.setAttribute("end", end);
31227
31228      const controlBar = document.createElement("media-control-bar");
31229
31230      const playBtn = document.createElement("media-play-button");
31231      const timeDisplay = document.createElement("media-time-display");
31232      timeDisplay.setAttribute("showduration", "");
31233      const timeRange = document.createElement("media-time-range");
31234      const playbackRate = document.createElement("media-playback-rate-button");
31235      const muteBtn = document.createElement("media-mute-button");
31236      const volumeRange = document.createElement("media-volume-range");
31237
31238      const captionsBtn = document.createElement("media-captions-button");
31239      captionsBtn.addEventListener("click", (e) => {
31240        e.stopPropagation();
31241        const provider = container.querySelector("youtube-media-provider");
31242        if (provider) provider.toggleCaptions();
31243      });
31244
31245      const qualityBtn = document.createElement("media-chrome-button");
31246      qualityBtn.textContent = "--";
31247      qualityBtn.addEventListener("click", () => {
31248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
31249      });
31250
31251      const fullscreenBtn = document.createElement("media-fullscreen-button");
31252
31253      controlBar.append(
31254        playBtn, timeDisplay, timeRange,
31255        playbackRate, muteBtn, volumeRange,
31256        captionsBtn, qualityBtn, fullscreenBtn
31257      );
31258
31259      const poster = document.createElement("media-poster-image");
31260      poster.setAttribute("slot", "poster");
31261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
31262
31263      controller.append(provider, poster, controlBar);
31264
31265      captionsBtn.setAttribute("aria-checked", "true");
31266      controller.setAttribute("mediasubtitlesshowing", "on");
31267
31268      container.appendChild(controller);
31269      details.appendChild(container);
31270
31271      provider.addEventListener("waiting", () => container.classList.add("loading"));
31272      provider.addEventListener("playing", () => container.classList.remove("loading"));
31273      provider.addEventListener("play", () => container.classList.remove("loading"));
31274
31275      provider._textTracks.addEventListener("change", () => {
31276        const enabled = provider._captionsEnabled;
31277        captionsBtn.setAttribute("aria-checked", enabled);
31278        if (enabled) {
31279          controller.setAttribute("mediasubtitlesshowing", "on");
31280        } else {
31281          controller.removeAttribute("mediasubtitlesshowing");
31282        }
31283      });
31284
31285      provider.addEventListener("qualitychange", (e) => {
31286        qualityBtn.textContent = e.detail.quality;
31287      });
31288    }
31289
31290    function initLazyPlayers() {
31291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
31292        if (details.dataset.lazyInit) return;
31293        details.dataset.lazyInit = "true";
31294        details.addEventListener("toggle", () => {
31295          if (details.open && !details.querySelector(".player-container")) {
31296            createPlayer(details);
31297          } else if (!details.open) {
31298            const provider = details.querySelector("youtube-media-provider");
31299            if (provider && typeof provider.pause === "function") {
31300              provider.pause();
31301            }
31302          }
31303        });
31304      });
31305    }
31306
31307    if (document.readyState === "loading") {
31308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
31309    } else {
31310      initLazyPlayers();
31311    }
31312  })();
31313</script>
31313<script>
31314  (() => {
31315    if (window.__courseCompletionsInitialized) return;
31316    window.__courseCompletionsInitialized = true;
31317
31318    const STORAGE_KEY = "course-completions";
31319
31320    function slugify(text) {
31321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
31322    }
31323
31324    function getCompletions() {
31325      try {
31326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
31327      } catch {
31328        return [];
31329      }
31330    }
31331
31332    function saveCompletions(completions) {
31333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
31334    }
31335
31336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
31337    function canonicalFromStored(storedId) {
31338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31339      if (!m) return slugify(storedId);
31340      const [, slug, p, mv, title] = m;
31341      return `${slug}-${p}-${mv}-${slugify(title)}`;
31342    }
31343
31344    function canonicalPlusOne(storedId) {
31345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31346      if (!m) return null;
31347      const [, slug, p, mv, title] = m;
31348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
31349    }
31350
31351    function isCompleted(canonicalId, completions) {
31352      if (completions.includes(canonicalId)) return true;
31353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
31354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
31355    }
31356
31357    function migrateCompletions() {
31358      const completions = getCompletions();
31359      let changed = false;
31360      const migrated = completions.map((c) => {
31361        const canon = canonicalFromStored(c);
31362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
31363        const plusOne = canonicalPlusOne(c);
31364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
31365          if (plusOne !== c) changed = true;
31366          return plusOne;
31367        }
31368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
31369          changed = true;
31370          return canon;
31371        }
31372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
31373        if (canon !== c) {
31374          changed = true;
31375          return canon;
31376        }
31377        return c;
31378      });
31379      // Elimina duplicados tras migración
31380      const deduped = [...new Set(migrated)];
31381      if (changed || deduped.length !== completions.length) {
31382        saveCompletions(deduped);
31383        return deduped;
31384      }
31385      return completions;
31386    }
31387
31388    function updateButtonState(btn, completions) {
31389      const id = btn.dataset.completionId;
31390      const list = completions ?? getCompletions();
31391      btn.classList.toggle("completed", isCompleted(id, list));
31392    }
31393
31394    function checkModuleCompletion(completions) {
31395      const list = completions ?? getCompletions();
31396      document.querySelectorAll(".course-video").forEach((module) => {
31397        const btns = module.querySelectorAll(".complete-btn");
31398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
31399        const check = module.querySelector(".module-check");
31400        if (check) check.classList.toggle("visible", allCompleted);
31401      });
31402    }
31403
31404    function initCompletionButtons() {
31405      const completions = migrateCompletions();
31406
31407      document.querySelectorAll(".complete-btn").forEach((btn) => {
31408        if (btn.dataset.completionInit) return;
31409        btn.dataset.completionInit = "true";
31410        updateButtonState(btn, completions);
31411
31412        btn.addEventListener("click", (e) => {
31413          e.preventDefault();
31414          e.stopPropagation();
31415
31416          const id = btn.dataset.completionId;
31417          let list = getCompletions();
31418          const completed = isCompleted(id, list);
31419
31420          if (completed) {
31421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
31422            list = list.filter((c) =>
31422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
31423          } else {
31424            list.push(id);
31425          }
31426
31427          saveCompletions(list);
31428          updateButtonState(btn, list);
31429          checkModuleCompletion(list);
31430        });
31431      });
31432
31433      checkModuleCompletion(completions);
31434    }
31435
31436    if (document.readyState === "loading") {
31437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
31438    } else {
31439      initCompletionButtons();
31440    }
31441  })();
31442</script>
31442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="XNbz9k83dik" data-start="14542" data-end="16588" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Carácteres zero-width y preguntas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>34 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-1-caracteres-zero-width-y-preguntas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
31442<script>
31443  (() => {
31444    if (window.__courseLazyPlayersInitialized) return;
31445    window.__courseLazyPlayersInitialized = true;
31446
31447    function createPlayer(details) {
31448      const videoId = details.dataset.videoId;
31449      const start = details.dataset.start;
31450      const end = details.dataset.end;
31451
31452      const container = document.createElement("div");
31453      container.className = "player-container";
31454
31455      const spinner = document.createElement("div");
31456      spinner.className = "spinner";
31457      container.appendChild(spinner);
31458
31459      const controller = document.createElement("media-controller");
31460      controller.setAttribute("defaultsubtitles", "");
31461
31462      const provider = document.createElement("youtube-media-provider");
31463      provider.setAttribute("slot", "media");
31464      provider.setAttribute("video-id", videoId);
31465      provider.setAttribute("start", start);
31466      provider.setAttribute("end", end);
31467
31468      const controlBar = document.createElement("media-control-bar");
31469
31470      const playBtn = document.createElement("media-play-button");
31471      const timeDisplay = document.createElement("media-time-display");
31472      timeDisplay.setAttribute("showduration", "");
31473      const timeRange = document.createElement("media-time-range");
31474      const playbackRate = document.createElement("media-playback-rate-button");
31475      const muteBtn = document.createElement("media-mute-button");
31476      const volumeRange = document.createElement("media-volume-range");
31477
31478      const captionsBtn = document.createElement("media-captions-button");
31479      captionsBtn.addEventListener("click", (e) => {
31480        e.stopPropagation();
31481        const provider = container.querySelector("youtube-media-provider");
31482        if (provider) provider.toggleCaptions();
31483      });
31484
31485      const qualityBtn = document.createElement("media-chrome-button");
31486      qualityBtn.textContent = "--";
31487      qualityBtn.addEventListener("click", () => {
31488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
31489      });
31490
31491      const fullscreenBtn = document.createElement("media-fullscreen-button");
31492
31493      controlBar.append(
31494        playBtn, timeDisplay, timeRange,
31495        playbackRate, muteBtn, volumeRange,
31496        captionsBtn, qualityBtn, fullscreenBtn
31497      );
31498
31499      const poster = document.createElement("media-poster-image");
31500      poster.setAttribute("slot", "poster");
31501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
31502
31503      controller.append(provider, poster, controlBar);
31504
31505      captionsBtn.setAttribute("aria-checked", "true");
31506      controller.setAttribute("mediasubtitlesshowing", "on");
31507
31508      container.appendChild(controller);
31509      details.appendChild(container);
31510
31511      provider.addEventListener("waiting", () => container.classList.add("loading"));
31512      provider.addEventListener("playing", () => container.classList.remove("loading"));
31513      provider.addEventListener("play", () => container.classList.remove("loading"));
31514
31515      provider._textTracks.addEventListener("change", () => {
31516        const enabled = provider._captionsEnabled;
31517        captionsBtn.setAttribute("aria-checked", enabled);
31518        if (enabled) {
31519          controller.setAttribute("mediasubtitlesshowing", "on");
31520        } else {
31521          controller.removeAttribute("mediasubtitlesshowing");
31522        }
31523      });
31524
31525      provider.addEventListener("qualitychange", (e) => {
31526        qualityBtn.textContent = e.detail.quality;
31527      });
31528    }
31529
31530    function initLazyPlayers() {
31531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
31532        if (details.dataset.lazyInit) return;
31533        details.dataset.lazyInit = "true";
31534        details.addEventListener("toggle", () => {
31535          if (details.open && !details.querySelector(".player-container")) {
31536            createPlayer(details);
31537          } else if (!details.open) {
31538            const provider = details.querySelector("youtube-media-provider");
31539            if (provider && typeof provider.pause === "function") {
31540              provider.pause();
31541            }
31542          }
31543        });
31544      });
31545    }
31546
31547    if (document.readyState === "loading") {
31548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
31549    } else {
31550      initLazyPlayers();
31551    }
31552  })();
31553</script>
31553<script>
31554  (() => {
31555    if (window.__courseCompletionsInitialized) return;
31556    window.__courseCompletionsInitialized = true;
31557
31558    const STORAGE_KEY = "course-completions";
31559
31560    function slugify(text) {
31561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
31562    }
31563
31564    function getCompletions() {
31565      try {
31566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
31567      } catch {
31568        return [];
31569      }
31570    }
31571
31572    function saveCompletions(completions) {
31573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
31574    }
31575
31576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
31577    function canonicalFromStored(storedId) {
31578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31579      if (!m) return slugify(storedId);
31580      const [, slug, p, mv, title] = m;
31581      return `${slug}-${p}-${mv}-${slugify(title)}`;
31582    }
31583
31584    function canonicalPlusOne(storedId) {
31585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31586      if (!m) return null;
31587      const [, slug, p, mv, title] = m;
31588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
31589    }
31590
31591    function isCompleted(canonicalId, completions) {
31592      if (completions.includes(canonicalId)) return true;
31593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
31594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
31595    }
31596
31597    function migrateCompletions() {
31598      const completions = getCompletions();
31599      let changed = false;
31600      const migrated = completions.map((c) => {
31601        const canon = canonicalFromStored(c);
31602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
31603        const plusOne = canonicalPlusOne(c);
31604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
31605          if (plusOne !== c) changed = true;
31606          return plusOne;
31607        }
31608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
31609          changed = true;
31610          return canon;
31611        }
31612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
31613        if (canon !== c) {
31614          changed = true;
31615          return canon;
31616        }
31617        return c;
31618      });
31619      // Elimina duplicados tras migración
31620      const deduped = [...new Set(migrated)];
31621      if (changed || deduped.length !== completions.length) {
31622        saveCompletions(deduped);
31623        return deduped;
31624      }
31625      return completions;
31626    }
31627
31628    function updateButtonState(btn, completions) {
31629      const id = btn.dataset.completionId;
31630      const list = completions ?? getCompletions();
31631      btn.classList.toggle("completed", isCompleted(id, list));
31632    }
31633
31634    function checkModuleCompletion(completions) {
31635      const list = completions ?? getCompletions();
31636      document.querySelectorAll(".course-video").forEach((module) => {
31637        const btns = module.querySelectorAll(".complete-btn");
31638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
31639        const check = module.querySelector(".module-check");
31640        if (check) check.classList.toggle("visible", allCompleted);
31641      });
31642    }
31643
31644    function initCompletionButtons() {
31645      const completions = migrateCompletions();
31646
31647      document.querySelectorAll(".complete-btn").forEach((btn) => {
31648        if (btn.dataset.completionInit) return;
31649        btn.dataset.completionInit = "true";
31650        updateButtonState(btn, completions);
31651
31652        btn.addEventListener("click", (e) => {
31653          e.preventDefault();
31654          e.stopPropagation();
31655
31656          const id = btn.dataset.completionId;
31657          let list = getCompletions();
31658          const completed = isCompleted(id, list);
31659
31660          if (completed) {
31661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
31662            list = list.filter((c) =>
31662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
31663          } else {
31664            list.push(id);
31665          }
31666
31667          saveCompletions(list);
31668          updateButtonState(btn, list);
31669          checkModuleCompletion(list);
31670        });
31671      });
31672
31673      checkModuleCompletion(completions);
31674    }
31675
31676    if (document.readyState === "loading") {
31677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
31678    } else {
31679      initCompletionButtons();
31680    }
31681  })();
31682</script>
31682</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 10 📒 DOM Moderno<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="770" data-end="1783" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Tour por el Bootcamp</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-tour-por-el-bootcamp" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
31682<script>
31683  (() => {
31684    if (window.__courseLazyPlayersInitialized) return;
31685    window.__courseLazyPlayersInitialized = true;
31686
31687    function createPlayer(details) {
31688      const videoId = details.dataset.videoId;
31689      const start = details.dataset.start;
31690      const end = details.dataset.end;
31691
31692      const container = document.createElement("div");
31693      container.className = "player-container";
31694
31695      const spinner = document.createElement("div");
31696      spinner.className = "spinner";
31697      container.appendChild(spinner);
31698
31699      const controller = document.createElement("media-controller");
31700      controller.setAttribute("defaultsubtitles", "");
31701
31702      const provider = document.createElement("youtube-media-provider");
31703      provider.setAttribute("slot", "media");
31704      provider.setAttribute("video-id", videoId);
31705      provider.setAttribute("start", start);
31706      provider.setAttribute("end", end);
31707
31708      const controlBar = document.createElement("media-control-bar");
31709
31710      const playBtn = document.createElement("media-play-button");
31711      const timeDisplay = document.createElement("media-time-display");
31712      timeDisplay.setAttribute("showduration", "");
31713      const timeRange = document.createElement("media-time-range");
31714      const playbackRate = document.createElement("media-playback-rate-button");
31715      const muteBtn = document.createElement("media-mute-button");
31716      const volumeRange = document.createElement("media-volume-range");
31717
31718      const captionsBtn = document.createElement("media-captions-button");
31719      captionsBtn.addEventListener("click", (e) => {
31720        e.stopPropagation();
31721        const provider = container.querySelector("youtube-media-provider");
31722        if (provider) provider.toggleCaptions();
31723      });
31724
31725      const qualityBtn = document.createElement("media-chrome-button");
31726      qualityBtn.textContent = "--";
31727      qualityBtn.addEventListener("click", () => {
31728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
31729      });
31730
31731      const fullscreenBtn = document.createElement("media-fullscreen-button");
31732
31733      controlBar.append(
31734        playBtn, timeDisplay, timeRange,
31735        playbackRate, muteBtn, volumeRange,
31736        captionsBtn, qualityBtn, fullscreenBtn
31737      );
31738
31739      const poster = document.createElement("media-poster-image");
31740      poster.setAttribute("slot", "poster");
31741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
31742
31743      controller.append(provider, poster, controlBar);
31744
31745      captionsBtn.setAttribute("aria-checked", "true");
31746      controller.setAttribute("mediasubtitlesshowing", "on");
31747
31748      container.appendChild(controller);
31749      details.appendChild(container);
31750
31751      provider.addEventListener("waiting", () => container.classList.add("loading"));
31752      provider.addEventListener("playing", () => container.classList.remove("loading"));
31753      provider.addEventListener("play", () => container.classList.remove("loading"));
31754
31755      provider._textTracks.addEventListener("change", () => {
31756        const enabled = provider._captionsEnabled;
31757        captionsBtn.setAttribute("aria-checked", enabled);
31758        if (enabled) {
31759          controller.setAttribute("mediasubtitlesshowing", "on");
31760        } else {
31761          controller.removeAttribute("mediasubtitlesshowing");
31762        }
31763      });
31764
31765      provider.addEventListener("qualitychange", (e) => {
31766        qualityBtn.textContent = e.detail.quality;
31767      });
31768    }
31769
31770    function initLazyPlayers() {
31771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
31772        if (details.dataset.lazyInit) return;
31773        details.dataset.lazyInit = "true";
31774        details.addEventListener("toggle", () => {
31775          if (details.open && !details.querySelector(".player-container")) {
31776            createPlayer(details);
31777          } else if (!details.open) {
31778            const provider = details.querySelector("youtube-media-provider");
31779            if (provider && typeof provider.pause === "function") {
31780              provider.pause();
31781            }
31782          }
31783        });
31784      });
31785    }
31786
31787    if (document.readyState === "loading") {
31788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
31789    } else {
31790      initLazyPlayers();
31791    }
31792  })();
31793</script>
31793<script>
31794  (() => {
31795    if (window.__courseCompletionsInitialized) return;
31796    window.__courseCompletionsInitialized = true;
31797
31798    const STORAGE_KEY = "course-completions";
31799
31800    function slugify(text) {
31801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
31802    }
31803
31804    function getCompletions() {
31805      try {
31806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
31807      } catch {
31808        return [];
31809      }
31810    }
31811
31812    function saveCompletions(completions) {
31813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
31814    }
31815
31816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
31817    function canonicalFromStored(storedId) {
31818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31819      if (!m) return slugify(storedId);
31820      const [, slug, p, mv, title] = m;
31821      return `${slug}-${p}-${mv}-${slugify(title)}`;
31822    }
31823
31824    function canonicalPlusOne(storedId) {
31825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
31826      if (!m) return null;
31827      const [, slug, p, mv, title] = m;
31828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
31829    }
31830
31831    function isCompleted(canonicalId, completions) {
31832      if (completions.includes(canonicalId)) return true;
31833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
31834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
31835    }
31836
31837    function migrateCompletions() {
31838      const completions = getCompletions();
31839      let changed = false;
31840      const migrated = completions.map((c) => {
31841        const canon = canonicalFromStored(c);
31842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
31843        const plusOne = canonicalPlusOne(c);
31844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
31845          if (plusOne !== c) changed = true;
31846          return plusOne;
31847        }
31848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
31849          changed = true;
31850          return canon;
31851        }
31852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
31853        if (canon !== c) {
31854          changed = true;
31855          return canon;
31856        }
31857        return c;
31858      });
31859      // Elimina duplicados tras migración
31860      const deduped = [...new Set(migrated)];
31861      if (changed || deduped.length !== completions.length) {
31862        saveCompletions(deduped);
31863        return deduped;
31864      }
31865      return completions;
31866    }
31867
31868    function updateButtonState(btn, completions) {
31869      const id = btn.dataset.completionId;
31870      const list = completions ?? getCompletions();
31871      btn.classList.toggle("completed", isCompleted(id, list));
31872    }
31873
31874    function checkModuleCompletion(completions) {
31875      const list = completions ?? getCompletions();
31876      document.querySelectorAll(".course-video").forEach((module) => {
31877        const btns = module.querySelectorAll(".complete-btn");
31878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
31879        const check = module.querySelector(".module-check");
31880        if (check) check.classList.toggle("visible", allCompleted);
31881      });
31882    }
31883
31884    function initCompletionButtons() {
31885      const completions = migrateCompletions();
31886
31887      document.querySelectorAll(".complete-btn").forEach((btn) => {
31888        if (btn.dataset.completionInit) return;
31889        btn.dataset.completionInit = "true";
31890        updateButtonState(btn, completions);
31891
31892        btn.addEventListener("click", (e) => {
31893          e.preventDefault();
31894          e.stopPropagation();
31895
31896          const id = btn.dataset.completionId;
31897          let list = getCompletions();
31898          const completed = isCompleted(id, list);
31899
31900          if (completed) {
31901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
31902            list = list.filter((c) =>
31902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
31903          } else {
31904            list.push(id);
31905          }
31906
31907          saveCompletions(list);
31908          updateButtonState(btn, list);
31909          checkModuleCompletion(list);
31910        });
31911      });
31912
31913      checkModuleCompletion(completions);
31914    }
31915
31916    if (document.readyState === "loading") {
31917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
31918    } else {
31919      initCompletionButtons();
31920    }
31921  })();
31922</script>
31922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="1783" data-end="2650" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. ¿Qué es el DOM?</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-que-es-el-dom" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
31922<script>
31923  (() => {
31924    if (window.__courseLazyPlayersInitialized) return;
31925    window.__courseLazyPlayersInitialized = true;
31926
31927    function createPlayer(details) {
31928      const videoId = details.dataset.videoId;
31929      const start = details.dataset.start;
31930      const end = details.dataset.end;
31931
31932      const container = document.createElement("div");
31933      container.className = "player-container";
31934
31935      const spinner = document.createElement("div");
31936      spinner.className = "spinner";
31937      container.appendChild(spinner);
31938
31939      const controller = document.createElement("media-controller");
31940      controller.setAttribute("defaultsubtitles", "");
31941
31942      const provider = document.createElement("youtube-media-provider");
31943      provider.setAttribute("slot", "media");
31944      provider.setAttribute("video-id", videoId);
31945      provider.setAttribute("start", start);
31946      provider.setAttribute("end", end);
31947
31948      const controlBar = document.createElement("media-control-bar");
31949
31950      const playBtn = document.createElement("media-play-button");
31951      const timeDisplay = document.createElement("media-time-display");
31952      timeDisplay.setAttribute("showduration", "");
31953      const timeRange = document.createElement("media-time-range");
31954      const playbackRate = document.createElement("media-playback-rate-button");
31955      const muteBtn = document.createElement("media-mute-button");
31956      const volumeRange = document.createElement("media-volume-range");
31957
31958      const captionsBtn = document.createElement("media-captions-button");
31959      captionsBtn.addEventListener("click", (e) => {
31960        e.stopPropagation();
31961        const provider = container.querySelector("youtube-media-provider");
31962        if (provider) provider.toggleCaptions();
31963      });
31964
31965      const qualityBtn = document.createElement("media-chrome-button");
31966      qualityBtn.textContent = "--";
31967      qualityBtn.addEventListener("click", () => {
31968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
31969      });
31970
31971      const fullscreenBtn = document.createElement("media-fullscreen-button");
31972
31973      controlBar.append(
31974        playBtn, timeDisplay, timeRange,
31975        playbackRate, muteBtn, volumeRange,
31976        captionsBtn, qualityBtn, fullscreenBtn
31977      );
31978
31979      const poster = document.createElement("media-poster-image");
31980      poster.setAttribute("slot", "poster");
31981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
31982
31983      controller.append(provider, poster, controlBar);
31984
31985      captionsBtn.setAttribute("aria-checked", "true");
31986      controller.setAttribute("mediasubtitlesshowing", "on");
31987
31988      container.appendChild(controller);
31989      details.appendChild(container);
31990
31991      provider.addEventListener("waiting", () => container.classList.add("loading"));
31992      provider.addEventListener("playing", () => container.classList.remove("loading"));
31993      provider.addEventListener("play", () => container.classList.remove("loading"));
31994
31995      provider._textTracks.addEventListener("change", () => {
31996        const enabled = provider._captionsEnabled;
31997        captionsBtn.setAttribute("aria-checked", enabled);
31998        if (enabled) {
31999          controller.setAttribute("mediasubtitlesshowing", "on");
32000        } else {
32001          controller.removeAttribute("mediasubtitlesshowing");
32002        }
32003      });
32004
32005      provider.addEventListener("qualitychange", (e) => {
32006        qualityBtn.textContent = e.detail.quality;
32007      });
32008    }
32009
32010    function initLazyPlayers() {
32011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
32012        if (details.dataset.lazyInit) return;
32013        details.dataset.lazyInit = "true";
32014        details.addEventListener("toggle", () => {
32015          if (details.open && !details.querySelector(".player-container")) {
32016            createPlayer(details);
32017          } else if (!details.open) {
32018            const provider = details.querySelector("youtube-media-provider");
32019            if (provider && typeof provider.pause === "function") {
32020              provider.pause();
32021            }
32022          }
32023        });
32024      });
32025    }
32026
32027    if (document.readyState === "loading") {
32028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
32029    } else {
32030      initLazyPlayers();
32031    }
32032  })();
32033</script>
32033<script>
32034  (() => {
32035    if (window.__courseCompletionsInitialized) return;
32036    window.__courseCompletionsInitialized = true;
32037
32038    const STORAGE_KEY = "course-completions";
32039
32040    function slugify(text) {
32041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
32042    }
32043
32044    function getCompletions() {
32045      try {
32046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
32047      } catch {
32048        return [];
32049      }
32050    }
32051
32052    function saveCompletions(completions) {
32053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
32054    }
32055
32056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
32057    function canonicalFromStored(storedId) {
32058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32059      if (!m) return slugify(storedId);
32060      const [, slug, p, mv, title] = m;
32061      return `${slug}-${p}-${mv}-${slugify(title)}`;
32062    }
32063
32064    function canonicalPlusOne(storedId) {
32065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32066      if (!m) return null;
32067      const [, slug, p, mv, title] = m;
32068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
32069    }
32070
32071    function isCompleted(canonicalId, completions) {
32072      if (completions.includes(canonicalId)) return true;
32073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
32074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
32075    }
32076
32077    function migrateCompletions() {
32078      const completions = getCompletions();
32079      let changed = false;
32080      const migrated = completions.map((c) => {
32081        const canon = canonicalFromStored(c);
32082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
32083        const plusOne = canonicalPlusOne(c);
32084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
32085          if (plusOne !== c) changed = true;
32086          return plusOne;
32087        }
32088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
32089          changed = true;
32090          return canon;
32091        }
32092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
32093        if (canon !== c) {
32094          changed = true;
32095          return canon;
32096        }
32097        return c;
32098      });
32099      // Elimina duplicados tras migración
32100      const deduped = [...new Set(migrated)];
32101      if (changed || deduped.length !== completions.length) {
32102        saveCompletions(deduped);
32103        return deduped;
32104      }
32105      return completions;
32106    }
32107
32108    function updateButtonState(btn, completions) {
32109      const id = btn.dataset.completionId;
32110      const list = completions ?? getCompletions();
32111      btn.classList.toggle("completed", isCompleted(id, list));
32112    }
32113
32114    function checkModuleCompletion(completions) {
32115      const list = completions ?? getCompletions();
32116      document.querySelectorAll(".course-video").forEach((module) => {
32117        const btns = module.querySelectorAll(".complete-btn");
32118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
32119        const check = module.querySelector(".module-check");
32120        if (check) check.classList.toggle("visible", allCompleted);
32121      });
32122    }
32123
32124    function initCompletionButtons() {
32125      const completions = migrateCompletions();
32126
32127      document.querySelectorAll(".complete-btn").forEach((btn) => {
32128        if (btn.dataset.completionInit) return;
32129        btn.dataset.completionInit = "true";
32130        updateButtonState(btn, completions);
32131
32132        btn.addEventListener("click", (e) => {
32133          e.preventDefault();
32134          e.stopPropagation();
32135
32136          const id = btn.dataset.completionId;
32137          let list = getCompletions();
32138          const completed = isCompleted(id, list);
32139
32140          if (completed) {
32141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
32142            list = list.filter((c) =>
32142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
32143          } else {
32144            list.push(id);
32145          }
32146
32147          saveCompletions(list);
32148          updateButtonState(btn, list);
32149          checkModuleCompletion(list);
32150        });
32151      });
32152
32153      checkModuleCompletion(completions);
32154    }
32155
32156    if (document.readyState === "loading") {
32157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
32158    } else {
32159      initCompletionButtons();
32160    }
32161  })();
32162</script>
32162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="2650" data-end="4175" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Buscar en el DOM</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>25 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-buscar-en-el-dom" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
32162<script>
32163  (() => {
32164    if (window.__courseLazyPlayersInitialized) return;
32165    window.__courseLazyPlayersInitialized = true;
32166
32167    function createPlayer(details) {
32168      const videoId = details.dataset.videoId;
32169      const start = details.dataset.start;
32170      const end = details.dataset.end;
32171
32172      const container = document.createElement("div");
32173      container.className = "player-container";
32174
32175      const spinner = document.createElement("div");
32176      spinner.className = "spinner";
32177      container.appendChild(spinner);
32178
32179      const controller = document.createElement("media-controller");
32180      controller.setAttribute("defaultsubtitles", "");
32181
32182      const provider = document.createElement("youtube-media-provider");
32183      provider.setAttribute("slot", "media");
32184      provider.setAttribute("video-id", videoId);
32185      provider.setAttribute("start", start);
32186      provider.setAttribute("end", end);
32187
32188      const controlBar = document.createElement("media-control-bar");
32189
32190      const playBtn = document.createElement("media-play-button");
32191      const timeDisplay = document.createElement("media-time-display");
32192      timeDisplay.setAttribute("showduration", "");
32193      const timeRange = document.createElement("media-time-range");
32194      const playbackRate = document.createElement("media-playback-rate-button");
32195      const muteBtn = document.createElement("media-mute-button");
32196      const volumeRange = document.createElement("media-volume-range");
32197
32198      const captionsBtn = document.createElement("media-captions-button");
32199      captionsBtn.addEventListener("click", (e) => {
32200        e.stopPropagation();
32201        const provider = container.querySelector("youtube-media-provider");
32202        if (provider) provider.toggleCaptions();
32203      });
32204
32205      const qualityBtn = document.createElement("media-chrome-button");
32206      qualityBtn.textContent = "--";
32207      qualityBtn.addEventListener("click", () => {
32208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
32209      });
32210
32211      const fullscreenBtn = document.createElement("media-fullscreen-button");
32212
32213      controlBar.append(
32214        playBtn, timeDisplay, timeRange,
32215        playbackRate, muteBtn, volumeRange,
32216        captionsBtn, qualityBtn, fullscreenBtn
32217      );
32218
32219      const poster = document.createElement("media-poster-image");
32220      poster.setAttribute("slot", "poster");
32221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
32222
32223      controller.append(provider, poster, controlBar);
32224
32225      captionsBtn.setAttribute("aria-checked", "true");
32226      controller.setAttribute("mediasubtitlesshowing", "on");
32227
32228      container.appendChild(controller);
32229      details.appendChild(container);
32230
32231      provider.addEventListener("waiting", () => container.classList.add("loading"));
32232      provider.addEventListener("playing", () => container.classList.remove("loading"));
32233      provider.addEventListener("play", () => container.classList.remove("loading"));
32234
32235      provider._textTracks.addEventListener("change", () => {
32236        const enabled = provider._captionsEnabled;
32237        captionsBtn.setAttribute("aria-checked", enabled);
32238        if (enabled) {
32239          controller.setAttribute("mediasubtitlesshowing", "on");
32240        } else {
32241          controller.removeAttribute("mediasubtitlesshowing");
32242        }
32243      });
32244
32245      provider.addEventListener("qualitychange", (e) => {
32246        qualityBtn.textContent = e.detail.quality;
32247      });
32248    }
32249
32250    function initLazyPlayers() {
32251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
32252        if (details.dataset.lazyInit) return;
32253        details.dataset.lazyInit = "true";
32254        details.addEventListener("toggle", () => {
32255          if (details.open && !details.querySelector(".player-container")) {
32256            createPlayer(details);
32257          } else if (!details.open) {
32258            const provider = details.querySelector("youtube-media-provider");
32259            if (provider && typeof provider.pause === "function") {
32260              provider.pause();
32261            }
32262          }
32263        });
32264      });
32265    }
32266
32267    if (document.readyState === "loading") {
32268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
32269    } else {
32270      initLazyPlayers();
32271    }
32272  })();
32273</script>
32273<script>
32274  (() => {
32275    if (window.__courseCompletionsInitialized) return;
32276    window.__courseCompletionsInitialized = true;
32277
32278    const STORAGE_KEY = "course-completions";
32279
32280    function slugify(text) {
32281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
32282    }
32283
32284    function getCompletions() {
32285      try {
32286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
32287      } catch {
32288        return [];
32289      }
32290    }
32291
32292    function saveCompletions(completions) {
32293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
32294    }
32295
32296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
32297    function canonicalFromStored(storedId) {
32298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32299      if (!m) return slugify(storedId);
32300      const [, slug, p, mv, title] = m;
32301      return `${slug}-${p}-${mv}-${slugify(title)}`;
32302    }
32303
32304    function canonicalPlusOne(storedId) {
32305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32306      if (!m) return null;
32307      const [, slug, p, mv, title] = m;
32308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
32309    }
32310
32311    function isCompleted(canonicalId, completions) {
32312      if (completions.includes(canonicalId)) return true;
32313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
32314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
32315    }
32316
32317    function migrateCompletions() {
32318      const completions = getCompletions();
32319      let changed = false;
32320      const migrated = completions.map((c) => {
32321        const canon = canonicalFromStored(c);
32322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
32323        const plusOne = canonicalPlusOne(c);
32324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
32325          if (plusOne !== c) changed = true;
32326          return plusOne;
32327        }
32328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
32329          changed = true;
32330          return canon;
32331        }
32332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
32333        if (canon !== c) {
32334          changed = true;
32335          return canon;
32336        }
32337        return c;
32338      });
32339      // Elimina duplicados tras migración
32340      const deduped = [...new Set(migrated)];
32341      if (changed || deduped.length !== completions.length) {
32342        saveCompletions(deduped);
32343        return deduped;
32344      }
32345      return completions;
32346    }
32347
32348    function updateButtonState(btn, completions) {
32349      const id = btn.dataset.completionId;
32350      const list = completions ?? getCompletions();
32351      btn.classList.toggle("completed", isCompleted(id, list));
32352    }
32353
32354    function checkModuleCompletion(completions) {
32355      const list = completions ?? getCompletions();
32356      document.querySelectorAll(".course-video").forEach((module) => {
32357        const btns = module.querySelectorAll(".complete-btn");
32358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
32359        const check = module.querySelector(".module-check");
32360        if (check) check.classList.toggle("visible", allCompleted);
32361      });
32362    }
32363
32364    function initCompletionButtons() {
32365      const completions = migrateCompletions();
32366
32367      document.querySelectorAll(".complete-btn").forEach((btn) => {
32368        if (btn.dataset.completionInit) return;
32369        btn.dataset.completionInit = "true";
32370        updateButtonState(btn, completions);
32371
32372        btn.addEventListener("click", (e) => {
32373          e.preventDefault();
32374          e.stopPropagation();
32375
32376          const id = btn.dataset.completionId;
32377          let list = getCompletions();
32378          const completed = isCompleted(id, list);
32379
32380          if (completed) {
32381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
32382            list = list.filter((c) =>
32382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
32383          } else {
32384            list.push(id);
32385          }
32386
32387          saveCompletions(list);
32388          updateButtonState(btn, list);
32389          checkModuleCompletion(list);
32390        });
32391      });
32392
32393      checkModuleCompletion(completions);
32394    }
32395
32396    if (document.readyState === "loading") {
32397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
32398    } else {
32399      initCompletionButtons();
32400    }
32401  })();
32402</script>
32402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="4175" data-end="5898" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Modificar contenido</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>29 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-modificar-contenido" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
32402<script>
32403  (() => {
32404    if (window.__courseLazyPlayersInitialized) return;
32405    window.__courseLazyPlayersInitialized = true;
32406
32407    function createPlayer(details) {
32408      const videoId = details.dataset.videoId;
32409      const start = details.dataset.start;
32410      const end = details.dataset.end;
32411
32412      const container = document.createElement("div");
32413      container.className = "player-container";
32414
32415      const spinner = document.createElement("div");
32416      spinner.className = "spinner";
32417      container.appendChild(spinner);
32418
32419      const controller = document.createElement("media-controller");
32420      controller.setAttribute("defaultsubtitles", "");
32421
32422      const provider = document.createElement("youtube-media-provider");
32423      provider.setAttribute("slot", "media");
32424      provider.setAttribute("video-id", videoId);
32425      provider.setAttribute("start", start);
32426      provider.setAttribute("end", end);
32427
32428      const controlBar = document.createElement("media-control-bar");
32429
32430      const playBtn = document.createElement("media-play-button");
32431      const timeDisplay = document.createElement("media-time-display");
32432      timeDisplay.setAttribute("showduration", "");
32433      const timeRange = document.createElement("media-time-range");
32434      const playbackRate = document.createElement("media-playback-rate-button");
32435      const muteBtn = document.createElement("media-mute-button");
32436      const volumeRange = document.createElement("media-volume-range");
32437
32438      const captionsBtn = document.createElement("media-captions-button");
32439      captionsBtn.addEventListener("click", (e) => {
32440        e.stopPropagation();
32441        const provider = container.querySelector("youtube-media-provider");
32442        if (provider) provider.toggleCaptions();
32443      });
32444
32445      const qualityBtn = document.createElement("media-chrome-button");
32446      qualityBtn.textContent = "--";
32447      qualityBtn.addEventListener("click", () => {
32448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
32449      });
32450
32451      const fullscreenBtn = document.createElement("media-fullscreen-button");
32452
32453      controlBar.append(
32454        playBtn, timeDisplay, timeRange,
32455        playbackRate, muteBtn, volumeRange,
32456        captionsBtn, qualityBtn, fullscreenBtn
32457      );
32458
32459      const poster = document.createElement("media-poster-image");
32460      poster.setAttribute("slot", "poster");
32461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
32462
32463      controller.append(provider, poster, controlBar);
32464
32465      captionsBtn.setAttribute("aria-checked", "true");
32466      controller.setAttribute("mediasubtitlesshowing", "on");
32467
32468      container.appendChild(controller);
32469      details.appendChild(container);
32470
32471      provider.addEventListener("waiting", () => container.classList.add("loading"));
32472      provider.addEventListener("playing", () => container.classList.remove("loading"));
32473      provider.addEventListener("play", () => container.classList.remove("loading"));
32474
32475      provider._textTracks.addEventListener("change", () => {
32476        const enabled = provider._captionsEnabled;
32477        captionsBtn.setAttribute("aria-checked", enabled);
32478        if (enabled) {
32479          controller.setAttribute("mediasubtitlesshowing", "on");
32480        } else {
32481          controller.removeAttribute("mediasubtitlesshowing");
32482        }
32483      });
32484
32485      provider.addEventListener("qualitychange", (e) => {
32486        qualityBtn.textContent = e.detail.quality;
32487      });
32488    }
32489
32490    function initLazyPlayers() {
32491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
32492        if (details.dataset.lazyInit) return;
32493        details.dataset.lazyInit = "true";
32494        details.addEventListener("toggle", () => {
32495          if (details.open && !details.querySelector(".player-container")) {
32496            createPlayer(details);
32497          } else if (!details.open) {
32498            const provider = details.querySelector("youtube-media-provider");
32499            if (provider && typeof provider.pause === "function") {
32500              provider.pause();
32501            }
32502          }
32503        });
32504      });
32505    }
32506
32507    if (document.readyState === "loading") {
32508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
32509    } else {
32510      initLazyPlayers();
32511    }
32512  })();
32513</script>
32513<script>
32514  (() => {
32515    if (window.__courseCompletionsInitialized) return;
32516    window.__courseCompletionsInitialized = true;
32517
32518    const STORAGE_KEY = "course-completions";
32519
32520    function slugify(text) {
32521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
32522    }
32523
32524    function getCompletions() {
32525      try {
32526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
32527      } catch {
32528        return [];
32529      }
32530    }
32531
32532    function saveCompletions(completions) {
32533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
32534    }
32535
32536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
32537    function canonicalFromStored(storedId) {
32538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32539      if (!m) return slugify(storedId);
32540      const [, slug, p, mv, title] = m;
32541      return `${slug}-${p}-${mv}-${slugify(title)}`;
32542    }
32543
32544    function canonicalPlusOne(storedId) {
32545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32546      if (!m) return null;
32547      const [, slug, p, mv, title] = m;
32548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
32549    }
32550
32551    function isCompleted(canonicalId, completions) {
32552      if (completions.includes(canonicalId)) return true;
32553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
32554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
32555    }
32556
32557    function migrateCompletions() {
32558      const completions = getCompletions();
32559      let changed = false;
32560      const migrated = completions.map((c) => {
32561        const canon = canonicalFromStored(c);
32562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
32563        const plusOne = canonicalPlusOne(c);
32564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
32565          if (plusOne !== c) changed = true;
32566          return plusOne;
32567        }
32568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
32569          changed = true;
32570          return canon;
32571        }
32572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
32573        if (canon !== c) {
32574          changed = true;
32575          return canon;
32576        }
32577        return c;
32578      });
32579      // Elimina duplicados tras migración
32580      const deduped = [...new Set(migrated)];
32581      if (changed || deduped.length !== completions.length) {
32582        saveCompletions(deduped);
32583        return deduped;
32584      }
32585      return completions;
32586    }
32587
32588    function updateButtonState(btn, completions) {
32589      const id = btn.dataset.completionId;
32590      const list = completions ?? getCompletions();
32591      btn.classList.toggle("completed", isCompleted(id, list));
32592    }
32593
32594    function checkModuleCompletion(completions) {
32595      const list = completions ?? getCompletions();
32596      document.querySelectorAll(".course-video").forEach((module) => {
32597        const btns = module.querySelectorAll(".complete-btn");
32598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
32599        const check = module.querySelector(".module-check");
32600        if (check) check.classList.toggle("visible", allCompleted);
32601      });
32602    }
32603
32604    function initCompletionButtons() {
32605      const completions = migrateCompletions();
32606
32607      document.querySelectorAll(".complete-btn").forEach((btn) => {
32608        if (btn.dataset.completionInit) return;
32609        btn.dataset.completionInit = "true";
32610        updateButtonState(btn, completions);
32611
32612        btn.addEventListener("click", (e) => {
32613          e.preventDefault();
32614          e.stopPropagation();
32615
32616          const id = btn.dataset.completionId;
32617          let list = getCompletions();
32618          const completed = isCompleted(id, list);
32619
32620          if (completed) {
32621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
32622            list = list.filter((c) =>
32622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
32623          } else {
32624            list.push(id);
32625          }
32626
32627          saveCompletions(list);
32628          updateButtonState(btn, list);
32629          checkModuleCompletion(list);
32630        });
32631      });
32632
32633      checkModuleCompletion(completions);
32634    }
32635
32636    if (document.readyState === "loading") {
32637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
32638    } else {
32639      initCompletionButtons();
32640    }
32641  })();
32642</script>
32642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="5898" data-end="7335" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Atributos HTML</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-atributos-html" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
32642<script>
32643  (() => {
32644    if (window.__courseLazyPlayersInitialized) return;
32645    window.__courseLazyPlayersInitialized = true;
32646
32647    function createPlayer(details) {
32648      const videoId = details.dataset.videoId;
32649      const start = details.dataset.start;
32650      const end = details.dataset.end;
32651
32652      const container = document.createElement("div");
32653      container.className = "player-container";
32654
32655      const spinner = document.createElement("div");
32656      spinner.className = "spinner";
32657      container.appendChild(spinner);
32658
32659      const controller = document.createElement("media-controller");
32660      controller.setAttribute("defaultsubtitles", "");
32661
32662      const provider = document.createElement("youtube-media-provider");
32663      provider.setAttribute("slot", "media");
32664      provider.setAttribute("video-id", videoId);
32665      provider.setAttribute("start", start);
32666      provider.setAttribute("end", end);
32667
32668      const controlBar = document.createElement("media-control-bar");
32669
32670      const playBtn = document.createElement("media-play-button");
32671      const timeDisplay = document.createElement("media-time-display");
32672      timeDisplay.setAttribute("showduration", "");
32673      const timeRange = document.createElement("media-time-range");
32674      const playbackRate = document.createElement("media-playback-rate-button");
32675      const muteBtn = document.createElement("media-mute-button");
32676      const volumeRange = document.createElement("media-volume-range");
32677
32678      const captionsBtn = document.createElement("media-captions-button");
32679      captionsBtn.addEventListener("click", (e) => {
32680        e.stopPropagation();
32681        const provider = container.querySelector("youtube-media-provider");
32682        if (provider) provider.toggleCaptions();
32683      });
32684
32685      const qualityBtn = document.createElement("media-chrome-button");
32686      qualityBtn.textContent = "--";
32687      qualityBtn.addEventListener("click", () => {
32688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
32689      });
32690
32691      const fullscreenBtn = document.createElement("media-fullscreen-button");
32692
32693      controlBar.append(
32694        playBtn, timeDisplay, timeRange,
32695        playbackRate, muteBtn, volumeRange,
32696        captionsBtn, qualityBtn, fullscreenBtn
32697      );
32698
32699      const poster = document.createElement("media-poster-image");
32700      poster.setAttribute("slot", "poster");
32701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
32702
32703      controller.append(provider, poster, controlBar);
32704
32705      captionsBtn.setAttribute("aria-checked", "true");
32706      controller.setAttribute("mediasubtitlesshowing", "on");
32707
32708      container.appendChild(controller);
32709      details.appendChild(container);
32710
32711      provider.addEventListener("waiting", () => container.classList.add("loading"));
32712      provider.addEventListener("playing", () => container.classList.remove("loading"));
32713      provider.addEventListener("play", () => container.classList.remove("loading"));
32714
32715      provider._textTracks.addEventListener("change", () => {
32716        const enabled = provider._captionsEnabled;
32717        captionsBtn.setAttribute("aria-checked", enabled);
32718        if (enabled) {
32719          controller.setAttribute("mediasubtitlesshowing", "on");
32720        } else {
32721          controller.removeAttribute("mediasubtitlesshowing");
32722        }
32723      });
32724
32725      provider.addEventListener("qualitychange", (e) => {
32726        qualityBtn.textContent = e.detail.quality;
32727      });
32728    }
32729
32730    function initLazyPlayers() {
32731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
32732        if (details.dataset.lazyInit) return;
32733        details.dataset.lazyInit = "true";
32734        details.addEventListener("toggle", () => {
32735          if (details.open && !details.querySelector(".player-container")) {
32736            createPlayer(details);
32737          } else if (!details.open) {
32738            const provider = details.querySelector("youtube-media-provider");
32739            if (provider && typeof provider.pause === "function") {
32740              provider.pause();
32741            }
32742          }
32743        });
32744      });
32745    }
32746
32747    if (document.readyState === "loading") {
32748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
32749    } else {
32750      initLazyPlayers();
32751    }
32752  })();
32753</script>
32753<script>
32754  (() => {
32755    if (window.__courseCompletionsInitialized) return;
32756    window.__courseCompletionsInitialized = true;
32757
32758    const STORAGE_KEY = "course-completions";
32759
32760    function slugify(text) {
32761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
32762    }
32763
32764    function getCompletions() {
32765      try {
32766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
32767      } catch {
32768        return [];
32769      }
32770    }
32771
32772    function saveCompletions(completions) {
32773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
32774    }
32775
32776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
32777    function canonicalFromStored(storedId) {
32778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32779      if (!m) return slugify(storedId);
32780      const [, slug, p, mv, title] = m;
32781      return `${slug}-${p}-${mv}-${slugify(title)}`;
32782    }
32783
32784    function canonicalPlusOne(storedId) {
32785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
32786      if (!m) return null;
32787      const [, slug, p, mv, title] = m;
32788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
32789    }
32790
32791    function isCompleted(canonicalId, completions) {
32792      if (completions.includes(canonicalId)) return true;
32793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
32794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
32795    }
32796
32797    function migrateCompletions() {
32798      const completions = getCompletions();
32799      let changed = false;
32800      const migrated = completions.map((c) => {
32801        const canon = canonicalFromStored(c);
32802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
32803        const plusOne = canonicalPlusOne(c);
32804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
32805          if (plusOne !== c) changed = true;
32806          return plusOne;
32807        }
32808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
32809          changed = true;
32810          return canon;
32811        }
32812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
32813        if (canon !== c) {
32814          changed = true;
32815          return canon;
32816        }
32817        return c;
32818      });
32819      // Elimina duplicados tras migración
32820      const deduped = [...new Set(migrated)];
32821      if (changed || deduped.length !== completions.length) {
32822        saveCompletions(deduped);
32823        return deduped;
32824      }
32825      return completions;
32826    }
32827
32828    function updateButtonState(btn, completions) {
32829      const id = btn.dataset.completionId;
32830      const list = completions ?? getCompletions();
32831      btn.classList.toggle("completed", isCompleted(id, list));
32832    }
32833
32834    function checkModuleCompletion(completions) {
32835      const list = completions ?? getCompletions();
32836      document.querySelectorAll(".course-video").forEach((module) => {
32837        const btns = module.querySelectorAll(".complete-btn");
32838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
32839        const check = module.querySelector(".module-check");
32840        if (check) check.classList.toggle("visible", allCompleted);
32841      });
32842    }
32843
32844    function initCompletionButtons() {
32845      const completions = migrateCompletions();
32846
32847      document.querySelectorAll(".complete-btn").forEach((btn) => {
32848        if (btn.dataset.completionInit) return;
32849        btn.dataset.completionInit = "true";
32850        updateButtonState(btn, completions);
32851
32852        btn.addEventListener("click", (e) => {
32853          e.preventDefault();
32854          e.stopPropagation();
32855
32856          const id = btn.dataset.completionId;
32857          let list = getCompletions();
32858          const completed = isCompleted(id, list);
32859
32860          if (completed) {
32861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
32862            list = list.filter((c) =>
32862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
32863          } else {
32864            list.push(id);
32865          }
32866
32867          saveCompletions(list);
32868          updateButtonState(btn, list);
32869          checkModuleCompletion(list);
32870        });
32871      });
32872
32873      checkModuleCompletion(completions);
32874    }
32875
32876    if (document.readyState === "loading") {
32877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
32878    } else {
32879      initCompletionButtons();
32880    }
32881  })();
32882</script>
32882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="7335" data-end="8288" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Clases HTML</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-clases-html" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
32882<script>
32883  (() => {
32884    if (window.__courseLazyPlayersInitialized) return;
32885    window.__courseLazyPlayersInitialized = true;
32886
32887    function createPlayer(details) {
32888      const videoId = details.dataset.videoId;
32889      const start = details.dataset.start;
32890      const end = details.dataset.end;
32891
32892      const container = document.createElement("div");
32893      container.className = "player-container";
32894
32895      const spinner = document.createElement("div");
32896      spinner.className = "spinner";
32897      container.appendChild(spinner);
32898
32899      const controller = document.createElement("media-controller");
32900      controller.setAttribute("defaultsubtitles", "");
32901
32902      const provider = document.createElement("youtube-media-provider");
32903      provider.setAttribute("slot", "media");
32904      provider.setAttribute("video-id", videoId);
32905      provider.setAttribute("start", start);
32906      provider.setAttribute("end", end);
32907
32908      const controlBar = document.createElement("media-control-bar");
32909
32910      const playBtn = document.createElement("media-play-button");
32911      const timeDisplay = document.createElement("media-time-display");
32912      timeDisplay.setAttribute("showduration", "");
32913      const timeRange = document.createElement("media-time-range");
32914      const playbackRate = document.createElement("media-playback-rate-button");
32915      const muteBtn = document.createElement("media-mute-button");
32916      const volumeRange = document.createElement("media-volume-range");
32917
32918      const captionsBtn = document.createElement("media-captions-button");
32919      captionsBtn.addEventListener("click", (e) => {
32920        e.stopPropagation();
32921        const provider = container.querySelector("youtube-media-provider");
32922        if (provider) provider.toggleCaptions();
32923      });
32924
32925      const qualityBtn = document.createElement("media-chrome-button");
32926      qualityBtn.textContent = "--";
32927      qualityBtn.addEventListener("click", () => {
32928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
32929      });
32930
32931      const fullscreenBtn = document.createElement("media-fullscreen-button");
32932
32933      controlBar.append(
32934        playBtn, timeDisplay, timeRange,
32935        playbackRate, muteBtn, volumeRange,
32936        captionsBtn, qualityBtn, fullscreenBtn
32937      );
32938
32939      const poster = document.createElement("media-poster-image");
32940      poster.setAttribute("slot", "poster");
32941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
32942
32943      controller.append(provider, poster, controlBar);
32944
32945      captionsBtn.setAttribute("aria-checked", "true");
32946      controller.setAttribute("mediasubtitlesshowing", "on");
32947
32948      container.appendChild(controller);
32949      details.appendChild(container);
32950
32951      provider.addEventListener("waiting", () => container.classList.add("loading"));
32952      provider.addEventListener("playing", () => container.classList.remove("loading"));
32953      provider.addEventListener("play", () => container.classList.remove("loading"));
32954
32955      provider._textTracks.addEventListener("change", () => {
32956        const enabled = provider._captionsEnabled;
32957        captionsBtn.setAttribute("aria-checked", enabled);
32958        if (enabled) {
32959          controller.setAttribute("mediasubtitlesshowing", "on");
32960        } else {
32961          controller.removeAttribute("mediasubtitlesshowing");
32962        }
32963      });
32964
32965      provider.addEventListener("qualitychange", (e) => {
32966        qualityBtn.textContent = e.detail.quality;
32967      });
32968    }
32969
32970    function initLazyPlayers() {
32971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
32972        if (details.dataset.lazyInit) return;
32973        details.dataset.lazyInit = "true";
32974        details.addEventListener("toggle", () => {
32975          if (details.open && !details.querySelector(".player-container")) {
32976            createPlayer(details);
32977          } else if (!details.open) {
32978            const provider = details.querySelector("youtube-media-provider");
32979            if (provider && typeof provider.pause === "function") {
32980              provider.pause();
32981            }
32982          }
32983        });
32984      });
32985    }
32986
32987    if (document.readyState === "loading") {
32988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
32989    } else {
32990      initLazyPlayers();
32991    }
32992  })();
32993</script>
32993<script>
32994  (() => {
32995    if (window.__courseCompletionsInitialized) return;
32996    window.__courseCompletionsInitialized = true;
32997
32998    const STORAGE_KEY = "course-completions";
32999
33000    function slugify(text) {
33001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
33002    }
33003
33004    function getCompletions() {
33005      try {
33006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
33007      } catch {
33008        return [];
33009      }
33010    }
33011
33012    function saveCompletions(completions) {
33013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
33014    }
33015
33016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
33017    function canonicalFromStored(storedId) {
33018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33019      if (!m) return slugify(storedId);
33020      const [, slug, p, mv, title] = m;
33021      return `${slug}-${p}-${mv}-${slugify(title)}`;
33022    }
33023
33024    function canonicalPlusOne(storedId) {
33025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33026      if (!m) return null;
33027      const [, slug, p, mv, title] = m;
33028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
33029    }
33030
33031    function isCompleted(canonicalId, completions) {
33032      if (completions.includes(canonicalId)) return true;
33033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
33034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
33035    }
33036
33037    function migrateCompletions() {
33038      const completions = getCompletions();
33039      let changed = false;
33040      const migrated = completions.map((c) => {
33041        const canon = canonicalFromStored(c);
33042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
33043        const plusOne = canonicalPlusOne(c);
33044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
33045          if (plusOne !== c) changed = true;
33046          return plusOne;
33047        }
33048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
33049          changed = true;
33050          return canon;
33051        }
33052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
33053        if (canon !== c) {
33054          changed = true;
33055          return canon;
33056        }
33057        return c;
33058      });
33059      // Elimina duplicados tras migración
33060      const deduped = [...new Set(migrated)];
33061      if (changed || deduped.length !== completions.length) {
33062        saveCompletions(deduped);
33063        return deduped;
33064      }
33065      return completions;
33066    }
33067
33068    function updateButtonState(btn, completions) {
33069      const id = btn.dataset.completionId;
33070      const list = completions ?? getCompletions();
33071      btn.classList.toggle("completed", isCompleted(id, list));
33072    }
33073
33074    function checkModuleCompletion(completions) {
33075      const list = completions ?? getCompletions();
33076      document.querySelectorAll(".course-video").forEach((module) => {
33077        const btns = module.querySelectorAll(".complete-btn");
33078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
33079        const check = module.querySelector(".module-check");
33080        if (check) check.classList.toggle("visible", allCompleted);
33081      });
33082    }
33083
33084    function initCompletionButtons() {
33085      const completions = migrateCompletions();
33086
33087      document.querySelectorAll(".complete-btn").forEach((btn) => {
33088        if (btn.dataset.completionInit) return;
33089        btn.dataset.completionInit = "true";
33090        updateButtonState(btn, completions);
33091
33092        btn.addEventListener("click", (e) => {
33093          e.preventDefault();
33094          e.stopPropagation();
33095
33096          const id = btn.dataset.completionId;
33097          let list = getCompletions();
33098          const completed = isCompleted(id, list);
33099
33100          if (completed) {
33101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
33102            list = list.filter((c) =>
33102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
33103          } else {
33104            list.push(id);
33105          }
33106
33107          saveCompletions(list);
33108          updateButtonState(btn, list);
33109          checkModuleCompletion(list);
33110        });
33111      });
33112
33113      checkModuleCompletion(completions);
33114    }
33115
33116    if (document.readyState === "loading") {
33117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
33118    } else {
33119      initCompletionButtons();
33120    }
33121  })();
33122</script>
33122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="8288" data-end="9125" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Crear elementos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-crear-elementos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
33122<script>
33123  (() => {
33124    if (window.__courseLazyPlayersInitialized) return;
33125    window.__courseLazyPlayersInitialized = true;
33126
33127    function createPlayer(details) {
33128      const videoId = details.dataset.videoId;
33129      const start = details.dataset.start;
33130      const end = details.dataset.end;
33131
33132      const container = document.createElement("div");
33133      container.className = "player-container";
33134
33135      const spinner = document.createElement("div");
33136      spinner.className = "spinner";
33137      container.appendChild(spinner);
33138
33139      const controller = document.createElement("media-controller");
33140      controller.setAttribute("defaultsubtitles", "");
33141
33142      const provider = document.createElement("youtube-media-provider");
33143      provider.setAttribute("slot", "media");
33144      provider.setAttribute("video-id", videoId);
33145      provider.setAttribute("start", start);
33146      provider.setAttribute("end", end);
33147
33148      const controlBar = document.createElement("media-control-bar");
33149
33150      const playBtn = document.createElement("media-play-button");
33151      const timeDisplay = document.createElement("media-time-display");
33152      timeDisplay.setAttribute("showduration", "");
33153      const timeRange = document.createElement("media-time-range");
33154      const playbackRate = document.createElement("media-playback-rate-button");
33155      const muteBtn = document.createElement("media-mute-button");
33156      const volumeRange = document.createElement("media-volume-range");
33157
33158      const captionsBtn = document.createElement("media-captions-button");
33159      captionsBtn.addEventListener("click", (e) => {
33160        e.stopPropagation();
33161        const provider = container.querySelector("youtube-media-provider");
33162        if (provider) provider.toggleCaptions();
33163      });
33164
33165      const qualityBtn = document.createElement("media-chrome-button");
33166      qualityBtn.textContent = "--";
33167      qualityBtn.addEventListener("click", () => {
33168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
33169      });
33170
33171      const fullscreenBtn = document.createElement("media-fullscreen-button");
33172
33173      controlBar.append(
33174        playBtn, timeDisplay, timeRange,
33175        playbackRate, muteBtn, volumeRange,
33176        captionsBtn, qualityBtn, fullscreenBtn
33177      );
33178
33179      const poster = document.createElement("media-poster-image");
33180      poster.setAttribute("slot", "poster");
33181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
33182
33183      controller.append(provider, poster, controlBar);
33184
33185      captionsBtn.setAttribute("aria-checked", "true");
33186      controller.setAttribute("mediasubtitlesshowing", "on");
33187
33188      container.appendChild(controller);
33189      details.appendChild(container);
33190
33191      provider.addEventListener("waiting", () => container.classList.add("loading"));
33192      provider.addEventListener("playing", () => container.classList.remove("loading"));
33193      provider.addEventListener("play", () => container.classList.remove("loading"));
33194
33195      provider._textTracks.addEventListener("change", () => {
33196        const enabled = provider._captionsEnabled;
33197        captionsBtn.setAttribute("aria-checked", enabled);
33198        if (enabled) {
33199          controller.setAttribute("mediasubtitlesshowing", "on");
33200        } else {
33201          controller.removeAttribute("mediasubtitlesshowing");
33202        }
33203      });
33204
33205      provider.addEventListener("qualitychange", (e) => {
33206        qualityBtn.textContent = e.detail.quality;
33207      });
33208    }
33209
33210    function initLazyPlayers() {
33211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
33212        if (details.dataset.lazyInit) return;
33213        details.dataset.lazyInit = "true";
33214        details.addEventListener("toggle", () => {
33215          if (details.open && !details.querySelector(".player-container")) {
33216            createPlayer(details);
33217          } else if (!details.open) {
33218            const provider = details.querySelector("youtube-media-provider");
33219            if (provider && typeof provider.pause === "function") {
33220              provider.pause();
33221            }
33222          }
33223        });
33224      });
33225    }
33226
33227    if (document.readyState === "loading") {
33228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
33229    } else {
33230      initLazyPlayers();
33231    }
33232  })();
33233</script>
33233<script>
33234  (() => {
33235    if (window.__courseCompletionsInitialized) return;
33236    window.__courseCompletionsInitialized = true;
33237
33238    const STORAGE_KEY = "course-completions";
33239
33240    function slugify(text) {
33241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
33242    }
33243
33244    function getCompletions() {
33245      try {
33246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
33247      } catch {
33248        return [];
33249      }
33250    }
33251
33252    function saveCompletions(completions) {
33253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
33254    }
33255
33256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
33257    function canonicalFromStored(storedId) {
33258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33259      if (!m) return slugify(storedId);
33260      const [, slug, p, mv, title] = m;
33261      return `${slug}-${p}-${mv}-${slugify(title)}`;
33262    }
33263
33264    function canonicalPlusOne(storedId) {
33265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33266      if (!m) return null;
33267      const [, slug, p, mv, title] = m;
33268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
33269    }
33270
33271    function isCompleted(canonicalId, completions) {
33272      if (completions.includes(canonicalId)) return true;
33273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
33274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
33275    }
33276
33277    function migrateCompletions() {
33278      const completions = getCompletions();
33279      let changed = false;
33280      const migrated = completions.map((c) => {
33281        const canon = canonicalFromStored(c);
33282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
33283        const plusOne = canonicalPlusOne(c);
33284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
33285          if (plusOne !== c) changed = true;
33286          return plusOne;
33287        }
33288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
33289          changed = true;
33290          return canon;
33291        }
33292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
33293        if (canon !== c) {
33294          changed = true;
33295          return canon;
33296        }
33297        return c;
33298      });
33299      // Elimina duplicados tras migración
33300      const deduped = [...new Set(migrated)];
33301      if (changed || deduped.length !== completions.length) {
33302        saveCompletions(deduped);
33303        return deduped;
33304      }
33305      return completions;
33306    }
33307
33308    function updateButtonState(btn, completions) {
33309      const id = btn.dataset.completionId;
33310      const list = completions ?? getCompletions();
33311      btn.classList.toggle("completed", isCompleted(id, list));
33312    }
33313
33314    function checkModuleCompletion(completions) {
33315      const list = completions ?? getCompletions();
33316      document.querySelectorAll(".course-video").forEach((module) => {
33317        const btns = module.querySelectorAll(".complete-btn");
33318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
33319        const check = module.querySelector(".module-check");
33320        if (check) check.classList.toggle("visible", allCompleted);
33321      });
33322    }
33323
33324    function initCompletionButtons() {
33325      const completions = migrateCompletions();
33326
33327      document.querySelectorAll(".complete-btn").forEach((btn) => {
33328        if (btn.dataset.completionInit) return;
33329        btn.dataset.completionInit = "true";
33330        updateButtonState(btn, completions);
33331
33332        btn.addEventListener("click", (e) => {
33333          e.preventDefault();
33334          e.stopPropagation();
33335
33336          const id = btn.dataset.completionId;
33337          let list = getCompletions();
33338          const completed = isCompleted(id, list);
33339
33340          if (completed) {
33341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
33342            list = list.filter((c) =>
33342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
33343          } else {
33344            list.push(id);
33345          }
33346
33347          saveCompletions(list);
33348          updateButtonState(btn, list);
33349          checkModuleCompletion(list);
33350        });
33351      });
33352
33353      checkModuleCompletion(completions);
33354    }
33355
33356    if (document.readyState === "loading") {
33357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
33358    } else {
33359      initCompletionButtons();
33360    }
33361  })();
33362</script>
33362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="9125" data-end="9810" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. APIs de inserción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-apis-de-insercion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
33362<script>
33363  (() => {
33364    if (window.__courseLazyPlayersInitialized) return;
33365    window.__courseLazyPlayersInitialized = true;
33366
33367    function createPlayer(details) {
33368      const videoId = details.dataset.videoId;
33369      const start = details.dataset.start;
33370      const end = details.dataset.end;
33371
33372      const container = document.createElement("div");
33373      container.className = "player-container";
33374
33375      const spinner = document.createElement("div");
33376      spinner.className = "spinner";
33377      container.appendChild(spinner);
33378
33379      const controller = document.createElement("media-controller");
33380      controller.setAttribute("defaultsubtitles", "");
33381
33382      const provider = document.createElement("youtube-media-provider");
33383      provider.setAttribute("slot", "media");
33384      provider.setAttribute("video-id", videoId);
33385      provider.setAttribute("start", start);
33386      provider.setAttribute("end", end);
33387
33388      const controlBar = document.createElement("media-control-bar");
33389
33390      const playBtn = document.createElement("media-play-button");
33391      const timeDisplay = document.createElement("media-time-display");
33392      timeDisplay.setAttribute("showduration", "");
33393      const timeRange = document.createElement("media-time-range");
33394      const playbackRate = document.createElement("media-playback-rate-button");
33395      const muteBtn = document.createElement("media-mute-button");
33396      const volumeRange = document.createElement("media-volume-range");
33397
33398      const captionsBtn = document.createElement("media-captions-button");
33399      captionsBtn.addEventListener("click", (e) => {
33400        e.stopPropagation();
33401        const provider = container.querySelector("youtube-media-provider");
33402        if (provider) provider.toggleCaptions();
33403      });
33404
33405      const qualityBtn = document.createElement("media-chrome-button");
33406      qualityBtn.textContent = "--";
33407      qualityBtn.addEventListener("click", () => {
33408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
33409      });
33410
33411      const fullscreenBtn = document.createElement("media-fullscreen-button");
33412
33413      controlBar.append(
33414        playBtn, timeDisplay, timeRange,
33415        playbackRate, muteBtn, volumeRange,
33416        captionsBtn, qualityBtn, fullscreenBtn
33417      );
33418
33419      const poster = document.createElement("media-poster-image");
33420      poster.setAttribute("slot", "poster");
33421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
33422
33423      controller.append(provider, poster, controlBar);
33424
33425      captionsBtn.setAttribute("aria-checked", "true");
33426      controller.setAttribute("mediasubtitlesshowing", "on");
33427
33428      container.appendChild(controller);
33429      details.appendChild(container);
33430
33431      provider.addEventListener("waiting", () => container.classList.add("loading"));
33432      provider.addEventListener("playing", () => container.classList.remove("loading"));
33433      provider.addEventListener("play", () => container.classList.remove("loading"));
33434
33435      provider._textTracks.addEventListener("change", () => {
33436        const enabled = provider._captionsEnabled;
33437        captionsBtn.setAttribute("aria-checked", enabled);
33438        if (enabled) {
33439          controller.setAttribute("mediasubtitlesshowing", "on");
33440        } else {
33441          controller.removeAttribute("mediasubtitlesshowing");
33442        }
33443      });
33444
33445      provider.addEventListener("qualitychange", (e) => {
33446        qualityBtn.textContent = e.detail.quality;
33447      });
33448    }
33449
33450    function initLazyPlayers() {
33451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
33452        if (details.dataset.lazyInit) return;
33453        details.dataset.lazyInit = "true";
33454        details.addEventListener("toggle", () => {
33455          if (details.open && !details.querySelector(".player-container")) {
33456            createPlayer(details);
33457          } else if (!details.open) {
33458            const provider = details.querySelector("youtube-media-provider");
33459            if (provider && typeof provider.pause === "function") {
33460              provider.pause();
33461            }
33462          }
33463        });
33464      });
33465    }
33466
33467    if (document.readyState === "loading") {
33468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
33469    } else {
33470      initLazyPlayers();
33471    }
33472  })();
33473</script>
33473<script>
33474  (() => {
33475    if (window.__courseCompletionsInitialized) return;
33476    window.__courseCompletionsInitialized = true;
33477
33478    const STORAGE_KEY = "course-completions";
33479
33480    function slugify(text) {
33481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
33482    }
33483
33484    function getCompletions() {
33485      try {
33486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
33487      } catch {
33488        return [];
33489      }
33490    }
33491
33492    function saveCompletions(completions) {
33493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
33494    }
33495
33496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
33497    function canonicalFromStored(storedId) {
33498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33499      if (!m) return slugify(storedId);
33500      const [, slug, p, mv, title] = m;
33501      return `${slug}-${p}-${mv}-${slugify(title)}`;
33502    }
33503
33504    function canonicalPlusOne(storedId) {
33505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33506      if (!m) return null;
33507      const [, slug, p, mv, title] = m;
33508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
33509    }
33510
33511    function isCompleted(canonicalId, completions) {
33512      if (completions.includes(canonicalId)) return true;
33513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
33514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
33515    }
33516
33517    function migrateCompletions() {
33518      const completions = getCompletions();
33519      let changed = false;
33520      const migrated = completions.map((c) => {
33521        const canon = canonicalFromStored(c);
33522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
33523        const plusOne = canonicalPlusOne(c);
33524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
33525          if (plusOne !== c) changed = true;
33526          return plusOne;
33527        }
33528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
33529          changed = true;
33530          return canon;
33531        }
33532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
33533        if (canon !== c) {
33534          changed = true;
33535          return canon;
33536        }
33537        return c;
33538      });
33539      // Elimina duplicados tras migración
33540      const deduped = [...new Set(migrated)];
33541      if (changed || deduped.length !== completions.length) {
33542        saveCompletions(deduped);
33543        return deduped;
33544      }
33545      return completions;
33546    }
33547
33548    function updateButtonState(btn, completions) {
33549      const id = btn.dataset.completionId;
33550      const list = completions ?? getCompletions();
33551      btn.classList.toggle("completed", isCompleted(id, list));
33552    }
33553
33554    function checkModuleCompletion(completions) {
33555      const list = completions ?? getCompletions();
33556      document.querySelectorAll(".course-video").forEach((module) => {
33557        const btns = module.querySelectorAll(".complete-btn");
33558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
33559        const check = module.querySelector(".module-check");
33560        if (check) check.classList.toggle("visible", allCompleted);
33561      });
33562    }
33563
33564    function initCompletionButtons() {
33565      const completions = migrateCompletions();
33566
33567      document.querySelectorAll(".complete-btn").forEach((btn) => {
33568        if (btn.dataset.completionInit) return;
33569        btn.dataset.completionInit = "true";
33570        updateButtonState(btn, completions);
33571
33572        btn.addEventListener("click", (e) => {
33573          e.preventDefault();
33574          e.stopPropagation();
33575
33576          const id = btn.dataset.completionId;
33577          let list = getCompletions();
33578          const completed = isCompleted(id, list);
33579
33580          if (completed) {
33581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
33582            list = list.filter((c) =>
33582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
33583          } else {
33584            list.push(id);
33585          }
33586
33587          saveCompletions(list);
33588          updateButtonState(btn, list);
33589          checkModuleCompletion(list);
33590        });
33591      });
33592
33593      checkModuleCompletion(completions);
33594    }
33595
33596    if (document.readyState === "loading") {
33597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
33598    } else {
33599      initCompletionButtons();
33600    }
33601  })();
33602</script>
33602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="9810" data-end="11046" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Etiqueta template</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-etiqueta-template" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
33602<script>
33603  (() => {
33604    if (window.__courseLazyPlayersInitialized) return;
33605    window.__courseLazyPlayersInitialized = true;
33606
33607    function createPlayer(details) {
33608      const videoId = details.dataset.videoId;
33609      const start = details.dataset.start;
33610      const end = details.dataset.end;
33611
33612      const container = document.createElement("div");
33613      container.className = "player-container";
33614
33615      const spinner = document.createElement("div");
33616      spinner.className = "spinner";
33617      container.appendChild(spinner);
33618
33619      const controller = document.createElement("media-controller");
33620      controller.setAttribute("defaultsubtitles", "");
33621
33622      const provider = document.createElement("youtube-media-provider");
33623      provider.setAttribute("slot", "media");
33624      provider.setAttribute("video-id", videoId);
33625      provider.setAttribute("start", start);
33626      provider.setAttribute("end", end);
33627
33628      const controlBar = document.createElement("media-control-bar");
33629
33630      const playBtn = document.createElement("media-play-button");
33631      const timeDisplay = document.createElement("media-time-display");
33632      timeDisplay.setAttribute("showduration", "");
33633      const timeRange = document.createElement("media-time-range");
33634      const playbackRate = document.createElement("media-playback-rate-button");
33635      const muteBtn = document.createElement("media-mute-button");
33636      const volumeRange = document.createElement("media-volume-range");
33637
33638      const captionsBtn = document.createElement("media-captions-button");
33639      captionsBtn.addEventListener("click", (e) => {
33640        e.stopPropagation();
33641        const provider = container.querySelector("youtube-media-provider");
33642        if (provider) provider.toggleCaptions();
33643      });
33644
33645      const qualityBtn = document.createElement("media-chrome-button");
33646      qualityBtn.textContent = "--";
33647      qualityBtn.addEventListener("click", () => {
33648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
33649      });
33650
33651      const fullscreenBtn = document.createElement("media-fullscreen-button");
33652
33653      controlBar.append(
33654        playBtn, timeDisplay, timeRange,
33655        playbackRate, muteBtn, volumeRange,
33656        captionsBtn, qualityBtn, fullscreenBtn
33657      );
33658
33659      const poster = document.createElement("media-poster-image");
33660      poster.setAttribute("slot", "poster");
33661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
33662
33663      controller.append(provider, poster, controlBar);
33664
33665      captionsBtn.setAttribute("aria-checked", "true");
33666      controller.setAttribute("mediasubtitlesshowing", "on");
33667
33668      container.appendChild(controller);
33669      details.appendChild(container);
33670
33671      provider.addEventListener("waiting", () => container.classList.add("loading"));
33672      provider.addEventListener("playing", () => container.classList.remove("loading"));
33673      provider.addEventListener("play", () => container.classList.remove("loading"));
33674
33675      provider._textTracks.addEventListener("change", () => {
33676        const enabled = provider._captionsEnabled;
33677        captionsBtn.setAttribute("aria-checked", enabled);
33678        if (enabled) {
33679          controller.setAttribute("mediasubtitlesshowing", "on");
33680        } else {
33681          controller.removeAttribute("mediasubtitlesshowing");
33682        }
33683      });
33684
33685      provider.addEventListener("qualitychange", (e) => {
33686        qualityBtn.textContent = e.detail.quality;
33687      });
33688    }
33689
33690    function initLazyPlayers() {
33691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
33692        if (details.dataset.lazyInit) return;
33693        details.dataset.lazyInit = "true";
33694        details.addEventListener("toggle", () => {
33695          if (details.open && !details.querySelector(".player-container")) {
33696            createPlayer(details);
33697          } else if (!details.open) {
33698            const provider = details.querySelector("youtube-media-provider");
33699            if (provider && typeof provider.pause === "function") {
33700              provider.pause();
33701            }
33702          }
33703        });
33704      });
33705    }
33706
33707    if (document.readyState === "loading") {
33708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
33709    } else {
33710      initLazyPlayers();
33711    }
33712  })();
33713</script>
33713<script>
33714  (() => {
33715    if (window.__courseCompletionsInitialized) return;
33716    window.__courseCompletionsInitialized = true;
33717
33718    const STORAGE_KEY = "course-completions";
33719
33720    function slugify(text) {
33721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
33722    }
33723
33724    function getCompletions() {
33725      try {
33726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
33727      } catch {
33728        return [];
33729      }
33730    }
33731
33732    function saveCompletions(completions) {
33733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
33734    }
33735
33736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
33737    function canonicalFromStored(storedId) {
33738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33739      if (!m) return slugify(storedId);
33740      const [, slug, p, mv, title] = m;
33741      return `${slug}-${p}-${mv}-${slugify(title)}`;
33742    }
33743
33744    function canonicalPlusOne(storedId) {
33745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33746      if (!m) return null;
33747      const [, slug, p, mv, title] = m;
33748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
33749    }
33750
33751    function isCompleted(canonicalId, completions) {
33752      if (completions.includes(canonicalId)) return true;
33753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
33754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
33755    }
33756
33757    function migrateCompletions() {
33758      const completions = getCompletions();
33759      let changed = false;
33760      const migrated = completions.map((c) => {
33761        const canon = canonicalFromStored(c);
33762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
33763        const plusOne = canonicalPlusOne(c);
33764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
33765          if (plusOne !== c) changed = true;
33766          return plusOne;
33767        }
33768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
33769          changed = true;
33770          return canon;
33771        }
33772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
33773        if (canon !== c) {
33774          changed = true;
33775          return canon;
33776        }
33777        return c;
33778      });
33779      // Elimina duplicados tras migración
33780      const deduped = [...new Set(migrated)];
33781      if (changed || deduped.length !== completions.length) {
33782        saveCompletions(deduped);
33783        return deduped;
33784      }
33785      return completions;
33786    }
33787
33788    function updateButtonState(btn, completions) {
33789      const id = btn.dataset.completionId;
33790      const list = completions ?? getCompletions();
33791      btn.classList.toggle("completed", isCompleted(id, list));
33792    }
33793
33794    function checkModuleCompletion(completions) {
33795      const list = completions ?? getCompletions();
33796      document.querySelectorAll(".course-video").forEach((module) => {
33797        const btns = module.querySelectorAll(".complete-btn");
33798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
33799        const check = module.querySelector(".module-check");
33800        if (check) check.classList.toggle("visible", allCompleted);
33801      });
33802    }
33803
33804    function initCompletionButtons() {
33805      const completions = migrateCompletions();
33806
33807      document.querySelectorAll(".complete-btn").forEach((btn) => {
33808        if (btn.dataset.completionInit) return;
33809        btn.dataset.completionInit = "true";
33810        updateButtonState(btn, completions);
33811
33812        btn.addEventListener("click", (e) => {
33813          e.preventDefault();
33814          e.stopPropagation();
33815
33816          const id = btn.dataset.completionId;
33817          let list = getCompletions();
33818          const completed = isCompleted(id, list);
33819
33820          if (completed) {
33821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
33822            list = list.filter((c) =>
33822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
33823          } else {
33824            list.push(id);
33825          }
33826
33827          saveCompletions(list);
33828          updateButtonState(btn, list);
33829          checkModuleCompletion(list);
33830        });
33831      });
33832
33833      checkModuleCompletion(completions);
33834    }
33835
33836    if (document.readyState === "loading") {
33837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
33838    } else {
33839      initCompletionButtons();
33840    }
33841  })();
33842</script>
33842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="11046" data-end="14030" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. APIs relacionadas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>50 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-apis-relacionadas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
33842<script>
33843  (() => {
33844    if (window.__courseLazyPlayersInitialized) return;
33845    window.__courseLazyPlayersInitialized = true;
33846
33847    function createPlayer(details) {
33848      const videoId = details.dataset.videoId;
33849      const start = details.dataset.start;
33850      const end = details.dataset.end;
33851
33852      const container = document.createElement("div");
33853      container.className = "player-container";
33854
33855      const spinner = document.createElement("div");
33856      spinner.className = "spinner";
33857      container.appendChild(spinner);
33858
33859      const controller = document.createElement("media-controller");
33860      controller.setAttribute("defaultsubtitles", "");
33861
33862      const provider = document.createElement("youtube-media-provider");
33863      provider.setAttribute("slot", "media");
33864      provider.setAttribute("video-id", videoId);
33865      provider.setAttribute("start", start);
33866      provider.setAttribute("end", end);
33867
33868      const controlBar = document.createElement("media-control-bar");
33869
33870      const playBtn = document.createElement("media-play-button");
33871      const timeDisplay = document.createElement("media-time-display");
33872      timeDisplay.setAttribute("showduration", "");
33873      const timeRange = document.createElement("media-time-range");
33874      const playbackRate = document.createElement("media-playback-rate-button");
33875      const muteBtn = document.createElement("media-mute-button");
33876      const volumeRange = document.createElement("media-volume-range");
33877
33878      const captionsBtn = document.createElement("media-captions-button");
33879      captionsBtn.addEventListener("click", (e) => {
33880        e.stopPropagation();
33881        const provider = container.querySelector("youtube-media-provider");
33882        if (provider) provider.toggleCaptions();
33883      });
33884
33885      const qualityBtn = document.createElement("media-chrome-button");
33886      qualityBtn.textContent = "--";
33887      qualityBtn.addEventListener("click", () => {
33888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
33889      });
33890
33891      const fullscreenBtn = document.createElement("media-fullscreen-button");
33892
33893      controlBar.append(
33894        playBtn, timeDisplay, timeRange,
33895        playbackRate, muteBtn, volumeRange,
33896        captionsBtn, qualityBtn, fullscreenBtn
33897      );
33898
33899      const poster = document.createElement("media-poster-image");
33900      poster.setAttribute("slot", "poster");
33901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
33902
33903      controller.append(provider, poster, controlBar);
33904
33905      captionsBtn.setAttribute("aria-checked", "true");
33906      controller.setAttribute("mediasubtitlesshowing", "on");
33907
33908      container.appendChild(controller);
33909      details.appendChild(container);
33910
33911      provider.addEventListener("waiting", () => container.classList.add("loading"));
33912      provider.addEventListener("playing", () => container.classList.remove("loading"));
33913      provider.addEventListener("play", () => container.classList.remove("loading"));
33914
33915      provider._textTracks.addEventListener("change", () => {
33916        const enabled = provider._captionsEnabled;
33917        captionsBtn.setAttribute("aria-checked", enabled);
33918        if (enabled) {
33919          controller.setAttribute("mediasubtitlesshowing", "on");
33920        } else {
33921          controller.removeAttribute("mediasubtitlesshowing");
33922        }
33923      });
33924
33925      provider.addEventListener("qualitychange", (e) => {
33926        qualityBtn.textContent = e.detail.quality;
33927      });
33928    }
33929
33930    function initLazyPlayers() {
33931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
33932        if (details.dataset.lazyInit) return;
33933        details.dataset.lazyInit = "true";
33934        details.addEventListener("toggle", () => {
33935          if (details.open && !details.querySelector(".player-container")) {
33936            createPlayer(details);
33937          } else if (!details.open) {
33938            const provider = details.querySelector("youtube-media-provider");
33939            if (provider && typeof provider.pause === "function") {
33940              provider.pause();
33941            }
33942          }
33943        });
33944      });
33945    }
33946
33947    if (document.readyState === "loading") {
33948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
33949    } else {
33950      initLazyPlayers();
33951    }
33952  })();
33953</script>
33953<script>
33954  (() => {
33955    if (window.__courseCompletionsInitialized) return;
33956    window.__courseCompletionsInitialized = true;
33957
33958    const STORAGE_KEY = "course-completions";
33959
33960    function slugify(text) {
33961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
33962    }
33963
33964    function getCompletions() {
33965      try {
33966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
33967      } catch {
33968        return [];
33969      }
33970    }
33971
33972    function saveCompletions(completions) {
33973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
33974    }
33975
33976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
33977    function canonicalFromStored(storedId) {
33978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33979      if (!m) return slugify(storedId);
33980      const [, slug, p, mv, title] = m;
33981      return `${slug}-${p}-${mv}-${slugify(title)}`;
33982    }
33983
33984    function canonicalPlusOne(storedId) {
33985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
33986      if (!m) return null;
33987      const [, slug, p, mv, title] = m;
33988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
33989    }
33990
33991    function isCompleted(canonicalId, completions) {
33992      if (completions.includes(canonicalId)) return true;
33993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
33994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
33995    }
33996
33997    function migrateCompletions() {
33998      const completions = getCompletions();
33999      let changed = false;
34000      const migrated = completions.map((c) => {
34001        const canon = canonicalFromStored(c);
34002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
34003        const plusOne = canonicalPlusOne(c);
34004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
34005          if (plusOne !== c) changed = true;
34006          return plusOne;
34007        }
34008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
34009          changed = true;
34010          return canon;
34011        }
34012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
34013        if (canon !== c) {
34014          changed = true;
34015          return canon;
34016        }
34017        return c;
34018      });
34019      // Elimina duplicados tras migración
34020      const deduped = [...new Set(migrated)];
34021      if (changed || deduped.length !== completions.length) {
34022        saveCompletions(deduped);
34023        return deduped;
34024      }
34025      return completions;
34026    }
34027
34028    function updateButtonState(btn, completions) {
34029      const id = btn.dataset.completionId;
34030      const list = completions ?? getCompletions();
34031      btn.classList.toggle("completed", isCompleted(id, list));
34032    }
34033
34034    function checkModuleCompletion(completions) {
34035      const list = completions ?? getCompletions();
34036      document.querySelectorAll(".course-video").forEach((module) => {
34037        const btns = module.querySelectorAll(".complete-btn");
34038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
34039        const check = module.querySelector(".module-check");
34040        if (check) check.classList.toggle("visible", allCompleted);
34041      });
34042    }
34043
34044    function initCompletionButtons() {
34045      const completions = migrateCompletions();
34046
34047      document.querySelectorAll(".complete-btn").forEach((btn) => {
34048        if (btn.dataset.completionInit) return;
34049        btn.dataset.completionInit = "true";
34050        updateButtonState(btn, completions);
34051
34052        btn.addEventListener("click", (e) => {
34053          e.preventDefault();
34054          e.stopPropagation();
34055
34056          const id = btn.dataset.completionId;
34057          let list = getCompletions();
34058          const completed = isCompleted(id, list);
34059
34060          if (completed) {
34061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
34062            list = list.filter((c) =>
34062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
34063          } else {
34064            list.push(id);
34065          }
34066
34067          saveCompletions(list);
34068          updateButtonState(btn, list);
34069          checkModuleCompletion(list);
34070        });
34071      });
34072
34073      checkModuleCompletion(completions);
34074    }
34075
34076    if (document.readyState === "loading") {
34077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
34078    } else {
34079      initCompletionButtons();
34080    }
34081  })();
34082</script>
34082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="aAPzJgclsPA" data-start="14030" data-end="16420" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Plantillas HTML</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>40 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-2-plantillas-html" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
34082<script>
34083  (() => {
34084    if (window.__courseLazyPlayersInitialized) return;
34085    window.__courseLazyPlayersInitialized = true;
34086
34087    function createPlayer(details) {
34088      const videoId = details.dataset.videoId;
34089      const start = details.dataset.start;
34090      const end = details.dataset.end;
34091
34092      const container = document.createElement("div");
34093      container.className = "player-container";
34094
34095      const spinner = document.createElement("div");
34096      spinner.className = "spinner";
34097      container.appendChild(spinner);
34098
34099      const controller = document.createElement("media-controller");
34100      controller.setAttribute("defaultsubtitles", "");
34101
34102      const provider = document.createElement("youtube-media-provider");
34103      provider.setAttribute("slot", "media");
34104      provider.setAttribute("video-id", videoId);
34105      provider.setAttribute("start", start);
34106      provider.setAttribute("end", end);
34107
34108      const controlBar = document.createElement("media-control-bar");
34109
34110      const playBtn = document.createElement("media-play-button");
34111      const timeDisplay = document.createElement("media-time-display");
34112      timeDisplay.setAttribute("showduration", "");
34113      const timeRange = document.createElement("media-time-range");
34114      const playbackRate = document.createElement("media-playback-rate-button");
34115      const muteBtn = document.createElement("media-mute-button");
34116      const volumeRange = document.createElement("media-volume-range");
34117
34118      const captionsBtn = document.createElement("media-captions-button");
34119      captionsBtn.addEventListener("click", (e) => {
34120        e.stopPropagation();
34121        const provider = container.querySelector("youtube-media-provider");
34122        if (provider) provider.toggleCaptions();
34123      });
34124
34125      const qualityBtn = document.createElement("media-chrome-button");
34126      qualityBtn.textContent = "--";
34127      qualityBtn.addEventListener("click", () => {
34128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
34129      });
34130
34131      const fullscreenBtn = document.createElement("media-fullscreen-button");
34132
34133      controlBar.append(
34134        playBtn, timeDisplay, timeRange,
34135        playbackRate, muteBtn, volumeRange,
34136        captionsBtn, qualityBtn, fullscreenBtn
34137      );
34138
34139      const poster = document.createElement("media-poster-image");
34140      poster.setAttribute("slot", "poster");
34141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
34142
34143      controller.append(provider, poster, controlBar);
34144
34145      captionsBtn.setAttribute("aria-checked", "true");
34146      controller.setAttribute("mediasubtitlesshowing", "on");
34147
34148      container.appendChild(controller);
34149      details.appendChild(container);
34150
34151      provider.addEventListener("waiting", () => container.classList.add("loading"));
34152      provider.addEventListener("playing", () => container.classList.remove("loading"));
34153      provider.addEventListener("play", () => container.classList.remove("loading"));
34154
34155      provider._textTracks.addEventListener("change", () => {
34156        const enabled = provider._captionsEnabled;
34157        captionsBtn.setAttribute("aria-checked", enabled);
34158        if (enabled) {
34159          controller.setAttribute("mediasubtitlesshowing", "on");
34160        } else {
34161          controller.removeAttribute("mediasubtitlesshowing");
34162        }
34163      });
34164
34165      provider.addEventListener("qualitychange", (e) => {
34166        qualityBtn.textContent = e.detail.quality;
34167      });
34168    }
34169
34170    function initLazyPlayers() {
34171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
34172        if (details.dataset.lazyInit) return;
34173        details.dataset.lazyInit = "true";
34174        details.addEventListener("toggle", () => {
34175          if (details.open && !details.querySelector(".player-container")) {
34176            createPlayer(details);
34177          } else if (!details.open) {
34178            const provider = details.querySelector("youtube-media-provider");
34179            if (provider && typeof provider.pause === "function") {
34180              provider.pause();
34181            }
34182          }
34183        });
34184      });
34185    }
34186
34187    if (document.readyState === "loading") {
34188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
34189    } else {
34190      initLazyPlayers();
34191    }
34192  })();
34193</script>
34193<script>
34194  (() => {
34195    if (window.__courseCompletionsInitialized) return;
34196    window.__courseCompletionsInitialized = true;
34197
34198    const STORAGE_KEY = "course-completions";
34199
34200    function slugify(text) {
34201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34202    }
34203
34204    function getCompletions() {
34205      try {
34206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
34207      } catch {
34208        return [];
34209      }
34210    }
34211
34212    function saveCompletions(completions) {
34213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
34214    }
34215
34216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
34217    function canonicalFromStored(storedId) {
34218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34219      if (!m) return slugify(storedId);
34220      const [, slug, p, mv, title] = m;
34221      return `${slug}-${p}-${mv}-${slugify(title)}`;
34222    }
34223
34224    function canonicalPlusOne(storedId) {
34225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34226      if (!m) return null;
34227      const [, slug, p, mv, title] = m;
34228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
34229    }
34230
34231    function isCompleted(canonicalId, completions) {
34232      if (completions.includes(canonicalId)) return true;
34233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
34234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
34235    }
34236
34237    function migrateCompletions() {
34238      const completions = getCompletions();
34239      let changed = false;
34240      const migrated = completions.map((c) => {
34241        const canon = canonicalFromStored(c);
34242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
34243        const plusOne = canonicalPlusOne(c);
34244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
34245          if (plusOne !== c) changed = true;
34246          return plusOne;
34247        }
34248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
34249          changed = true;
34250          return canon;
34251        }
34252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
34253        if (canon !== c) {
34254          changed = true;
34255          return canon;
34256        }
34257        return c;
34258      });
34259      // Elimina duplicados tras migración
34260      const deduped = [...new Set(migrated)];
34261      if (changed || deduped.length !== completions.length) {
34262        saveCompletions(deduped);
34263        return deduped;
34264      }
34265      return completions;
34266    }
34267
34268    function updateButtonState(btn, completions) {
34269      const id = btn.dataset.completionId;
34270      const list = completions ?? getCompletions();
34271      btn.classList.toggle("completed", isCompleted(id, list));
34272    }
34273
34274    function checkModuleCompletion(completions) {
34275      const list = completions ?? getCompletions();
34276      document.querySelectorAll(".course-video").forEach((module) => {
34277        const btns = module.querySelectorAll(".complete-btn");
34278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
34279        const check = module.querySelector(".module-check");
34280        if (check) check.classList.toggle("visible", allCompleted);
34281      });
34282    }
34283
34284    function initCompletionButtons() {
34285      const completions = migrateCompletions();
34286
34287      document.querySelectorAll(".complete-btn").forEach((btn) => {
34288        if (btn.dataset.completionInit) return;
34289        btn.dataset.completionInit = "true";
34290        updateButtonState(btn, completions);
34291
34292        btn.addEventListener("click", (e) => {
34293          e.preventDefault();
34294          e.stopPropagation();
34295
34296          const id = btn.dataset.completionId;
34297          let list = getCompletions();
34298          const completed = isCompleted(id, list);
34299
34300          if (completed) {
34301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
34302            list = list.filter((c) =>
34302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
34303          } else {
34304            list.push(id);
34305          }
34306
34307          saveCompletions(list);
34308          updateButtonState(btn, list);
34309          checkModuleCompletion(list);
34310        });
34311      });
34312
34313      checkModuleCompletion(completions);
34314    }
34315
34316    if (document.readyState === "loading") {
34317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
34318    } else {
34319      initCompletionButtons();
34320    }
34321  })();
34322</script>
34322</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 11 📒 Eventos JS<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Awbco0mMbmM" data-start="2090" data-end="5225" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Eventos en Javascript</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>52 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-3-eventos-en-javascript" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
34322<script>
34323  (() => {
34324    if (window.__courseLazyPlayersInitialized) return;
34325    window.__courseLazyPlayersInitialized = true;
34326
34327    function createPlayer(details) {
34328      const videoId = details.dataset.videoId;
34329      const start = details.dataset.start;
34330      const end = details.dataset.end;
34331
34332      const container = document.createElement("div");
34333      container.className = "player-container";
34334
34335      const spinner = document.createElement("div");
34336      spinner.className = "spinner";
34337      container.appendChild(spinner);
34338
34339      const controller = document.createElement("media-controller");
34340      controller.setAttribute("defaultsubtitles", "");
34341
34342      const provider = document.createElement("youtube-media-provider");
34343      provider.setAttribute("slot", "media");
34344      provider.setAttribute("video-id", videoId);
34345      provider.setAttribute("start", start);
34346      provider.setAttribute("end", end);
34347
34348      const controlBar = document.createElement("media-control-bar");
34349
34350      const playBtn = document.createElement("media-play-button");
34351      const timeDisplay = document.createElement("media-time-display");
34352      timeDisplay.setAttribute("showduration", "");
34353      const timeRange = document.createElement("media-time-range");
34354      const playbackRate = document.createElement("media-playback-rate-button");
34355      const muteBtn = document.createElement("media-mute-button");
34356      const volumeRange = document.createElement("media-volume-range");
34357
34358      const captionsBtn = document.createElement("media-captions-button");
34359      captionsBtn.addEventListener("click", (e) => {
34360        e.stopPropagation();
34361        const provider = container.querySelector("youtube-media-provider");
34362        if (provider) provider.toggleCaptions();
34363      });
34364
34365      const qualityBtn = document.createElement("media-chrome-button");
34366      qualityBtn.textContent = "--";
34367      qualityBtn.addEventListener("click", () => {
34368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
34369      });
34370
34371      const fullscreenBtn = document.createElement("media-fullscreen-button");
34372
34373      controlBar.append(
34374        playBtn, timeDisplay, timeRange,
34375        playbackRate, muteBtn, volumeRange,
34376        captionsBtn, qualityBtn, fullscreenBtn
34377      );
34378
34379      const poster = document.createElement("media-poster-image");
34380      poster.setAttribute("slot", "poster");
34381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
34382
34383      controller.append(provider, poster, controlBar);
34384
34385      captionsBtn.setAttribute("aria-checked", "true");
34386      controller.setAttribute("mediasubtitlesshowing", "on");
34387
34388      container.appendChild(controller);
34389      details.appendChild(container);
34390
34391      provider.addEventListener("waiting", () => container.classList.add("loading"));
34392      provider.addEventListener("playing", () => container.classList.remove("loading"));
34393      provider.addEventListener("play", () => container.classList.remove("loading"));
34394
34395      provider._textTracks.addEventListener("change", () => {
34396        const enabled = provider._captionsEnabled;
34397        captionsBtn.setAttribute("aria-checked", enabled);
34398        if (enabled) {
34399          controller.setAttribute("mediasubtitlesshowing", "on");
34400        } else {
34401          controller.removeAttribute("mediasubtitlesshowing");
34402        }
34403      });
34404
34405      provider.addEventListener("qualitychange", (e) => {
34406        qualityBtn.textContent = e.detail.quality;
34407      });
34408    }
34409
34410    function initLazyPlayers() {
34411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
34412        if (details.dataset.lazyInit) return;
34413        details.dataset.lazyInit = "true";
34414        details.addEventListener("toggle", () => {
34415          if (details.open && !details.querySelector(".player-container")) {
34416            createPlayer(details);
34417          } else if (!details.open) {
34418            const provider = details.querySelector("youtube-media-provider");
34419            if (provider && typeof provider.pause === "function") {
34420              provider.pause();
34421            }
34422          }
34423        });
34424      });
34425    }
34426
34427    if (document.readyState === "loading") {
34428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
34429    } else {
34430      initLazyPlayers();
34431    }
34432  })();
34433</script>
34433<script>
34434  (() => {
34435    if (window.__courseCompletionsInitialized) return;
34436    window.__courseCompletionsInitialized = true;
34437
34438    const STORAGE_KEY = "course-completions";
34439
34440    function slugify(text) {
34441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34442    }
34443
34444    function getCompletions() {
34445      try {
34446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
34447      } catch {
34448        return [];
34449      }
34450    }
34451
34452    function saveCompletions(completions) {
34453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
34454    }
34455
34456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
34457    function canonicalFromStored(storedId) {
34458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34459      if (!m) return slugify(storedId);
34460      const [, slug, p, mv, title] = m;
34461      return `${slug}-${p}-${mv}-${slugify(title)}`;
34462    }
34463
34464    function canonicalPlusOne(storedId) {
34465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34466      if (!m) return null;
34467      const [, slug, p, mv, title] = m;
34468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
34469    }
34470
34471    function isCompleted(canonicalId, completions) {
34472      if (completions.includes(canonicalId)) return true;
34473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
34474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
34475    }
34476
34477    function migrateCompletions() {
34478      const completions = getCompletions();
34479      let changed = false;
34480      const migrated = completions.map((c) => {
34481        const canon = canonicalFromStored(c);
34482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
34483        const plusOne = canonicalPlusOne(c);
34484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
34485          if (plusOne !== c) changed = true;
34486          return plusOne;
34487        }
34488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
34489          changed = true;
34490          return canon;
34491        }
34492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
34493        if (canon !== c) {
34494          changed = true;
34495          return canon;
34496        }
34497        return c;
34498      });
34499      // Elimina duplicados tras migración
34500      const deduped = [...new Set(migrated)];
34501      if (changed || deduped.length !== completions.length) {
34502        saveCompletions(deduped);
34503        return deduped;
34504      }
34505      return completions;
34506    }
34507
34508    function updateButtonState(btn, completions) {
34509      const id = btn.dataset.completionId;
34510      const list = completions ?? getCompletions();
34511      btn.classList.toggle("completed", isCompleted(id, list));
34512    }
34513
34514    function checkModuleCompletion(completions) {
34515      const list = completions ?? getCompletions();
34516      document.querySelectorAll(".course-video").forEach((module) => {
34517        const btns = module.querySelectorAll(".complete-btn");
34518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
34519        const check = module.querySelector(".module-check");
34520        if (check) check.classList.toggle("visible", allCompleted);
34521      });
34522    }
34523
34524    function initCompletionButtons() {
34525      const completions = migrateCompletions();
34526
34527      document.querySelectorAll(".complete-btn").forEach((btn) => {
34528        if (btn.dataset.completionInit) return;
34529        btn.dataset.completionInit = "true";
34530        updateButtonState(btn, completions);
34531
34532        btn.addEventListener("click", (e) => {
34533          e.preventDefault();
34534          e.stopPropagation();
34535
34536          const id = btn.dataset.completionId;
34537          let list = getCompletions();
34538          const completed = isCompleted(id, list);
34539
34540          if (completed) {
34541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
34542            list = list.filter((c) =>
34542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
34543          } else {
34544            list.push(id);
34545          }
34546
34547          saveCompletions(list);
34548          updateButtonState(btn, list);
34549          checkModuleCompletion(list);
34550        });
34551      });
34552
34553      checkModuleCompletion(completions);
34554    }
34555
34556    if (document.readyState === "loading") {
34557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
34558    } else {
34559      initCompletionButtons();
34560    }
34561  })();
34562</script>
34562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Awbco0mMbmM" data-start="5225" data-end="9220" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Información de Eventos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>1 hr 7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-3-informacion-de-eventos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
34562<script>
34563  (() => {
34564    if (window.__courseLazyPlayersInitialized) return;
34565    window.__courseLazyPlayersInitialized = true;
34566
34567    function createPlayer(details) {
34568      const videoId = details.dataset.videoId;
34569      const start = details.dataset.start;
34570      const end = details.dataset.end;
34571
34572      const container = document.createElement("div");
34573      container.className = "player-container";
34574
34575      const spinner = document.createElement("div");
34576      spinner.className = "spinner";
34577      container.appendChild(spinner);
34578
34579      const controller = document.createElement("media-controller");
34580      controller.setAttribute("defaultsubtitles", "");
34581
34582      const provider = document.createElement("youtube-media-provider");
34583      provider.setAttribute("slot", "media");
34584      provider.setAttribute("video-id", videoId);
34585      provider.setAttribute("start", start);
34586      provider.setAttribute("end", end);
34587
34588      const controlBar = document.createElement("media-control-bar");
34589
34590      const playBtn = document.createElement("media-play-button");
34591      const timeDisplay = document.createElement("media-time-display");
34592      timeDisplay.setAttribute("showduration", "");
34593      const timeRange = document.createElement("media-time-range");
34594      const playbackRate = document.createElement("media-playback-rate-button");
34595      const muteBtn = document.createElement("media-mute-button");
34596      const volumeRange = document.createElement("media-volume-range");
34597
34598      const captionsBtn = document.createElement("media-captions-button");
34599      captionsBtn.addEventListener("click", (e) => {
34600        e.stopPropagation();
34601        const provider = container.querySelector("youtube-media-provider");
34602        if (provider) provider.toggleCaptions();
34603      });
34604
34605      const qualityBtn = document.createElement("media-chrome-button");
34606      qualityBtn.textContent = "--";
34607      qualityBtn.addEventListener("click", () => {
34608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
34609      });
34610
34611      const fullscreenBtn = document.createElement("media-fullscreen-button");
34612
34613      controlBar.append(
34614        playBtn, timeDisplay, timeRange,
34615        playbackRate, muteBtn, volumeRange,
34616        captionsBtn, qualityBtn, fullscreenBtn
34617      );
34618
34619      const poster = document.createElement("media-poster-image");
34620      poster.setAttribute("slot", "poster");
34621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
34622
34623      controller.append(provider, poster, controlBar);
34624
34625      captionsBtn.setAttribute("aria-checked", "true");
34626      controller.setAttribute("mediasubtitlesshowing", "on");
34627
34628      container.appendChild(controller);
34629      details.appendChild(container);
34630
34631      provider.addEventListener("waiting", () => container.classList.add("loading"));
34632      provider.addEventListener("playing", () => container.classList.remove("loading"));
34633      provider.addEventListener("play", () => container.classList.remove("loading"));
34634
34635      provider._textTracks.addEventListener("change", () => {
34636        const enabled = provider._captionsEnabled;
34637        captionsBtn.setAttribute("aria-checked", enabled);
34638        if (enabled) {
34639          controller.setAttribute("mediasubtitlesshowing", "on");
34640        } else {
34641          controller.removeAttribute("mediasubtitlesshowing");
34642        }
34643      });
34644
34645      provider.addEventListener("qualitychange", (e) => {
34646        qualityBtn.textContent = e.detail.quality;
34647      });
34648    }
34649
34650    function initLazyPlayers() {
34651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
34652        if (details.dataset.lazyInit) return;
34653        details.dataset.lazyInit = "true";
34654        details.addEventListener("toggle", () => {
34655          if (details.open && !details.querySelector(".player-container")) {
34656            createPlayer(details);
34657          } else if (!details.open) {
34658            const provider = details.querySelector("youtube-media-provider");
34659            if (provider && typeof provider.pause === "function") {
34660              provider.pause();
34661            }
34662          }
34663        });
34664      });
34665    }
34666
34667    if (document.readyState === "loading") {
34668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
34669    } else {
34670      initLazyPlayers();
34671    }
34672  })();
34673</script>
34673<script>
34674  (() => {
34675    if (window.__courseCompletionsInitialized) return;
34676    window.__courseCompletionsInitialized = true;
34677
34678    const STORAGE_KEY = "course-completions";
34679
34680    function slugify(text) {
34681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34682    }
34683
34684    function getCompletions() {
34685      try {
34686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
34687      } catch {
34688        return [];
34689      }
34690    }
34691
34692    function saveCompletions(completions) {
34693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
34694    }
34695
34696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
34697    function canonicalFromStored(storedId) {
34698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34699      if (!m) return slugify(storedId);
34700      const [, slug, p, mv, title] = m;
34701      return `${slug}-${p}-${mv}-${slugify(title)}`;
34702    }
34703
34704    function canonicalPlusOne(storedId) {
34705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34706      if (!m) return null;
34707      const [, slug, p, mv, title] = m;
34708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
34709    }
34710
34711    function isCompleted(canonicalId, completions) {
34712      if (completions.includes(canonicalId)) return true;
34713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
34714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
34715    }
34716
34717    function migrateCompletions() {
34718      const completions = getCompletions();
34719      let changed = false;
34720      const migrated = completions.map((c) => {
34721        const canon = canonicalFromStored(c);
34722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
34723        const plusOne = canonicalPlusOne(c);
34724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
34725          if (plusOne !== c) changed = true;
34726          return plusOne;
34727        }
34728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
34729          changed = true;
34730          return canon;
34731        }
34732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
34733        if (canon !== c) {
34734          changed = true;
34735          return canon;
34736        }
34737        return c;
34738      });
34739      // Elimina duplicados tras migración
34740      const deduped = [...new Set(migrated)];
34741      if (changed || deduped.length !== completions.length) {
34742        saveCompletions(deduped);
34743        return deduped;
34744      }
34745      return completions;
34746    }
34747
34748    function updateButtonState(btn, completions) {
34749      const id = btn.dataset.completionId;
34750      const list = completions ?? getCompletions();
34751      btn.classList.toggle("completed", isCompleted(id, list));
34752    }
34753
34754    function checkModuleCompletion(completions) {
34755      const list = completions ?? getCompletions();
34756      document.querySelectorAll(".course-video").forEach((module) => {
34757        const btns = module.querySelectorAll(".complete-btn");
34758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
34759        const check = module.querySelector(".module-check");
34760        if (check) check.classList.toggle("visible", allCompleted);
34761      });
34762    }
34763
34764    function initCompletionButtons() {
34765      const completions = migrateCompletions();
34766
34767      document.querySelectorAll(".complete-btn").forEach((btn) => {
34768        if (btn.dataset.completionInit) return;
34769        btn.dataset.completionInit = "true";
34770        updateButtonState(btn, completions);
34771
34772        btn.addEventListener("click", (e) => {
34773          e.preventDefault();
34774          e.stopPropagation();
34775
34776          const id = btn.dataset.completionId;
34777          let list = getCompletions();
34778          const completed = isCompleted(id, list);
34779
34780          if (completed) {
34781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
34782            list = list.filter((c) =>
34782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
34783          } else {
34784            list.push(id);
34785          }
34786
34787          saveCompletions(list);
34788          updateButtonState(btn, list);
34789          checkModuleCompletion(list);
34790        });
34791      });
34792
34793      checkModuleCompletion(completions);
34794    }
34795
34796    if (document.readyState === "loading") {
34797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
34798    } else {
34799      initCompletionButtons();
34800    }
34801  })();
34802</script>
34802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Awbco0mMbmM" data-start="9220" data-end="12000" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Propagación de eventos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>46 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-3-propagacion-de-eventos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
34802<script>
34803  (() => {
34804    if (window.__courseLazyPlayersInitialized) return;
34805    window.__courseLazyPlayersInitialized = true;
34806
34807    function createPlayer(details) {
34808      const videoId = details.dataset.videoId;
34809      const start = details.dataset.start;
34810      const end = details.dataset.end;
34811
34812      const container = document.createElement("div");
34813      container.className = "player-container";
34814
34815      const spinner = document.createElement("div");
34816      spinner.className = "spinner";
34817      container.appendChild(spinner);
34818
34819      const controller = document.createElement("media-controller");
34820      controller.setAttribute("defaultsubtitles", "");
34821
34822      const provider = document.createElement("youtube-media-provider");
34823      provider.setAttribute("slot", "media");
34824      provider.setAttribute("video-id", videoId);
34825      provider.setAttribute("start", start);
34826      provider.setAttribute("end", end);
34827
34828      const controlBar = document.createElement("media-control-bar");
34829
34830      const playBtn = document.createElement("media-play-button");
34831      const timeDisplay = document.createElement("media-time-display");
34832      timeDisplay.setAttribute("showduration", "");
34833      const timeRange = document.createElement("media-time-range");
34834      const playbackRate = document.createElement("media-playback-rate-button");
34835      const muteBtn = document.createElement("media-mute-button");
34836      const volumeRange = document.createElement("media-volume-range");
34837
34838      const captionsBtn = document.createElement("media-captions-button");
34839      captionsBtn.addEventListener("click", (e) => {
34840        e.stopPropagation();
34841        const provider = container.querySelector("youtube-media-provider");
34842        if (provider) provider.toggleCaptions();
34843      });
34844
34845      const qualityBtn = document.createElement("media-chrome-button");
34846      qualityBtn.textContent = "--";
34847      qualityBtn.addEventListener("click", () => {
34848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
34849      });
34850
34851      const fullscreenBtn = document.createElement("media-fullscreen-button");
34852
34853      controlBar.append(
34854        playBtn, timeDisplay, timeRange,
34855        playbackRate, muteBtn, volumeRange,
34856        captionsBtn, qualityBtn, fullscreenBtn
34857      );
34858
34859      const poster = document.createElement("media-poster-image");
34860      poster.setAttribute("slot", "poster");
34861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
34862
34863      controller.append(provider, poster, controlBar);
34864
34865      captionsBtn.setAttribute("aria-checked", "true");
34866      controller.setAttribute("mediasubtitlesshowing", "on");
34867
34868      container.appendChild(controller);
34869      details.appendChild(container);
34870
34871      provider.addEventListener("waiting", () => container.classList.add("loading"));
34872      provider.addEventListener("playing", () => container.classList.remove("loading"));
34873      provider.addEventListener("play", () => container.classList.remove("loading"));
34874
34875      provider._textTracks.addEventListener("change", () => {
34876        const enabled = provider._captionsEnabled;
34877        captionsBtn.setAttribute("aria-checked", enabled);
34878        if (enabled) {
34879          controller.setAttribute("mediasubtitlesshowing", "on");
34880        } else {
34881          controller.removeAttribute("mediasubtitlesshowing");
34882        }
34883      });
34884
34885      provider.addEventListener("qualitychange", (e) => {
34886        qualityBtn.textContent = e.detail.quality;
34887      });
34888    }
34889
34890    function initLazyPlayers() {
34891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
34892        if (details.dataset.lazyInit) return;
34893        details.dataset.lazyInit = "true";
34894        details.addEventListener("toggle", () => {
34895          if (details.open && !details.querySelector(".player-container")) {
34896            createPlayer(details);
34897          } else if (!details.open) {
34898            const provider = details.querySelector("youtube-media-provider");
34899            if (provider && typeof provider.pause === "function") {
34900              provider.pause();
34901            }
34902          }
34903        });
34904      });
34905    }
34906
34907    if (document.readyState === "loading") {
34908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
34909    } else {
34910      initLazyPlayers();
34911    }
34912  })();
34913</script>
34913<script>
34914  (() => {
34915    if (window.__courseCompletionsInitialized) return;
34916    window.__courseCompletionsInitialized = true;
34917
34918    const STORAGE_KEY = "course-completions";
34919
34920    function slugify(text) {
34921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34922    }
34923
34924    function getCompletions() {
34925      try {
34926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
34927      } catch {
34928        return [];
34929      }
34930    }
34931
34932    function saveCompletions(completions) {
34933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
34934    }
34935
34936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
34937    function canonicalFromStored(storedId) {
34938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34939      if (!m) return slugify(storedId);
34940      const [, slug, p, mv, title] = m;
34941      return `${slug}-${p}-${mv}-${slugify(title)}`;
34942    }
34943
34944    function canonicalPlusOne(storedId) {
34945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
34946      if (!m) return null;
34947      const [, slug, p, mv, title] = m;
34948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
34949    }
34950
34951    function isCompleted(canonicalId, completions) {
34952      if (completions.includes(canonicalId)) return true;
34953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
34954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
34955    }
34956
34957    function migrateCompletions() {
34958      const completions = getCompletions();
34959      let changed = false;
34960      const migrated = completions.map((c) => {
34961        const canon = canonicalFromStored(c);
34962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
34963        const plusOne = canonicalPlusOne(c);
34964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
34965          if (plusOne !== c) changed = true;
34966          return plusOne;
34967        }
34968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
34969          changed = true;
34970          return canon;
34971        }
34972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
34973        if (canon !== c) {
34974          changed = true;
34975          return canon;
34976        }
34977        return c;
34978      });
34979      // Elimina duplicados tras migración
34980      const deduped = [...new Set(migrated)];
34981      if (changed || deduped.length !== completions.length) {
34982        saveCompletions(deduped);
34983        return deduped;
34984      }
34985      return completions;
34986    }
34987
34988    function updateButtonState(btn, completions) {
34989      const id = btn.dataset.completionId;
34990      const list = completions ?? getCompletions();
34991      btn.classList.toggle("completed", isCompleted(id, list));
34992    }
34993
34994    function checkModuleCompletion(completions) {
34995      const list = completions ?? getCompletions();
34996      document.querySelectorAll(".course-video").forEach((module) => {
34997        const btns = module.querySelectorAll(".complete-btn");
34998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
34999        const check = module.querySelector(".module-check");
35000        if (check) check.classList.toggle("visible", allCompleted);
35001      });
35002    }
35003
35004    function initCompletionButtons() {
35005      const completions = migrateCompletions();
35006
35007      document.querySelectorAll(".complete-btn").forEach((btn) => {
35008        if (btn.dataset.completionInit) return;
35009        btn.dataset.completionInit = "true";
35010        updateButtonState(btn, completions);
35011
35012        btn.addEventListener("click", (e) => {
35013          e.preventDefault();
35014          e.stopPropagation();
35015
35016          const id = btn.dataset.completionId;
35017          let list = getCompletions();
35018          const completed = isCompleted(id, list);
35019
35020          if (completed) {
35021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
35022            list = list.filter((c) =>
35022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
35023          } else {
35024            list.push(id);
35025          }
35026
35027          saveCompletions(list);
35028          updateButtonState(btn, list);
35029          checkModuleCompletion(list);
35030        });
35031      });
35032
35033      checkModuleCompletion(completions);
35034    }
35035
35036    if (document.readyState === "loading") {
35037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
35038    } else {
35039      initCompletionButtons();
35040    }
35041  })();
35042</script>
35042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Awbco0mMbmM" data-start="12000" data-end="13680" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. APIs modernas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-3-apis-modernas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
35042<script>
35043  (() => {
35044    if (window.__courseLazyPlayersInitialized) return;
35045    window.__courseLazyPlayersInitialized = true;
35046
35047    function createPlayer(details) {
35048      const videoId = details.dataset.videoId;
35049      const start = details.dataset.start;
35050      const end = details.dataset.end;
35051
35052      const container = document.createElement("div");
35053      container.className = "player-container";
35054
35055      const spinner = document.createElement("div");
35056      spinner.className = "spinner";
35057      container.appendChild(spinner);
35058
35059      const controller = document.createElement("media-controller");
35060      controller.setAttribute("defaultsubtitles", "");
35061
35062      const provider = document.createElement("youtube-media-provider");
35063      provider.setAttribute("slot", "media");
35064      provider.setAttribute("video-id", videoId);
35065      provider.setAttribute("start", start);
35066      provider.setAttribute("end", end);
35067
35068      const controlBar = document.createElement("media-control-bar");
35069
35070      const playBtn = document.createElement("media-play-button");
35071      const timeDisplay = document.createElement("media-time-display");
35072      timeDisplay.setAttribute("showduration", "");
35073      const timeRange = document.createElement("media-time-range");
35074      const playbackRate = document.createElement("media-playback-rate-button");
35075      const muteBtn = document.createElement("media-mute-button");
35076      const volumeRange = document.createElement("media-volume-range");
35077
35078      const captionsBtn = document.createElement("media-captions-button");
35079      captionsBtn.addEventListener("click", (e) => {
35080        e.stopPropagation();
35081        const provider = container.querySelector("youtube-media-provider");
35082        if (provider) provider.toggleCaptions();
35083      });
35084
35085      const qualityBtn = document.createElement("media-chrome-button");
35086      qualityBtn.textContent = "--";
35087      qualityBtn.addEventListener("click", () => {
35088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
35089      });
35090
35091      const fullscreenBtn = document.createElement("media-fullscreen-button");
35092
35093      controlBar.append(
35094        playBtn, timeDisplay, timeRange,
35095        playbackRate, muteBtn, volumeRange,
35096        captionsBtn, qualityBtn, fullscreenBtn
35097      );
35098
35099      const poster = document.createElement("media-poster-image");
35100      poster.setAttribute("slot", "poster");
35101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
35102
35103      controller.append(provider, poster, controlBar);
35104
35105      captionsBtn.setAttribute("aria-checked", "true");
35106      controller.setAttribute("mediasubtitlesshowing", "on");
35107
35108      container.appendChild(controller);
35109      details.appendChild(container);
35110
35111      provider.addEventListener("waiting", () => container.classList.add("loading"));
35112      provider.addEventListener("playing", () => container.classList.remove("loading"));
35113      provider.addEventListener("play", () => container.classList.remove("loading"));
35114
35115      provider._textTracks.addEventListener("change", () => {
35116        const enabled = provider._captionsEnabled;
35117        captionsBtn.setAttribute("aria-checked", enabled);
35118        if (enabled) {
35119          controller.setAttribute("mediasubtitlesshowing", "on");
35120        } else {
35121          controller.removeAttribute("mediasubtitlesshowing");
35122        }
35123      });
35124
35125      provider.addEventListener("qualitychange", (e) => {
35126        qualityBtn.textContent = e.detail.quality;
35127      });
35128    }
35129
35130    function initLazyPlayers() {
35131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
35132        if (details.dataset.lazyInit) return;
35133        details.dataset.lazyInit = "true";
35134        details.addEventListener("toggle", () => {
35135          if (details.open && !details.querySelector(".player-container")) {
35136            createPlayer(details);
35137          } else if (!details.open) {
35138            const provider = details.querySelector("youtube-media-provider");
35139            if (provider && typeof provider.pause === "function") {
35140              provider.pause();
35141            }
35142          }
35143        });
35144      });
35145    }
35146
35147    if (document.readyState === "loading") {
35148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
35149    } else {
35150      initLazyPlayers();
35151    }
35152  })();
35153</script>
35153<script>
35154  (() => {
35155    if (window.__courseCompletionsInitialized) return;
35156    window.__courseCompletionsInitialized = true;
35157
35158    const STORAGE_KEY = "course-completions";
35159
35160    function slugify(text) {
35161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
35162    }
35163
35164    function getCompletions() {
35165      try {
35166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
35167      } catch {
35168        return [];
35169      }
35170    }
35171
35172    function saveCompletions(completions) {
35173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
35174    }
35175
35176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
35177    function canonicalFromStored(storedId) {
35178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35179      if (!m) return slugify(storedId);
35180      const [, slug, p, mv, title] = m;
35181      return `${slug}-${p}-${mv}-${slugify(title)}`;
35182    }
35183
35184    function canonicalPlusOne(storedId) {
35185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35186      if (!m) return null;
35187      const [, slug, p, mv, title] = m;
35188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
35189    }
35190
35191    function isCompleted(canonicalId, completions) {
35192      if (completions.includes(canonicalId)) return true;
35193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
35194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
35195    }
35196
35197    function migrateCompletions() {
35198      const completions = getCompletions();
35199      let changed = false;
35200      const migrated = completions.map((c) => {
35201        const canon = canonicalFromStored(c);
35202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
35203        const plusOne = canonicalPlusOne(c);
35204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
35205          if (plusOne !== c) changed = true;
35206          return plusOne;
35207        }
35208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
35209          changed = true;
35210          return canon;
35211        }
35212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
35213        if (canon !== c) {
35214          changed = true;
35215          return canon;
35216        }
35217        return c;
35218      });
35219      // Elimina duplicados tras migración
35220      const deduped = [...new Set(migrated)];
35221      if (changed || deduped.length !== completions.length) {
35222        saveCompletions(deduped);
35223        return deduped;
35224      }
35225      return completions;
35226    }
35227
35228    function updateButtonState(btn, completions) {
35229      const id = btn.dataset.completionId;
35230      const list = completions ?? getCompletions();
35231      btn.classList.toggle("completed", isCompleted(id, list));
35232    }
35233
35234    function checkModuleCompletion(completions) {
35235      const list = completions ?? getCompletions();
35236      document.querySelectorAll(".course-video").forEach((module) => {
35237        const btns = module.querySelectorAll(".complete-btn");
35238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
35239        const check = module.querySelector(".module-check");
35240        if (check) check.classList.toggle("visible", allCompleted);
35241      });
35242    }
35243
35244    function initCompletionButtons() {
35245      const completions = migrateCompletions();
35246
35247      document.querySelectorAll(".complete-btn").forEach((btn) => {
35248        if (btn.dataset.completionInit) return;
35249        btn.dataset.completionInit = "true";
35250        updateButtonState(btn, completions);
35251
35252        btn.addEventListener("click", (e) => {
35253          e.preventDefault();
35254          e.stopPropagation();
35255
35256          const id = btn.dataset.completionId;
35257          let list = getCompletions();
35258          const completed = isCompleted(id, list);
35259
35260          if (completed) {
35261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
35262            list = list.filter((c) =>
35262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
35263          } else {
35264            list.push(id);
35265          }
35266
35267          saveCompletions(list);
35268          updateButtonState(btn, list);
35269          checkModuleCompletion(list);
35270        });
35271      });
35272
35273      checkModuleCompletion(completions);
35274    }
35275
35276    if (document.readyState === "loading") {
35277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
35278    } else {
35279      initCompletionButtons();
35280    }
35281  })();
35282</script>
35282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="Awbco0mMbmM" data-start="13680" data-end="16287" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Expresiones regulares</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>43 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-3-expresiones-regulares" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
35282<script>
35283  (() => {
35284    if (window.__courseLazyPlayersInitialized) return;
35285    window.__courseLazyPlayersInitialized = true;
35286
35287    function createPlayer(details) {
35288      const videoId = details.dataset.videoId;
35289      const start = details.dataset.start;
35290      const end = details.dataset.end;
35291
35292      const container = document.createElement("div");
35293      container.className = "player-container";
35294
35295      const spinner = document.createElement("div");
35296      spinner.className = "spinner";
35297      container.appendChild(spinner);
35298
35299      const controller = document.createElement("media-controller");
35300      controller.setAttribute("defaultsubtitles", "");
35301
35302      const provider = document.createElement("youtube-media-provider");
35303      provider.setAttribute("slot", "media");
35304      provider.setAttribute("video-id", videoId);
35305      provider.setAttribute("start", start);
35306      provider.setAttribute("end", end);
35307
35308      const controlBar = document.createElement("media-control-bar");
35309
35310      const playBtn = document.createElement("media-play-button");
35311      const timeDisplay = document.createElement("media-time-display");
35312      timeDisplay.setAttribute("showduration", "");
35313      const timeRange = document.createElement("media-time-range");
35314      const playbackRate = document.createElement("media-playback-rate-button");
35315      const muteBtn = document.createElement("media-mute-button");
35316      const volumeRange = document.createElement("media-volume-range");
35317
35318      const captionsBtn = document.createElement("media-captions-button");
35319      captionsBtn.addEventListener("click", (e) => {
35320        e.stopPropagation();
35321        const provider = container.querySelector("youtube-media-provider");
35322        if (provider) provider.toggleCaptions();
35323      });
35324
35325      const qualityBtn = document.createElement("media-chrome-button");
35326      qualityBtn.textContent = "--";
35327      qualityBtn.addEventListener("click", () => {
35328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
35329      });
35330
35331      const fullscreenBtn = document.createElement("media-fullscreen-button");
35332
35333      controlBar.append(
35334        playBtn, timeDisplay, timeRange,
35335        playbackRate, muteBtn, volumeRange,
35336        captionsBtn, qualityBtn, fullscreenBtn
35337      );
35338
35339      const poster = document.createElement("media-poster-image");
35340      poster.setAttribute("slot", "poster");
35341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
35342
35343      controller.append(provider, poster, controlBar);
35344
35345      captionsBtn.setAttribute("aria-checked", "true");
35346      controller.setAttribute("mediasubtitlesshowing", "on");
35347
35348      container.appendChild(controller);
35349      details.appendChild(container);
35350
35351      provider.addEventListener("waiting", () => container.classList.add("loading"));
35352      provider.addEventListener("playing", () => container.classList.remove("loading"));
35353      provider.addEventListener("play", () => container.classList.remove("loading"));
35354
35355      provider._textTracks.addEventListener("change", () => {
35356        const enabled = provider._captionsEnabled;
35357        captionsBtn.setAttribute("aria-checked", enabled);
35358        if (enabled) {
35359          controller.setAttribute("mediasubtitlesshowing", "on");
35360        } else {
35361          controller.removeAttribute("mediasubtitlesshowing");
35362        }
35363      });
35364
35365      provider.addEventListener("qualitychange", (e) => {
35366        qualityBtn.textContent = e.detail.quality;
35367      });
35368    }
35369
35370    function initLazyPlayers() {
35371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
35372        if (details.dataset.lazyInit) return;
35373        details.dataset.lazyInit = "true";
35374        details.addEventListener("toggle", () => {
35375          if (details.open && !details.querySelector(".player-container")) {
35376            createPlayer(details);
35377          } else if (!details.open) {
35378            const provider = details.querySelector("youtube-media-provider");
35379            if (provider && typeof provider.pause === "function") {
35380              provider.pause();
35381            }
35382          }
35383        });
35384      });
35385    }
35386
35387    if (document.readyState === "loading") {
35388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
35389    } else {
35390      initLazyPlayers();
35391    }
35392  })();
35393</script>
35393<script>
35394  (() => {
35395    if (window.__courseCompletionsInitialized) return;
35396    window.__courseCompletionsInitialized = true;
35397
35398    const STORAGE_KEY = "course-completions";
35399
35400    function slugify(text) {
35401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
35402    }
35403
35404    function getCompletions() {
35405      try {
35406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
35407      } catch {
35408        return [];
35409      }
35410    }
35411
35412    function saveCompletions(completions) {
35413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
35414    }
35415
35416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
35417    function canonicalFromStored(storedId) {
35418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35419      if (!m) return slugify(storedId);
35420      const [, slug, p, mv, title] = m;
35421      return `${slug}-${p}-${mv}-${slugify(title)}`;
35422    }
35423
35424    function canonicalPlusOne(storedId) {
35425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35426      if (!m) return null;
35427      const [, slug, p, mv, title] = m;
35428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
35429    }
35430
35431    function isCompleted(canonicalId, completions) {
35432      if (completions.includes(canonicalId)) return true;
35433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
35434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
35435    }
35436
35437    function migrateCompletions() {
35438      const completions = getCompletions();
35439      let changed = false;
35440      const migrated = completions.map((c) => {
35441        const canon = canonicalFromStored(c);
35442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
35443        const plusOne = canonicalPlusOne(c);
35444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
35445          if (plusOne !== c) changed = true;
35446          return plusOne;
35447        }
35448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
35449          changed = true;
35450          return canon;
35451        }
35452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
35453        if (canon !== c) {
35454          changed = true;
35455          return canon;
35456        }
35457        return c;
35458      });
35459      // Elimina duplicados tras migración
35460      const deduped = [...new Set(migrated)];
35461      if (changed || deduped.length !== completions.length) {
35462        saveCompletions(deduped);
35463        return deduped;
35464      }
35465      return completions;
35466    }
35467
35468    function updateButtonState(btn, completions) {
35469      const id = btn.dataset.completionId;
35470      const list = completions ?? getCompletions();
35471      btn.classList.toggle("completed", isCompleted(id, list));
35472    }
35473
35474    function checkModuleCompletion(completions) {
35475      const list = completions ?? getCompletions();
35476      document.querySelectorAll(".course-video").forEach((module) => {
35477        const btns = module.querySelectorAll(".complete-btn");
35478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
35479        const check = module.querySelector(".module-check");
35480        if (check) check.classList.toggle("visible", allCompleted);
35481      });
35482    }
35483
35484    function initCompletionButtons() {
35485      const completions = migrateCompletions();
35486
35487      document.querySelectorAll(".complete-btn").forEach((btn) => {
35488        if (btn.dataset.completionInit) return;
35489        btn.dataset.completionInit = "true";
35490        updateButtonState(btn, completions);
35491
35492        btn.addEventListener("click", (e) => {
35493          e.preventDefault();
35494          e.stopPropagation();
35495
35496          const id = btn.dataset.completionId;
35497          let list = getCompletions();
35498          const completed = isCompleted(id, list);
35499
35500          if (completed) {
35501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
35502            list = list.filter((c) =>
35502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
35503          } else {
35504            list.push(id);
35505          }
35506
35507          saveCompletions(list);
35508          updateButtonState(btn, list);
35509          checkModuleCompletion(list);
35510        });
35511      });
35512
35513      checkModuleCompletion(completions);
35514    }
35515
35516    if (document.readyState === "loading") {
35517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
35518    } else {
35519      initCompletionButtons();
35520    }
35521  })();
35522</script>
35522</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 12 📒 Javascript Avanzado<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="1700" data-end="5360" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Vite</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>1 hr 1 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-vite" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
35522<script>
35523  (() => {
35524    if (window.__courseLazyPlayersInitialized) return;
35525    window.__courseLazyPlayersInitialized = true;
35526
35527    function createPlayer(details) {
35528      const videoId = details.dataset.videoId;
35529      const start = details.dataset.start;
35530      const end = details.dataset.end;
35531
35532      const container = document.createElement("div");
35533      container.className = "player-container";
35534
35535      const spinner = document.createElement("div");
35536      spinner.className = "spinner";
35537      container.appendChild(spinner);
35538
35539      const controller = document.createElement("media-controller");
35540      controller.setAttribute("defaultsubtitles", "");
35541
35542      const provider = document.createElement("youtube-media-provider");
35543      provider.setAttribute("slot", "media");
35544      provider.setAttribute("video-id", videoId);
35545      provider.setAttribute("start", start);
35546      provider.setAttribute("end", end);
35547
35548      const controlBar = document.createElement("media-control-bar");
35549
35550      const playBtn = document.createElement("media-play-button");
35551      const timeDisplay = document.createElement("media-time-display");
35552      timeDisplay.setAttribute("showduration", "");
35553      const timeRange = document.createElement("media-time-range");
35554      const playbackRate = document.createElement("media-playback-rate-button");
35555      const muteBtn = document.createElement("media-mute-button");
35556      const volumeRange = document.createElement("media-volume-range");
35557
35558      const captionsBtn = document.createElement("media-captions-button");
35559      captionsBtn.addEventListener("click", (e) => {
35560        e.stopPropagation();
35561        const provider = container.querySelector("youtube-media-provider");
35562        if (provider) provider.toggleCaptions();
35563      });
35564
35565      const qualityBtn = document.createElement("media-chrome-button");
35566      qualityBtn.textContent = "--";
35567      qualityBtn.addEventListener("click", () => {
35568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
35569      });
35570
35571      const fullscreenBtn = document.createElement("media-fullscreen-button");
35572
35573      controlBar.append(
35574        playBtn, timeDisplay, timeRange,
35575        playbackRate, muteBtn, volumeRange,
35576        captionsBtn, qualityBtn, fullscreenBtn
35577      );
35578
35579      const poster = document.createElement("media-poster-image");
35580      poster.setAttribute("slot", "poster");
35581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
35582
35583      controller.append(provider, poster, controlBar);
35584
35585      captionsBtn.setAttribute("aria-checked", "true");
35586      controller.setAttribute("mediasubtitlesshowing", "on");
35587
35588      container.appendChild(controller);
35589      details.appendChild(container);
35590
35591      provider.addEventListener("waiting", () => container.classList.add("loading"));
35592      provider.addEventListener("playing", () => container.classList.remove("loading"));
35593      provider.addEventListener("play", () => container.classList.remove("loading"));
35594
35595      provider._textTracks.addEventListener("change", () => {
35596        const enabled = provider._captionsEnabled;
35597        captionsBtn.setAttribute("aria-checked", enabled);
35598        if (enabled) {
35599          controller.setAttribute("mediasubtitlesshowing", "on");
35600        } else {
35601          controller.removeAttribute("mediasubtitlesshowing");
35602        }
35603      });
35604
35605      provider.addEventListener("qualitychange", (e) => {
35606        qualityBtn.textContent = e.detail.quality;
35607      });
35608    }
35609
35610    function initLazyPlayers() {
35611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
35612        if (details.dataset.lazyInit) return;
35613        details.dataset.lazyInit = "true";
35614        details.addEventListener("toggle", () => {
35615          if (details.open && !details.querySelector(".player-container")) {
35616            createPlayer(details);
35617          } else if (!details.open) {
35618            const provider = details.querySelector("youtube-media-provider");
35619            if (provider && typeof provider.pause === "function") {
35620              provider.pause();
35621            }
35622          }
35623        });
35624      });
35625    }
35626
35627    if (document.readyState === "loading") {
35628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
35629    } else {
35630      initLazyPlayers();
35631    }
35632  })();
35633</script>
35633<script>
35634  (() => {
35635    if (window.__courseCompletionsInitialized) return;
35636    window.__courseCompletionsInitialized = true;
35637
35638    const STORAGE_KEY = "course-completions";
35639
35640    function slugify(text) {
35641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
35642    }
35643
35644    function getCompletions() {
35645      try {
35646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
35647      } catch {
35648        return [];
35649      }
35650    }
35651
35652    function saveCompletions(completions) {
35653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
35654    }
35655
35656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
35657    function canonicalFromStored(storedId) {
35658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35659      if (!m) return slugify(storedId);
35660      const [, slug, p, mv, title] = m;
35661      return `${slug}-${p}-${mv}-${slugify(title)}`;
35662    }
35663
35664    function canonicalPlusOne(storedId) {
35665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35666      if (!m) return null;
35667      const [, slug, p, mv, title] = m;
35668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
35669    }
35670
35671    function isCompleted(canonicalId, completions) {
35672      if (completions.includes(canonicalId)) return true;
35673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
35674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
35675    }
35676
35677    function migrateCompletions() {
35678      const completions = getCompletions();
35679      let changed = false;
35680      const migrated = completions.map((c) => {
35681        const canon = canonicalFromStored(c);
35682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
35683        const plusOne = canonicalPlusOne(c);
35684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
35685          if (plusOne !== c) changed = true;
35686          return plusOne;
35687        }
35688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
35689          changed = true;
35690          return canon;
35691        }
35692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
35693        if (canon !== c) {
35694          changed = true;
35695          return canon;
35696        }
35697        return c;
35698      });
35699      // Elimina duplicados tras migración
35700      const deduped = [...new Set(migrated)];
35701      if (changed || deduped.length !== completions.length) {
35702        saveCompletions(deduped);
35703        return deduped;
35704      }
35705      return completions;
35706    }
35707
35708    function updateButtonState(btn, completions) {
35709      const id = btn.dataset.completionId;
35710      const list = completions ?? getCompletions();
35711      btn.classList.toggle("completed", isCompleted(id, list));
35712    }
35713
35714    function checkModuleCompletion(completions) {
35715      const list = completions ?? getCompletions();
35716      document.querySelectorAll(".course-video").forEach((module) => {
35717        const btns = module.querySelectorAll(".complete-btn");
35718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
35719        const check = module.querySelector(".module-check");
35720        if (check) check.classList.toggle("visible", allCompleted);
35721      });
35722    }
35723
35724    function initCompletionButtons() {
35725      const completions = migrateCompletions();
35726
35727      document.querySelectorAll(".complete-btn").forEach((btn) => {
35728        if (btn.dataset.completionInit) return;
35729        btn.dataset.completionInit = "true";
35730        updateButtonState(btn, completions);
35731
35732        btn.addEventListener("click", (e) => {
35733          e.preventDefault();
35734          e.stopPropagation();
35735
35736          const id = btn.dataset.completionId;
35737          let list = getCompletions();
35738          const completed = isCompleted(id, list);
35739
35740          if (completed) {
35741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
35742            list = list.filter((c) =>
35742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
35743          } else {
35744            list.push(id);
35745          }
35746
35747          saveCompletions(list);
35748          updateButtonState(btn, list);
35749          checkModuleCompletion(list);
35750        });
35751      });
35752
35753      checkModuleCompletion(completions);
35754    }
35755
35756    if (document.readyState === "loading") {
35757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
35758    } else {
35759      initCompletionButtons();
35760    }
35761  })();
35762</script>
35762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="5360" data-end="8460" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. LightningCSS y plugins</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>52 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-lightningcss-y-plugins" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
35762<script>
35763  (() => {
35764    if (window.__courseLazyPlayersInitialized) return;
35765    window.__courseLazyPlayersInitialized = true;
35766
35767    function createPlayer(details) {
35768      const videoId = details.dataset.videoId;
35769      const start = details.dataset.start;
35770      const end = details.dataset.end;
35771
35772      const container = document.createElement("div");
35773      container.className = "player-container";
35774
35775      const spinner = document.createElement("div");
35776      spinner.className = "spinner";
35777      container.appendChild(spinner);
35778
35779      const controller = document.createElement("media-controller");
35780      controller.setAttribute("defaultsubtitles", "");
35781
35782      const provider = document.createElement("youtube-media-provider");
35783      provider.setAttribute("slot", "media");
35784      provider.setAttribute("video-id", videoId);
35785      provider.setAttribute("start", start);
35786      provider.setAttribute("end", end);
35787
35788      const controlBar = document.createElement("media-control-bar");
35789
35790      const playBtn = document.createElement("media-play-button");
35791      const timeDisplay = document.createElement("media-time-display");
35792      timeDisplay.setAttribute("showduration", "");
35793      const timeRange = document.createElement("media-time-range");
35794      const playbackRate = document.createElement("media-playback-rate-button");
35795      const muteBtn = document.createElement("media-mute-button");
35796      const volumeRange = document.createElement("media-volume-range");
35797
35798      const captionsBtn = document.createElement("media-captions-button");
35799      captionsBtn.addEventListener("click", (e) => {
35800        e.stopPropagation();
35801        const provider = container.querySelector("youtube-media-provider");
35802        if (provider) provider.toggleCaptions();
35803      });
35804
35805      const qualityBtn = document.createElement("media-chrome-button");
35806      qualityBtn.textContent = "--";
35807      qualityBtn.addEventListener("click", () => {
35808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
35809      });
35810
35811      const fullscreenBtn = document.createElement("media-fullscreen-button");
35812
35813      controlBar.append(
35814        playBtn, timeDisplay, timeRange,
35815        playbackRate, muteBtn, volumeRange,
35816        captionsBtn, qualityBtn, fullscreenBtn
35817      );
35818
35819      const poster = document.createElement("media-poster-image");
35820      poster.setAttribute("slot", "poster");
35821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
35822
35823      controller.append(provider, poster, controlBar);
35824
35825      captionsBtn.setAttribute("aria-checked", "true");
35826      controller.setAttribute("mediasubtitlesshowing", "on");
35827
35828      container.appendChild(controller);
35829      details.appendChild(container);
35830
35831      provider.addEventListener("waiting", () => container.classList.add("loading"));
35832      provider.addEventListener("playing", () => container.classList.remove("loading"));
35833      provider.addEventListener("play", () => container.classList.remove("loading"));
35834
35835      provider._textTracks.addEventListener("change", () => {
35836        const enabled = provider._captionsEnabled;
35837        captionsBtn.setAttribute("aria-checked", enabled);
35838        if (enabled) {
35839          controller.setAttribute("mediasubtitlesshowing", "on");
35840        } else {
35841          controller.removeAttribute("mediasubtitlesshowing");
35842        }
35843      });
35844
35845      provider.addEventListener("qualitychange", (e) => {
35846        qualityBtn.textContent = e.detail.quality;
35847      });
35848    }
35849
35850    function initLazyPlayers() {
35851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
35852        if (details.dataset.lazyInit) return;
35853        details.dataset.lazyInit = "true";
35854        details.addEventListener("toggle", () => {
35855          if (details.open && !details.querySelector(".player-container")) {
35856            createPlayer(details);
35857          } else if (!details.open) {
35858            const provider = details.querySelector("youtube-media-provider");
35859            if (provider && typeof provider.pause === "function") {
35860              provider.pause();
35861            }
35862          }
35863        });
35864      });
35865    }
35866
35867    if (document.readyState === "loading") {
35868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
35869    } else {
35870      initLazyPlayers();
35871    }
35872  })();
35873</script>
35873<script>
35874  (() => {
35875    if (window.__courseCompletionsInitialized) return;
35876    window.__courseCompletionsInitialized = true;
35877
35878    const STORAGE_KEY = "course-completions";
35879
35880    function slugify(text) {
35881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
35882    }
35883
35884    function getCompletions() {
35885      try {
35886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
35887      } catch {
35888        return [];
35889      }
35890    }
35891
35892    function saveCompletions(completions) {
35893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
35894    }
35895
35896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
35897    function canonicalFromStored(storedId) {
35898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35899      if (!m) return slugify(storedId);
35900      const [, slug, p, mv, title] = m;
35901      return `${slug}-${p}-${mv}-${slugify(title)}`;
35902    }
35903
35904    function canonicalPlusOne(storedId) {
35905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
35906      if (!m) return null;
35907      const [, slug, p, mv, title] = m;
35908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
35909    }
35910
35911    function isCompleted(canonicalId, completions) {
35912      if (completions.includes(canonicalId)) return true;
35913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
35914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
35915    }
35916
35917    function migrateCompletions() {
35918      const completions = getCompletions();
35919      let changed = false;
35920      const migrated = completions.map((c) => {
35921        const canon = canonicalFromStored(c);
35922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
35923        const plusOne = canonicalPlusOne(c);
35924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
35925          if (plusOne !== c) changed = true;
35926          return plusOne;
35927        }
35928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
35929          changed = true;
35930          return canon;
35931        }
35932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
35933        if (canon !== c) {
35934          changed = true;
35935          return canon;
35936        }
35937        return c;
35938      });
35939      // Elimina duplicados tras migración
35940      const deduped = [...new Set(migrated)];
35941      if (changed || deduped.length !== completions.length) {
35942        saveCompletions(deduped);
35943        return deduped;
35944      }
35945      return completions;
35946    }
35947
35948    function updateButtonState(btn, completions) {
35949      const id = btn.dataset.completionId;
35950      const list = completions ?? getCompletions();
35951      btn.classList.toggle("completed", isCompleted(id, list));
35952    }
35953
35954    function checkModuleCompletion(completions) {
35955      const list = completions ?? getCompletions();
35956      document.querySelectorAll(".course-video").forEach((module) => {
35957        const btns = module.querySelectorAll(".complete-btn");
35958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
35959        const check = module.querySelector(".module-check");
35960        if (check) check.classList.toggle("visible", allCompleted);
35961      });
35962    }
35963
35964    function initCompletionButtons() {
35965      const completions = migrateCompletions();
35966
35967      document.querySelectorAll(".complete-btn").forEach((btn) => {
35968        if (btn.dataset.completionInit) return;
35969        btn.dataset.completionInit = "true";
35970        updateButtonState(btn, completions);
35971
35972        btn.addEventListener("click", (e) => {
35973          e.preventDefault();
35974          e.stopPropagation();
35975
35976          const id = btn.dataset.completionId;
35977          let list = getCompletions();
35978          const completed = isCompleted(id, list);
35979
35980          if (completed) {
35981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
35982            list = list.filter((c) =>
35982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
35983          } else {
35984            list.push(id);
35985          }
35986
35987          saveCompletions(list);
35988          updateButtonState(btn, list);
35989          checkModuleCompletion(list);
35990        });
35991      });
35992
35993      checkModuleCompletion(completions);
35994    }
35995
35996    if (document.readyState === "loading") {
35997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
35998    } else {
35999      initCompletionButtons();
36000    }
36001  })();
36002</script>
36002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="8460" data-end="10440" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Array functions</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>33 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-array-functions" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
36002<script>
36003  (() => {
36004    if (window.__courseLazyPlayersInitialized) return;
36005    window.__courseLazyPlayersInitialized = true;
36006
36007    function createPlayer(details) {
36008      const videoId = details.dataset.videoId;
36009      const start = details.dataset.start;
36010      const end = details.dataset.end;
36011
36012      const container = document.createElement("div");
36013      container.className = "player-container";
36014
36015      const spinner = document.createElement("div");
36016      spinner.className = "spinner";
36017      container.appendChild(spinner);
36018
36019      const controller = document.createElement("media-controller");
36020      controller.setAttribute("defaultsubtitles", "");
36021
36022      const provider = document.createElement("youtube-media-provider");
36023      provider.setAttribute("slot", "media");
36024      provider.setAttribute("video-id", videoId);
36025      provider.setAttribute("start", start);
36026      provider.setAttribute("end", end);
36027
36028      const controlBar = document.createElement("media-control-bar");
36029
36030      const playBtn = document.createElement("media-play-button");
36031      const timeDisplay = document.createElement("media-time-display");
36032      timeDisplay.setAttribute("showduration", "");
36033      const timeRange = document.createElement("media-time-range");
36034      const playbackRate = document.createElement("media-playback-rate-button");
36035      const muteBtn = document.createElement("media-mute-button");
36036      const volumeRange = document.createElement("media-volume-range");
36037
36038      const captionsBtn = document.createElement("media-captions-button");
36039      captionsBtn.addEventListener("click", (e) => {
36040        e.stopPropagation();
36041        const provider = container.querySelector("youtube-media-provider");
36042        if (provider) provider.toggleCaptions();
36043      });
36044
36045      const qualityBtn = document.createElement("media-chrome-button");
36046      qualityBtn.textContent = "--";
36047      qualityBtn.addEventListener("click", () => {
36048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
36049      });
36050
36051      const fullscreenBtn = document.createElement("media-fullscreen-button");
36052
36053      controlBar.append(
36054        playBtn, timeDisplay, timeRange,
36055        playbackRate, muteBtn, volumeRange,
36056        captionsBtn, qualityBtn, fullscreenBtn
36057      );
36058
36059      const poster = document.createElement("media-poster-image");
36060      poster.setAttribute("slot", "poster");
36061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
36062
36063      controller.append(provider, poster, controlBar);
36064
36065      captionsBtn.setAttribute("aria-checked", "true");
36066      controller.setAttribute("mediasubtitlesshowing", "on");
36067
36068      container.appendChild(controller);
36069      details.appendChild(container);
36070
36071      provider.addEventListener("waiting", () => container.classList.add("loading"));
36072      provider.addEventListener("playing", () => container.classList.remove("loading"));
36073      provider.addEventListener("play", () => container.classList.remove("loading"));
36074
36075      provider._textTracks.addEventListener("change", () => {
36076        const enabled = provider._captionsEnabled;
36077        captionsBtn.setAttribute("aria-checked", enabled);
36078        if (enabled) {
36079          controller.setAttribute("mediasubtitlesshowing", "on");
36080        } else {
36081          controller.removeAttribute("mediasubtitlesshowing");
36082        }
36083      });
36084
36085      provider.addEventListener("qualitychange", (e) => {
36086        qualityBtn.textContent = e.detail.quality;
36087      });
36088    }
36089
36090    function initLazyPlayers() {
36091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
36092        if (details.dataset.lazyInit) return;
36093        details.dataset.lazyInit = "true";
36094        details.addEventListener("toggle", () => {
36095          if (details.open && !details.querySelector(".player-container")) {
36096            createPlayer(details);
36097          } else if (!details.open) {
36098            const provider = details.querySelector("youtube-media-provider");
36099            if (provider && typeof provider.pause === "function") {
36100              provider.pause();
36101            }
36102          }
36103        });
36104      });
36105    }
36106
36107    if (document.readyState === "loading") {
36108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
36109    } else {
36110      initLazyPlayers();
36111    }
36112  })();
36113</script>
36113<script>
36114  (() => {
36115    if (window.__courseCompletionsInitialized) return;
36116    window.__courseCompletionsInitialized = true;
36117
36118    const STORAGE_KEY = "course-completions";
36119
36120    function slugify(text) {
36121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
36122    }
36123
36124    function getCompletions() {
36125      try {
36126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
36127      } catch {
36128        return [];
36129      }
36130    }
36131
36132    function saveCompletions(completions) {
36133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
36134    }
36135
36136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
36137    function canonicalFromStored(storedId) {
36138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36139      if (!m) return slugify(storedId);
36140      const [, slug, p, mv, title] = m;
36141      return `${slug}-${p}-${mv}-${slugify(title)}`;
36142    }
36143
36144    function canonicalPlusOne(storedId) {
36145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36146      if (!m) return null;
36147      const [, slug, p, mv, title] = m;
36148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
36149    }
36150
36151    function isCompleted(canonicalId, completions) {
36152      if (completions.includes(canonicalId)) return true;
36153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
36154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
36155    }
36156
36157    function migrateCompletions() {
36158      const completions = getCompletions();
36159      let changed = false;
36160      const migrated = completions.map((c) => {
36161        const canon = canonicalFromStored(c);
36162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
36163        const plusOne = canonicalPlusOne(c);
36164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
36165          if (plusOne !== c) changed = true;
36166          return plusOne;
36167        }
36168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
36169          changed = true;
36170          return canon;
36171        }
36172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
36173        if (canon !== c) {
36174          changed = true;
36175          return canon;
36176        }
36177        return c;
36178      });
36179      // Elimina duplicados tras migración
36180      const deduped = [...new Set(migrated)];
36181      if (changed || deduped.length !== completions.length) {
36182        saveCompletions(deduped);
36183        return deduped;
36184      }
36185      return completions;
36186    }
36187
36188    function updateButtonState(btn, completions) {
36189      const id = btn.dataset.completionId;
36190      const list = completions ?? getCompletions();
36191      btn.classList.toggle("completed", isCompleted(id, list));
36192    }
36193
36194    function checkModuleCompletion(completions) {
36195      const list = completions ?? getCompletions();
36196      document.querySelectorAll(".course-video").forEach((module) => {
36197        const btns = module.querySelectorAll(".complete-btn");
36198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
36199        const check = module.querySelector(".module-check");
36200        if (check) check.classList.toggle("visible", allCompleted);
36201      });
36202    }
36203
36204    function initCompletionButtons() {
36205      const completions = migrateCompletions();
36206
36207      document.querySelectorAll(".complete-btn").forEach((btn) => {
36208        if (btn.dataset.completionInit) return;
36209        btn.dataset.completionInit = "true";
36210        updateButtonState(btn, completions);
36211
36212        btn.addEventListener("click", (e) => {
36213          e.preventDefault();
36214          e.stopPropagation();
36215
36216          const id = btn.dataset.completionId;
36217          let list = getCompletions();
36218          const completed = isCompleted(id, list);
36219
36220          if (completed) {
36221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
36222            list = list.filter((c) =>
36222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
36223          } else {
36224            list.push(id);
36225          }
36226
36227          saveCompletions(list);
36228          updateButtonState(btn, list);
36229          checkModuleCompletion(list);
36230        });
36231      });
36232
36233      checkModuleCompletion(completions);
36234    }
36235
36236    if (document.readyState === "loading") {
36237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
36238    } else {
36239      initCompletionButtons();
36240    }
36241  })();
36242</script>
36242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="10440" data-end="11448" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Complejidad</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-complejidad" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
36242<script>
36243  (() => {
36244    if (window.__courseLazyPlayersInitialized) return;
36245    window.__courseLazyPlayersInitialized = true;
36246
36247    function createPlayer(details) {
36248      const videoId = details.dataset.videoId;
36249      const start = details.dataset.start;
36250      const end = details.dataset.end;
36251
36252      const container = document.createElement("div");
36253      container.className = "player-container";
36254
36255      const spinner = document.createElement("div");
36256      spinner.className = "spinner";
36257      container.appendChild(spinner);
36258
36259      const controller = document.createElement("media-controller");
36260      controller.setAttribute("defaultsubtitles", "");
36261
36262      const provider = document.createElement("youtube-media-provider");
36263      provider.setAttribute("slot", "media");
36264      provider.setAttribute("video-id", videoId);
36265      provider.setAttribute("start", start);
36266      provider.setAttribute("end", end);
36267
36268      const controlBar = document.createElement("media-control-bar");
36269
36270      const playBtn = document.createElement("media-play-button");
36271      const timeDisplay = document.createElement("media-time-display");
36272      timeDisplay.setAttribute("showduration", "");
36273      const timeRange = document.createElement("media-time-range");
36274      const playbackRate = document.createElement("media-playback-rate-button");
36275      const muteBtn = document.createElement("media-mute-button");
36276      const volumeRange = document.createElement("media-volume-range");
36277
36278      const captionsBtn = document.createElement("media-captions-button");
36279      captionsBtn.addEventListener("click", (e) => {
36280        e.stopPropagation();
36281        const provider = container.querySelector("youtube-media-provider");
36282        if (provider) provider.toggleCaptions();
36283      });
36284
36285      const qualityBtn = document.createElement("media-chrome-button");
36286      qualityBtn.textContent = "--";
36287      qualityBtn.addEventListener("click", () => {
36288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
36289      });
36290
36291      const fullscreenBtn = document.createElement("media-fullscreen-button");
36292
36293      controlBar.append(
36294        playBtn, timeDisplay, timeRange,
36295        playbackRate, muteBtn, volumeRange,
36296        captionsBtn, qualityBtn, fullscreenBtn
36297      );
36298
36299      const poster = document.createElement("media-poster-image");
36300      poster.setAttribute("slot", "poster");
36301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
36302
36303      controller.append(provider, poster, controlBar);
36304
36305      captionsBtn.setAttribute("aria-checked", "true");
36306      controller.setAttribute("mediasubtitlesshowing", "on");
36307
36308      container.appendChild(controller);
36309      details.appendChild(container);
36310
36311      provider.addEventListener("waiting", () => container.classList.add("loading"));
36312      provider.addEventListener("playing", () => container.classList.remove("loading"));
36313      provider.addEventListener("play", () => container.classList.remove("loading"));
36314
36315      provider._textTracks.addEventListener("change", () => {
36316        const enabled = provider._captionsEnabled;
36317        captionsBtn.setAttribute("aria-checked", enabled);
36318        if (enabled) {
36319          controller.setAttribute("mediasubtitlesshowing", "on");
36320        } else {
36321          controller.removeAttribute("mediasubtitlesshowing");
36322        }
36323      });
36324
36325      provider.addEventListener("qualitychange", (e) => {
36326        qualityBtn.textContent = e.detail.quality;
36327      });
36328    }
36329
36330    function initLazyPlayers() {
36331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
36332        if (details.dataset.lazyInit) return;
36333        details.dataset.lazyInit = "true";
36334        details.addEventListener("toggle", () => {
36335          if (details.open && !details.querySelector(".player-container")) {
36336            createPlayer(details);
36337          } else if (!details.open) {
36338            const provider = details.querySelector("youtube-media-provider");
36339            if (provider && typeof provider.pause === "function") {
36340              provider.pause();
36341            }
36342          }
36343        });
36344      });
36345    }
36346
36347    if (document.readyState === "loading") {
36348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
36349    } else {
36350      initLazyPlayers();
36351    }
36352  })();
36353</script>
36353<script>
36354  (() => {
36355    if (window.__courseCompletionsInitialized) return;
36356    window.__courseCompletionsInitialized = true;
36357
36358    const STORAGE_KEY = "course-completions";
36359
36360    function slugify(text) {
36361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
36362    }
36363
36364    function getCompletions() {
36365      try {
36366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
36367      } catch {
36368        return [];
36369      }
36370    }
36371
36372    function saveCompletions(completions) {
36373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
36374    }
36375
36376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
36377    function canonicalFromStored(storedId) {
36378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36379      if (!m) return slugify(storedId);
36380      const [, slug, p, mv, title] = m;
36381      return `${slug}-${p}-${mv}-${slugify(title)}`;
36382    }
36383
36384    function canonicalPlusOne(storedId) {
36385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36386      if (!m) return null;
36387      const [, slug, p, mv, title] = m;
36388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
36389    }
36390
36391    function isCompleted(canonicalId, completions) {
36392      if (completions.includes(canonicalId)) return true;
36393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
36394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
36395    }
36396
36397    function migrateCompletions() {
36398      const completions = getCompletions();
36399      let changed = false;
36400      const migrated = completions.map((c) => {
36401        const canon = canonicalFromStored(c);
36402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
36403        const plusOne = canonicalPlusOne(c);
36404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
36405          if (plusOne !== c) changed = true;
36406          return plusOne;
36407        }
36408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
36409          changed = true;
36410          return canon;
36411        }
36412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
36413        if (canon !== c) {
36414          changed = true;
36415          return canon;
36416        }
36417        return c;
36418      });
36419      // Elimina duplicados tras migración
36420      const deduped = [...new Set(migrated)];
36421      if (changed || deduped.length !== completions.length) {
36422        saveCompletions(deduped);
36423        return deduped;
36424      }
36425      return completions;
36426    }
36427
36428    function updateButtonState(btn, completions) {
36429      const id = btn.dataset.completionId;
36430      const list = completions ?? getCompletions();
36431      btn.classList.toggle("completed", isCompleted(id, list));
36432    }
36433
36434    function checkModuleCompletion(completions) {
36435      const list = completions ?? getCompletions();
36436      document.querySelectorAll(".course-video").forEach((module) => {
36437        const btns = module.querySelectorAll(".complete-btn");
36438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
36439        const check = module.querySelector(".module-check");
36440        if (check) check.classList.toggle("visible", allCompleted);
36441      });
36442    }
36443
36444    function initCompletionButtons() {
36445      const completions = migrateCompletions();
36446
36447      document.querySelectorAll(".complete-btn").forEach((btn) => {
36448        if (btn.dataset.completionInit) return;
36449        btn.dataset.completionInit = "true";
36450        updateButtonState(btn, completions);
36451
36452        btn.addEventListener("click", (e) => {
36453          e.preventDefault();
36454          e.stopPropagation();
36455
36456          const id = btn.dataset.completionId;
36457          let list = getCompletions();
36458          const completed = isCompleted(id, list);
36459
36460          if (completed) {
36461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
36462            list = list.filter((c) =>
36462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
36463          } else {
36464            list.push(id);
36465          }
36466
36467          saveCompletions(list);
36468          updateButtonState(btn, list);
36469          checkModuleCompletion(list);
36470        });
36471      });
36472
36473      checkModuleCompletion(completions);
36474    }
36475
36476    if (document.readyState === "loading") {
36477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
36478    } else {
36479      initCompletionButtons();
36480    }
36481  })();
36482</script>
36482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="11448" data-end="12895" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Iterator functions</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-iterator-functions" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
36482<script>
36483  (() => {
36484    if (window.__courseLazyPlayersInitialized) return;
36485    window.__courseLazyPlayersInitialized = true;
36486
36487    function createPlayer(details) {
36488      const videoId = details.dataset.videoId;
36489      const start = details.dataset.start;
36490      const end = details.dataset.end;
36491
36492      const container = document.createElement("div");
36493      container.className = "player-container";
36494
36495      const spinner = document.createElement("div");
36496      spinner.className = "spinner";
36497      container.appendChild(spinner);
36498
36499      const controller = document.createElement("media-controller");
36500      controller.setAttribute("defaultsubtitles", "");
36501
36502      const provider = document.createElement("youtube-media-provider");
36503      provider.setAttribute("slot", "media");
36504      provider.setAttribute("video-id", videoId);
36505      provider.setAttribute("start", start);
36506      provider.setAttribute("end", end);
36507
36508      const controlBar = document.createElement("media-control-bar");
36509
36510      const playBtn = document.createElement("media-play-button");
36511      const timeDisplay = document.createElement("media-time-display");
36512      timeDisplay.setAttribute("showduration", "");
36513      const timeRange = document.createElement("media-time-range");
36514      const playbackRate = document.createElement("media-playback-rate-button");
36515      const muteBtn = document.createElement("media-mute-button");
36516      const volumeRange = document.createElement("media-volume-range");
36517
36518      const captionsBtn = document.createElement("media-captions-button");
36519      captionsBtn.addEventListener("click", (e) => {
36520        e.stopPropagation();
36521        const provider = container.querySelector("youtube-media-provider");
36522        if (provider) provider.toggleCaptions();
36523      });
36524
36525      const qualityBtn = document.createElement("media-chrome-button");
36526      qualityBtn.textContent = "--";
36527      qualityBtn.addEventListener("click", () => {
36528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
36529      });
36530
36531      const fullscreenBtn = document.createElement("media-fullscreen-button");
36532
36533      controlBar.append(
36534        playBtn, timeDisplay, timeRange,
36535        playbackRate, muteBtn, volumeRange,
36536        captionsBtn, qualityBtn, fullscreenBtn
36537      );
36538
36539      const poster = document.createElement("media-poster-image");
36540      poster.setAttribute("slot", "poster");
36541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
36542
36543      controller.append(provider, poster, controlBar);
36544
36545      captionsBtn.setAttribute("aria-checked", "true");
36546      controller.setAttribute("mediasubtitlesshowing", "on");
36547
36548      container.appendChild(controller);
36549      details.appendChild(container);
36550
36551      provider.addEventListener("waiting", () => container.classList.add("loading"));
36552      provider.addEventListener("playing", () => container.classList.remove("loading"));
36553      provider.addEventListener("play", () => container.classList.remove("loading"));
36554
36555      provider._textTracks.addEventListener("change", () => {
36556        const enabled = provider._captionsEnabled;
36557        captionsBtn.setAttribute("aria-checked", enabled);
36558        if (enabled) {
36559          controller.setAttribute("mediasubtitlesshowing", "on");
36560        } else {
36561          controller.removeAttribute("mediasubtitlesshowing");
36562        }
36563      });
36564
36565      provider.addEventListener("qualitychange", (e) => {
36566        qualityBtn.textContent = e.detail.quality;
36567      });
36568    }
36569
36570    function initLazyPlayers() {
36571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
36572        if (details.dataset.lazyInit) return;
36573        details.dataset.lazyInit = "true";
36574        details.addEventListener("toggle", () => {
36575          if (details.open && !details.querySelector(".player-container")) {
36576            createPlayer(details);
36577          } else if (!details.open) {
36578            const provider = details.querySelector("youtube-media-provider");
36579            if (provider && typeof provider.pause === "function") {
36580              provider.pause();
36581            }
36582          }
36583        });
36584      });
36585    }
36586
36587    if (document.readyState === "loading") {
36588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
36589    } else {
36590      initLazyPlayers();
36591    }
36592  })();
36593</script>
36593<script>
36594  (() => {
36595    if (window.__courseCompletionsInitialized) return;
36596    window.__courseCompletionsInitialized = true;
36597
36598    const STORAGE_KEY = "course-completions";
36599
36600    function slugify(text) {
36601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
36602    }
36603
36604    function getCompletions() {
36605      try {
36606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
36607      } catch {
36608        return [];
36609      }
36610    }
36611
36612    function saveCompletions(completions) {
36613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
36614    }
36615
36616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
36617    function canonicalFromStored(storedId) {
36618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36619      if (!m) return slugify(storedId);
36620      const [, slug, p, mv, title] = m;
36621      return `${slug}-${p}-${mv}-${slugify(title)}`;
36622    }
36623
36624    function canonicalPlusOne(storedId) {
36625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36626      if (!m) return null;
36627      const [, slug, p, mv, title] = m;
36628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
36629    }
36630
36631    function isCompleted(canonicalId, completions) {
36632      if (completions.includes(canonicalId)) return true;
36633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
36634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
36635    }
36636
36637    function migrateCompletions() {
36638      const completions = getCompletions();
36639      let changed = false;
36640      const migrated = completions.map((c) => {
36641        const canon = canonicalFromStored(c);
36642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
36643        const plusOne = canonicalPlusOne(c);
36644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
36645          if (plusOne !== c) changed = true;
36646          return plusOne;
36647        }
36648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
36649          changed = true;
36650          return canon;
36651        }
36652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
36653        if (canon !== c) {
36654          changed = true;
36655          return canon;
36656        }
36657        return c;
36658      });
36659      // Elimina duplicados tras migración
36660      const deduped = [...new Set(migrated)];
36661      if (changed || deduped.length !== completions.length) {
36662        saveCompletions(deduped);
36663        return deduped;
36664      }
36665      return completions;
36666    }
36667
36668    function updateButtonState(btn, completions) {
36669      const id = btn.dataset.completionId;
36670      const list = completions ?? getCompletions();
36671      btn.classList.toggle("completed", isCompleted(id, list));
36672    }
36673
36674    function checkModuleCompletion(completions) {
36675      const list = completions ?? getCompletions();
36676      document.querySelectorAll(".course-video").forEach((module) => {
36677        const btns = module.querySelectorAll(".complete-btn");
36678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
36679        const check = module.querySelector(".module-check");
36680        if (check) check.classList.toggle("visible", allCompleted);
36681      });
36682    }
36683
36684    function initCompletionButtons() {
36685      const completions = migrateCompletions();
36686
36687      document.querySelectorAll(".complete-btn").forEach((btn) => {
36688        if (btn.dataset.completionInit) return;
36689        btn.dataset.completionInit = "true";
36690        updateButtonState(btn, completions);
36691
36692        btn.addEventListener("click", (e) => {
36693          e.preventDefault();
36694          e.stopPropagation();
36695
36696          const id = btn.dataset.completionId;
36697          let list = getCompletions();
36698          const completed = isCompleted(id, list);
36699
36700          if (completed) {
36701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
36702            list = list.filter((c) =>
36702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
36703          } else {
36704            list.push(id);
36705          }
36706
36707          saveCompletions(list);
36708          updateButtonState(btn, list);
36709          checkModuleCompletion(list);
36710        });
36711      });
36712
36713      checkModuleCompletion(completions);
36714    }
36715
36716    if (document.readyState === "loading") {
36717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
36718    } else {
36719      initCompletionButtons();
36720    }
36721  })();
36722</script>
36722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="12895" data-end="14595" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Código limpio</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-codigo-limpio" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
36722<script>
36723  (() => {
36724    if (window.__courseLazyPlayersInitialized) return;
36725    window.__courseLazyPlayersInitialized = true;
36726
36727    function createPlayer(details) {
36728      const videoId = details.dataset.videoId;
36729      const start = details.dataset.start;
36730      const end = details.dataset.end;
36731
36732      const container = document.createElement("div");
36733      container.className = "player-container";
36734
36735      const spinner = document.createElement("div");
36736      spinner.className = "spinner";
36737      container.appendChild(spinner);
36738
36739      const controller = document.createElement("media-controller");
36740      controller.setAttribute("defaultsubtitles", "");
36741
36742      const provider = document.createElement("youtube-media-provider");
36743      provider.setAttribute("slot", "media");
36744      provider.setAttribute("video-id", videoId);
36745      provider.setAttribute("start", start);
36746      provider.setAttribute("end", end);
36747
36748      const controlBar = document.createElement("media-control-bar");
36749
36750      const playBtn = document.createElement("media-play-button");
36751      const timeDisplay = document.createElement("media-time-display");
36752      timeDisplay.setAttribute("showduration", "");
36753      const timeRange = document.createElement("media-time-range");
36754      const playbackRate = document.createElement("media-playback-rate-button");
36755      const muteBtn = document.createElement("media-mute-button");
36756      const volumeRange = document.createElement("media-volume-range");
36757
36758      const captionsBtn = document.createElement("media-captions-button");
36759      captionsBtn.addEventListener("click", (e) => {
36760        e.stopPropagation();
36761        const provider = container.querySelector("youtube-media-provider");
36762        if (provider) provider.toggleCaptions();
36763      });
36764
36765      const qualityBtn = document.createElement("media-chrome-button");
36766      qualityBtn.textContent = "--";
36767      qualityBtn.addEventListener("click", () => {
36768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
36769      });
36770
36771      const fullscreenBtn = document.createElement("media-fullscreen-button");
36772
36773      controlBar.append(
36774        playBtn, timeDisplay, timeRange,
36775        playbackRate, muteBtn, volumeRange,
36776        captionsBtn, qualityBtn, fullscreenBtn
36777      );
36778
36779      const poster = document.createElement("media-poster-image");
36780      poster.setAttribute("slot", "poster");
36781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
36782
36783      controller.append(provider, poster, controlBar);
36784
36785      captionsBtn.setAttribute("aria-checked", "true");
36786      controller.setAttribute("mediasubtitlesshowing", "on");
36787
36788      container.appendChild(controller);
36789      details.appendChild(container);
36790
36791      provider.addEventListener("waiting", () => container.classList.add("loading"));
36792      provider.addEventListener("playing", () => container.classList.remove("loading"));
36793      provider.addEventListener("play", () => container.classList.remove("loading"));
36794
36795      provider._textTracks.addEventListener("change", () => {
36796        const enabled = provider._captionsEnabled;
36797        captionsBtn.setAttribute("aria-checked", enabled);
36798        if (enabled) {
36799          controller.setAttribute("mediasubtitlesshowing", "on");
36800        } else {
36801          controller.removeAttribute("mediasubtitlesshowing");
36802        }
36803      });
36804
36805      provider.addEventListener("qualitychange", (e) => {
36806        qualityBtn.textContent = e.detail.quality;
36807      });
36808    }
36809
36810    function initLazyPlayers() {
36811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
36812        if (details.dataset.lazyInit) return;
36813        details.dataset.lazyInit = "true";
36814        details.addEventListener("toggle", () => {
36815          if (details.open && !details.querySelector(".player-container")) {
36816            createPlayer(details);
36817          } else if (!details.open) {
36818            const provider = details.querySelector("youtube-media-provider");
36819            if (provider && typeof provider.pause === "function") {
36820              provider.pause();
36821            }
36822          }
36823        });
36824      });
36825    }
36826
36827    if (document.readyState === "loading") {
36828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
36829    } else {
36830      initLazyPlayers();
36831    }
36832  })();
36833</script>
36833<script>
36834  (() => {
36835    if (window.__courseCompletionsInitialized) return;
36836    window.__courseCompletionsInitialized = true;
36837
36838    const STORAGE_KEY = "course-completions";
36839
36840    function slugify(text) {
36841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
36842    }
36843
36844    function getCompletions() {
36845      try {
36846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
36847      } catch {
36848        return [];
36849      }
36850    }
36851
36852    function saveCompletions(completions) {
36853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
36854    }
36855
36856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
36857    function canonicalFromStored(storedId) {
36858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36859      if (!m) return slugify(storedId);
36860      const [, slug, p, mv, title] = m;
36861      return `${slug}-${p}-${mv}-${slugify(title)}`;
36862    }
36863
36864    function canonicalPlusOne(storedId) {
36865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
36866      if (!m) return null;
36867      const [, slug, p, mv, title] = m;
36868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
36869    }
36870
36871    function isCompleted(canonicalId, completions) {
36872      if (completions.includes(canonicalId)) return true;
36873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
36874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
36875    }
36876
36877    function migrateCompletions() {
36878      const completions = getCompletions();
36879      let changed = false;
36880      const migrated = completions.map((c) => {
36881        const canon = canonicalFromStored(c);
36882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
36883        const plusOne = canonicalPlusOne(c);
36884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
36885          if (plusOne !== c) changed = true;
36886          return plusOne;
36887        }
36888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
36889          changed = true;
36890          return canon;
36891        }
36892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
36893        if (canon !== c) {
36894          changed = true;
36895          return canon;
36896        }
36897        return c;
36898      });
36899      // Elimina duplicados tras migración
36900      const deduped = [...new Set(migrated)];
36901      if (changed || deduped.length !== completions.length) {
36902        saveCompletions(deduped);
36903        return deduped;
36904      }
36905      return completions;
36906    }
36907
36908    function updateButtonState(btn, completions) {
36909      const id = btn.dataset.completionId;
36910      const list = completions ?? getCompletions();
36911      btn.classList.toggle("completed", isCompleted(id, list));
36912    }
36913
36914    function checkModuleCompletion(completions) {
36915      const list = completions ?? getCompletions();
36916      document.querySelectorAll(".course-video").forEach((module) => {
36917        const btns = module.querySelectorAll(".complete-btn");
36918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
36919        const check = module.querySelector(".module-check");
36920        if (check) check.classList.toggle("visible", allCompleted);
36921      });
36922    }
36923
36924    function initCompletionButtons() {
36925      const completions = migrateCompletions();
36926
36927      document.querySelectorAll(".complete-btn").forEach((btn) => {
36928        if (btn.dataset.completionInit) return;
36929        btn.dataset.completionInit = "true";
36930        updateButtonState(btn, completions);
36931
36932        btn.addEventListener("click", (e) => {
36933          e.preventDefault();
36934          e.stopPropagation();
36935
36936          const id = btn.dataset.completionId;
36937          let list = getCompletions();
36938          const completed = isCompleted(id, list);
36939
36940          if (completed) {
36941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
36942            list = list.filter((c) =>
36942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
36943          } else {
36944            list.push(id);
36945          }
36946
36947          saveCompletions(list);
36948          updateButtonState(btn, list);
36949          checkModuleCompletion(list);
36950        });
36951      });
36952
36953      checkModuleCompletion(completions);
36954    }
36955
36956    if (document.readyState === "loading") {
36957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
36958    } else {
36959      initCompletionButtons();
36960    }
36961  })();
36962</script>
36962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="b2Si8gGswuA" data-start="14595" data-end="16778" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Patrones de diseño</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>36 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-3-4-patrones-de-diseno" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
36962<script>
36963  (() => {
36964    if (window.__courseLazyPlayersInitialized) return;
36965    window.__courseLazyPlayersInitialized = true;
36966
36967    function createPlayer(details) {
36968      const videoId = details.dataset.videoId;
36969      const start = details.dataset.start;
36970      const end = details.dataset.end;
36971
36972      const container = document.createElement("div");
36973      container.className = "player-container";
36974
36975      const spinner = document.createElement("div");
36976      spinner.className = "spinner";
36977      container.appendChild(spinner);
36978
36979      const controller = document.createElement("media-controller");
36980      controller.setAttribute("defaultsubtitles", "");
36981
36982      const provider = document.createElement("youtube-media-provider");
36983      provider.setAttribute("slot", "media");
36984      provider.setAttribute("video-id", videoId);
36985      provider.setAttribute("start", start);
36986      provider.setAttribute("end", end);
36987
36988      const controlBar = document.createElement("media-control-bar");
36989
36990      const playBtn = document.createElement("media-play-button");
36991      const timeDisplay = document.createElement("media-time-display");
36992      timeDisplay.setAttribute("showduration", "");
36993      const timeRange = document.createElement("media-time-range");
36994      const playbackRate = document.createElement("media-playback-rate-button");
36995      const muteBtn = document.createElement("media-mute-button");
36996      const volumeRange = document.createElement("media-volume-range");
36997
36998      const captionsBtn = document.createElement("media-captions-button");
36999      captionsBtn.addEventListener("click", (e) => {
37000        e.stopPropagation();
37001        const provider = container.querySelector("youtube-media-provider");
37002        if (provider) provider.toggleCaptions();
37003      });
37004
37005      const qualityBtn = document.createElement("media-chrome-button");
37006      qualityBtn.textContent = "--";
37007      qualityBtn.addEventListener("click", () => {
37008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
37009      });
37010
37011      const fullscreenBtn = document.createElement("media-fullscreen-button");
37012
37013      controlBar.append(
37014        playBtn, timeDisplay, timeRange,
37015        playbackRate, muteBtn, volumeRange,
37016        captionsBtn, qualityBtn, fullscreenBtn
37017      );
37018
37019      const poster = document.createElement("media-poster-image");
37020      poster.setAttribute("slot", "poster");
37021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
37022
37023      controller.append(provider, poster, controlBar);
37024
37025      captionsBtn.setAttribute("aria-checked", "true");
37026      controller.setAttribute("mediasubtitlesshowing", "on");
37027
37028      container.appendChild(controller);
37029      details.appendChild(container);
37030
37031      provider.addEventListener("waiting", () => container.classList.add("loading"));
37032      provider.addEventListener("playing", () => container.classList.remove("loading"));
37033      provider.addEventListener("play", () => container.classList.remove("loading"));
37034
37035      provider._textTracks.addEventListener("change", () => {
37036        const enabled = provider._captionsEnabled;
37037        captionsBtn.setAttribute("aria-checked", enabled);
37038        if (enabled) {
37039          controller.setAttribute("mediasubtitlesshowing", "on");
37040        } else {
37041          controller.removeAttribute("mediasubtitlesshowing");
37042        }
37043      });
37044
37045      provider.addEventListener("qualitychange", (e) => {
37046        qualityBtn.textContent = e.detail.quality;
37047      });
37048    }
37049
37050    function initLazyPlayers() {
37051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
37052        if (details.dataset.lazyInit) return;
37053        details.dataset.lazyInit = "true";
37054        details.addEventListener("toggle", () => {
37055          if (details.open && !details.querySelector(".player-container")) {
37056            createPlayer(details);
37057          } else if (!details.open) {
37058            const provider = details.querySelector("youtube-media-provider");
37059            if (provider && typeof provider.pause === "function") {
37060              provider.pause();
37061            }
37062          }
37063        });
37064      });
37065    }
37066
37067    if (document.readyState === "loading") {
37068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
37069    } else {
37070      initLazyPlayers();
37071    }
37072  })();
37073</script>
37073<script>
37074  (() => {
37075    if (window.__courseCompletionsInitialized) return;
37076    window.__courseCompletionsInitialized = true;
37077
37078    const STORAGE_KEY = "course-completions";
37079
37080    function slugify(text) {
37081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
37082    }
37083
37084    function getCompletions() {
37085      try {
37086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
37087      } catch {
37088        return [];
37089      }
37090    }
37091
37092    function saveCompletions(completions) {
37093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
37094    }
37095
37096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
37097    function canonicalFromStored(storedId) {
37098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37099      if (!m) return slugify(storedId);
37100      const [, slug, p, mv, title] = m;
37101      return `${slug}-${p}-${mv}-${slugify(title)}`;
37102    }
37103
37104    function canonicalPlusOne(storedId) {
37105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37106      if (!m) return null;
37107      const [, slug, p, mv, title] = m;
37108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
37109    }
37110
37111    function isCompleted(canonicalId, completions) {
37112      if (completions.includes(canonicalId)) return true;
37113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
37114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
37115    }
37116
37117    function migrateCompletions() {
37118      const completions = getCompletions();
37119      let changed = false;
37120      const migrated = completions.map((c) => {
37121        const canon = canonicalFromStored(c);
37122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
37123        const plusOne = canonicalPlusOne(c);
37124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
37125          if (plusOne !== c) changed = true;
37126          return plusOne;
37127        }
37128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
37129          changed = true;
37130          return canon;
37131        }
37132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
37133        if (canon !== c) {
37134          changed = true;
37135          return canon;
37136        }
37137        return c;
37138      });
37139      // Elimina duplicados tras migración
37140      const deduped = [...new Set(migrated)];
37141      if (changed || deduped.length !== completions.length) {
37142        saveCompletions(deduped);
37143        return deduped;
37144      }
37145      return completions;
37146    }
37147
37148    function updateButtonState(btn, completions) {
37149      const id = btn.dataset.completionId;
37150      const list = completions ?? getCompletions();
37151      btn.classList.toggle("completed", isCompleted(id, list));
37152    }
37153
37154    function checkModuleCompletion(completions) {
37155      const list = completions ?? getCompletions();
37156      document.querySelectorAll(".course-video").forEach((module) => {
37157        const btns = module.querySelectorAll(".complete-btn");
37158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
37159        const check = module.querySelector(".module-check");
37160        if (check) check.classList.toggle("visible", allCompleted);
37161      });
37162    }
37163
37164    function initCompletionButtons() {
37165      const completions = migrateCompletions();
37166
37167      document.querySelectorAll(".complete-btn").forEach((btn) => {
37168        if (btn.dataset.completionInit) return;
37169        btn.dataset.completionInit = "true";
37170        updateButtonState(btn, completions);
37171
37172        btn.addEventListener("click", (e) => {
37173          e.preventDefault();
37174          e.stopPropagation();
37175
37176          const id = btn.dataset.completionId;
37177          let list = getCompletions();
37178          const completed = isCompleted(id, list);
37179
37180          if (completed) {
37181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
37182            list = list.filter((c) =>
37182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
37183          } else {
37184            list.push(id);
37185          }
37186
37187          saveCompletions(list);
37188          updateButtonState(btn, list);
37189          checkModuleCompletion(list);
37190        });
37191      });
37192
37193      checkModuleCompletion(completions);
37194    }
37195
37196    if (document.readyState === "loading") {
37197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
37198    } else {
37199      initCompletionButtons();
37200    }
37201  })();
37202</script>
37202</div></details></div></details><details class="phase" open data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>FASE 4: Avanzado</summary><div class="phase-content" data-astro-cid-3yfbwi5j><div class="description" data-astro-cid-3yfbwi5j><p data-astro-cid-3yfbwi5j>En esta fase 4 vamos entrando en temas más avanzados y profesionales. Exploramos los módulos de Javascript y aprendemos a separar y organizar, crear webcomponents nativos y detalles de performance y rendimiento web, para evitar que nuestra web tenga un mal rendimiento.</p></div><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 13 📘 Módulos Javascript<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="1605" data-end="4112" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Historia de módulos en Javascript</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>42 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-historia-de-modulos-en-javascript" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
37202<script>
37203  (() => {
37204    if (window.__courseLazyPlayersInitialized) return;
37205    window.__courseLazyPlayersInitialized = true;
37206
37207    function createPlayer(details) {
37208      const videoId = details.dataset.videoId;
37209      const start = details.dataset.start;
37210      const end = details.dataset.end;
37211
37212      const container = document.createElement("div");
37213      container.className = "player-container";
37214
37215      const spinner = document.createElement("div");
37216      spinner.className = "spinner";
37217      container.appendChild(spinner);
37218
37219      const controller = document.createElement("media-controller");
37220      controller.setAttribute("defaultsubtitles", "");
37221
37222      const provider = document.createElement("youtube-media-provider");
37223      provider.setAttribute("slot", "media");
37224      provider.setAttribute("video-id", videoId);
37225      provider.setAttribute("start", start);
37226      provider.setAttribute("end", end);
37227
37228      const controlBar = document.createElement("media-control-bar");
37229
37230      const playBtn = document.createElement("media-play-button");
37231      const timeDisplay = document.createElement("media-time-display");
37232      timeDisplay.setAttribute("showduration", "");
37233      const timeRange = document.createElement("media-time-range");
37234      const playbackRate = document.createElement("media-playback-rate-button");
37235      const muteBtn = document.createElement("media-mute-button");
37236      const volumeRange = document.createElement("media-volume-range");
37237
37238      const captionsBtn = document.createElement("media-captions-button");
37239      captionsBtn.addEventListener("click", (e) => {
37240        e.stopPropagation();
37241        const provider = container.querySelector("youtube-media-provider");
37242        if (provider) provider.toggleCaptions();
37243      });
37244
37245      const qualityBtn = document.createElement("media-chrome-button");
37246      qualityBtn.textContent = "--";
37247      qualityBtn.addEventListener("click", () => {
37248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
37249      });
37250
37251      const fullscreenBtn = document.createElement("media-fullscreen-button");
37252
37253      controlBar.append(
37254        playBtn, timeDisplay, timeRange,
37255        playbackRate, muteBtn, volumeRange,
37256        captionsBtn, qualityBtn, fullscreenBtn
37257      );
37258
37259      const poster = document.createElement("media-poster-image");
37260      poster.setAttribute("slot", "poster");
37261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
37262
37263      controller.append(provider, poster, controlBar);
37264
37265      captionsBtn.setAttribute("aria-checked", "true");
37266      controller.setAttribute("mediasubtitlesshowing", "on");
37267
37268      container.appendChild(controller);
37269      details.appendChild(container);
37270
37271      provider.addEventListener("waiting", () => container.classList.add("loading"));
37272      provider.addEventListener("playing", () => container.classList.remove("loading"));
37273      provider.addEventListener("play", () => container.classList.remove("loading"));
37274
37275      provider._textTracks.addEventListener("change", () => {
37276        const enabled = provider._captionsEnabled;
37277        captionsBtn.setAttribute("aria-checked", enabled);
37278        if (enabled) {
37279          controller.setAttribute("mediasubtitlesshowing", "on");
37280        } else {
37281          controller.removeAttribute("mediasubtitlesshowing");
37282        }
37283      });
37284
37285      provider.addEventListener("qualitychange", (e) => {
37286        qualityBtn.textContent = e.detail.quality;
37287      });
37288    }
37289
37290    function initLazyPlayers() {
37291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
37292        if (details.dataset.lazyInit) return;
37293        details.dataset.lazyInit = "true";
37294        details.addEventListener("toggle", () => {
37295          if (details.open && !details.querySelector(".player-container")) {
37296            createPlayer(details);
37297          } else if (!details.open) {
37298            const provider = details.querySelector("youtube-media-provider");
37299            if (provider && typeof provider.pause === "function") {
37300              provider.pause();
37301            }
37302          }
37303        });
37304      });
37305    }
37306
37307    if (document.readyState === "loading") {
37308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
37309    } else {
37310      initLazyPlayers();
37311    }
37312  })();
37313</script>
37313<script>
37314  (() => {
37315    if (window.__courseCompletionsInitialized) return;
37316    window.__courseCompletionsInitialized = true;
37317
37318    const STORAGE_KEY = "course-completions";
37319
37320    function slugify(text) {
37321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
37322    }
37323
37324    function getCompletions() {
37325      try {
37326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
37327      } catch {
37328        return [];
37329      }
37330    }
37331
37332    function saveCompletions(completions) {
37333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
37334    }
37335
37336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
37337    function canonicalFromStored(storedId) {
37338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37339      if (!m) return slugify(storedId);
37340      const [, slug, p, mv, title] = m;
37341      return `${slug}-${p}-${mv}-${slugify(title)}`;
37342    }
37343
37344    function canonicalPlusOne(storedId) {
37345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37346      if (!m) return null;
37347      const [, slug, p, mv, title] = m;
37348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
37349    }
37350
37351    function isCompleted(canonicalId, completions) {
37352      if (completions.includes(canonicalId)) return true;
37353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
37354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
37355    }
37356
37357    function migrateCompletions() {
37358      const completions = getCompletions();
37359      let changed = false;
37360      const migrated = completions.map((c) => {
37361        const canon = canonicalFromStored(c);
37362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
37363        const plusOne = canonicalPlusOne(c);
37364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
37365          if (plusOne !== c) changed = true;
37366          return plusOne;
37367        }
37368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
37369          changed = true;
37370          return canon;
37371        }
37372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
37373        if (canon !== c) {
37374          changed = true;
37375          return canon;
37376        }
37377        return c;
37378      });
37379      // Elimina duplicados tras migración
37380      const deduped = [...new Set(migrated)];
37381      if (changed || deduped.length !== completions.length) {
37382        saveCompletions(deduped);
37383        return deduped;
37384      }
37385      return completions;
37386    }
37387
37388    function updateButtonState(btn, completions) {
37389      const id = btn.dataset.completionId;
37390      const list = completions ?? getCompletions();
37391      btn.classList.toggle("completed", isCompleted(id, list));
37392    }
37393
37394    function checkModuleCompletion(completions) {
37395      const list = completions ?? getCompletions();
37396      document.querySelectorAll(".course-video").forEach((module) => {
37397        const btns = module.querySelectorAll(".complete-btn");
37398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
37399        const check = module.querySelector(".module-check");
37400        if (check) check.classList.toggle("visible", allCompleted);
37401      });
37402    }
37403
37404    function initCompletionButtons() {
37405      const completions = migrateCompletions();
37406
37407      document.querySelectorAll(".complete-btn").forEach((btn) => {
37408        if (btn.dataset.completionInit) return;
37409        btn.dataset.completionInit = "true";
37410        updateButtonState(btn, completions);
37411
37412        btn.addEventListener("click", (e) => {
37413          e.preventDefault();
37414          e.stopPropagation();
37415
37416          const id = btn.dataset.completionId;
37417          let list = getCompletions();
37418          const completed = isCompleted(id, list);
37419
37420          if (completed) {
37421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
37422            list = list.filter((c) =>
37422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
37423          } else {
37424            list.push(id);
37425          }
37426
37427          saveCompletions(list);
37428          updateButtonState(btn, list);
37429          checkModuleCompletion(list);
37430        });
37431      });
37432
37433      checkModuleCompletion(completions);
37434    }
37435
37436    if (document.readyState === "loading") {
37437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
37438    } else {
37439      initCompletionButtons();
37440    }
37441  })();
37442</script>
37442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="4112" data-end="5572" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Módulos de Javascript (ESM)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-modulos-de-javascript-esm" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
37442<script>
37443  (() => {
37444    if (window.__courseLazyPlayersInitialized) return;
37445    window.__courseLazyPlayersInitialized = true;
37446
37447    function createPlayer(details) {
37448      const videoId = details.dataset.videoId;
37449      const start = details.dataset.start;
37450      const end = details.dataset.end;
37451
37452      const container = document.createElement("div");
37453      container.className = "player-container";
37454
37455      const spinner = document.createElement("div");
37456      spinner.className = "spinner";
37457      container.appendChild(spinner);
37458
37459      const controller = document.createElement("media-controller");
37460      controller.setAttribute("defaultsubtitles", "");
37461
37462      const provider = document.createElement("youtube-media-provider");
37463      provider.setAttribute("slot", "media");
37464      provider.setAttribute("video-id", videoId);
37465      provider.setAttribute("start", start);
37466      provider.setAttribute("end", end);
37467
37468      const controlBar = document.createElement("media-control-bar");
37469
37470      const playBtn = document.createElement("media-play-button");
37471      const timeDisplay = document.createElement("media-time-display");
37472      timeDisplay.setAttribute("showduration", "");
37473      const timeRange = document.createElement("media-time-range");
37474      const playbackRate = document.createElement("media-playback-rate-button");
37475      const muteBtn = document.createElement("media-mute-button");
37476      const volumeRange = document.createElement("media-volume-range");
37477
37478      const captionsBtn = document.createElement("media-captions-button");
37479      captionsBtn.addEventListener("click", (e) => {
37480        e.stopPropagation();
37481        const provider = container.querySelector("youtube-media-provider");
37482        if (provider) provider.toggleCaptions();
37483      });
37484
37485      const qualityBtn = document.createElement("media-chrome-button");
37486      qualityBtn.textContent = "--";
37487      qualityBtn.addEventListener("click", () => {
37488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
37489      });
37490
37491      const fullscreenBtn = document.createElement("media-fullscreen-button");
37492
37493      controlBar.append(
37494        playBtn, timeDisplay, timeRange,
37495        playbackRate, muteBtn, volumeRange,
37496        captionsBtn, qualityBtn, fullscreenBtn
37497      );
37498
37499      const poster = document.createElement("media-poster-image");
37500      poster.setAttribute("slot", "poster");
37501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
37502
37503      controller.append(provider, poster, controlBar);
37504
37505      captionsBtn.setAttribute("aria-checked", "true");
37506      controller.setAttribute("mediasubtitlesshowing", "on");
37507
37508      container.appendChild(controller);
37509      details.appendChild(container);
37510
37511      provider.addEventListener("waiting", () => container.classList.add("loading"));
37512      provider.addEventListener("playing", () => container.classList.remove("loading"));
37513      provider.addEventListener("play", () => container.classList.remove("loading"));
37514
37515      provider._textTracks.addEventListener("change", () => {
37516        const enabled = provider._captionsEnabled;
37517        captionsBtn.setAttribute("aria-checked", enabled);
37518        if (enabled) {
37519          controller.setAttribute("mediasubtitlesshowing", "on");
37520        } else {
37521          controller.removeAttribute("mediasubtitlesshowing");
37522        }
37523      });
37524
37525      provider.addEventListener("qualitychange", (e) => {
37526        qualityBtn.textContent = e.detail.quality;
37527      });
37528    }
37529
37530    function initLazyPlayers() {
37531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
37532        if (details.dataset.lazyInit) return;
37533        details.dataset.lazyInit = "true";
37534        details.addEventListener("toggle", () => {
37535          if (details.open && !details.querySelector(".player-container")) {
37536            createPlayer(details);
37537          } else if (!details.open) {
37538            const provider = details.querySelector("youtube-media-provider");
37539            if (provider && typeof provider.pause === "function") {
37540              provider.pause();
37541            }
37542          }
37543        });
37544      });
37545    }
37546
37547    if (document.readyState === "loading") {
37548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
37549    } else {
37550      initLazyPlayers();
37551    }
37552  })();
37553</script>
37553<script>
37554  (() => {
37555    if (window.__courseCompletionsInitialized) return;
37556    window.__courseCompletionsInitialized = true;
37557
37558    const STORAGE_KEY = "course-completions";
37559
37560    function slugify(text) {
37561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
37562    }
37563
37564    function getCompletions() {
37565      try {
37566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
37567      } catch {
37568        return [];
37569      }
37570    }
37571
37572    function saveCompletions(completions) {
37573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
37574    }
37575
37576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
37577    function canonicalFromStored(storedId) {
37578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37579      if (!m) return slugify(storedId);
37580      const [, slug, p, mv, title] = m;
37581      return `${slug}-${p}-${mv}-${slugify(title)}`;
37582    }
37583
37584    function canonicalPlusOne(storedId) {
37585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37586      if (!m) return null;
37587      const [, slug, p, mv, title] = m;
37588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
37589    }
37590
37591    function isCompleted(canonicalId, completions) {
37592      if (completions.includes(canonicalId)) return true;
37593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
37594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
37595    }
37596
37597    function migrateCompletions() {
37598      const completions = getCompletions();
37599      let changed = false;
37600      const migrated = completions.map((c) => {
37601        const canon = canonicalFromStored(c);
37602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
37603        const plusOne = canonicalPlusOne(c);
37604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
37605          if (plusOne !== c) changed = true;
37606          return plusOne;
37607        }
37608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
37609          changed = true;
37610          return canon;
37611        }
37612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
37613        if (canon !== c) {
37614          changed = true;
37615          return canon;
37616        }
37617        return c;
37618      });
37619      // Elimina duplicados tras migración
37620      const deduped = [...new Set(migrated)];
37621      if (changed || deduped.length !== completions.length) {
37622        saveCompletions(deduped);
37623        return deduped;
37624      }
37625      return completions;
37626    }
37627
37628    function updateButtonState(btn, completions) {
37629      const id = btn.dataset.completionId;
37630      const list = completions ?? getCompletions();
37631      btn.classList.toggle("completed", isCompleted(id, list));
37632    }
37633
37634    function checkModuleCompletion(completions) {
37635      const list = completions ?? getCompletions();
37636      document.querySelectorAll(".course-video").forEach((module) => {
37637        const btns = module.querySelectorAll(".complete-btn");
37638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
37639        const check = module.querySelector(".module-check");
37640        if (check) check.classList.toggle("visible", allCompleted);
37641      });
37642    }
37643
37644    function initCompletionButtons() {
37645      const completions = migrateCompletions();
37646
37647      document.querySelectorAll(".complete-btn").forEach((btn) => {
37648        if (btn.dataset.completionInit) return;
37649        btn.dataset.completionInit = "true";
37650        updateButtonState(btn, completions);
37651
37652        btn.addEventListener("click", (e) => {
37653          e.preventDefault();
37654          e.stopPropagation();
37655
37656          const id = btn.dataset.completionId;
37657          let list = getCompletions();
37658          const completed = isCompleted(id, list);
37659
37660          if (completed) {
37661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
37662            list = list.filter((c) =>
37662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
37663          } else {
37664            list.push(id);
37665          }
37666
37667          saveCompletions(list);
37668          updateButtonState(btn, list);
37669          checkModuleCompletion(list);
37670        });
37671      });
37672
37673      checkModuleCompletion(completions);
37674    }
37675
37676    if (document.readyState === "loading") {
37677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
37678    } else {
37679      initCompletionButtons();
37680    }
37681  })();
37682</script>
37682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="5922" data-end="6906" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Rutas, CDN y Bare imports</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-rutas-cdn-y-bare-imports" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
37682<script>
37683  (() => {
37684    if (window.__courseLazyPlayersInitialized) return;
37685    window.__courseLazyPlayersInitialized = true;
37686
37687    function createPlayer(details) {
37688      const videoId = details.dataset.videoId;
37689      const start = details.dataset.start;
37690      const end = details.dataset.end;
37691
37692      const container = document.createElement("div");
37693      container.className = "player-container";
37694
37695      const spinner = document.createElement("div");
37696      spinner.className = "spinner";
37697      container.appendChild(spinner);
37698
37699      const controller = document.createElement("media-controller");
37700      controller.setAttribute("defaultsubtitles", "");
37701
37702      const provider = document.createElement("youtube-media-provider");
37703      provider.setAttribute("slot", "media");
37704      provider.setAttribute("video-id", videoId);
37705      provider.setAttribute("start", start);
37706      provider.setAttribute("end", end);
37707
37708      const controlBar = document.createElement("media-control-bar");
37709
37710      const playBtn = document.createElement("media-play-button");
37711      const timeDisplay = document.createElement("media-time-display");
37712      timeDisplay.setAttribute("showduration", "");
37713      const timeRange = document.createElement("media-time-range");
37714      const playbackRate = document.createElement("media-playback-rate-button");
37715      const muteBtn = document.createElement("media-mute-button");
37716      const volumeRange = document.createElement("media-volume-range");
37717
37718      const captionsBtn = document.createElement("media-captions-button");
37719      captionsBtn.addEventListener("click", (e) => {
37720        e.stopPropagation();
37721        const provider = container.querySelector("youtube-media-provider");
37722        if (provider) provider.toggleCaptions();
37723      });
37724
37725      const qualityBtn = document.createElement("media-chrome-button");
37726      qualityBtn.textContent = "--";
37727      qualityBtn.addEventListener("click", () => {
37728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
37729      });
37730
37731      const fullscreenBtn = document.createElement("media-fullscreen-button");
37732
37733      controlBar.append(
37734        playBtn, timeDisplay, timeRange,
37735        playbackRate, muteBtn, volumeRange,
37736        captionsBtn, qualityBtn, fullscreenBtn
37737      );
37738
37739      const poster = document.createElement("media-poster-image");
37740      poster.setAttribute("slot", "poster");
37741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
37742
37743      controller.append(provider, poster, controlBar);
37744
37745      captionsBtn.setAttribute("aria-checked", "true");
37746      controller.setAttribute("mediasubtitlesshowing", "on");
37747
37748      container.appendChild(controller);
37749      details.appendChild(container);
37750
37751      provider.addEventListener("waiting", () => container.classList.add("loading"));
37752      provider.addEventListener("playing", () => container.classList.remove("loading"));
37753      provider.addEventListener("play", () => container.classList.remove("loading"));
37754
37755      provider._textTracks.addEventListener("change", () => {
37756        const enabled = provider._captionsEnabled;
37757        captionsBtn.setAttribute("aria-checked", enabled);
37758        if (enabled) {
37759          controller.setAttribute("mediasubtitlesshowing", "on");
37760        } else {
37761          controller.removeAttribute("mediasubtitlesshowing");
37762        }
37763      });
37764
37765      provider.addEventListener("qualitychange", (e) => {
37766        qualityBtn.textContent = e.detail.quality;
37767      });
37768    }
37769
37770    function initLazyPlayers() {
37771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
37772        if (details.dataset.lazyInit) return;
37773        details.dataset.lazyInit = "true";
37774        details.addEventListener("toggle", () => {
37775          if (details.open && !details.querySelector(".player-container")) {
37776            createPlayer(details);
37777          } else if (!details.open) {
37778            const provider = details.querySelector("youtube-media-provider");
37779            if (provider && typeof provider.pause === "function") {
37780              provider.pause();
37781            }
37782          }
37783        });
37784      });
37785    }
37786
37787    if (document.readyState === "loading") {
37788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
37789    } else {
37790      initLazyPlayers();
37791    }
37792  })();
37793</script>
37793<script>
37794  (() => {
37795    if (window.__courseCompletionsInitialized) return;
37796    window.__courseCompletionsInitialized = true;
37797
37798    const STORAGE_KEY = "course-completions";
37799
37800    function slugify(text) {
37801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
37802    }
37803
37804    function getCompletions() {
37805      try {
37806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
37807      } catch {
37808        return [];
37809      }
37810    }
37811
37812    function saveCompletions(completions) {
37813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
37814    }
37815
37816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
37817    function canonicalFromStored(storedId) {
37818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37819      if (!m) return slugify(storedId);
37820      const [, slug, p, mv, title] = m;
37821      return `${slug}-${p}-${mv}-${slugify(title)}`;
37822    }
37823
37824    function canonicalPlusOne(storedId) {
37825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
37826      if (!m) return null;
37827      const [, slug, p, mv, title] = m;
37828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
37829    }
37830
37831    function isCompleted(canonicalId, completions) {
37832      if (completions.includes(canonicalId)) return true;
37833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
37834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
37835    }
37836
37837    function migrateCompletions() {
37838      const completions = getCompletions();
37839      let changed = false;
37840      const migrated = completions.map((c) => {
37841        const canon = canonicalFromStored(c);
37842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
37843        const plusOne = canonicalPlusOne(c);
37844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
37845          if (plusOne !== c) changed = true;
37846          return plusOne;
37847        }
37848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
37849          changed = true;
37850          return canon;
37851        }
37852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
37853        if (canon !== c) {
37854          changed = true;
37855          return canon;
37856        }
37857        return c;
37858      });
37859      // Elimina duplicados tras migración
37860      const deduped = [...new Set(migrated)];
37861      if (changed || deduped.length !== completions.length) {
37862        saveCompletions(deduped);
37863        return deduped;
37864      }
37865      return completions;
37866    }
37867
37868    function updateButtonState(btn, completions) {
37869      const id = btn.dataset.completionId;
37870      const list = completions ?? getCompletions();
37871      btn.classList.toggle("completed", isCompleted(id, list));
37872    }
37873
37874    function checkModuleCompletion(completions) {
37875      const list = completions ?? getCompletions();
37876      document.querySelectorAll(".course-video").forEach((module) => {
37877        const btns = module.querySelectorAll(".complete-btn");
37878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
37879        const check = module.querySelector(".module-check");
37880        if (check) check.classList.toggle("visible", allCompleted);
37881      });
37882    }
37883
37884    function initCompletionButtons() {
37885      const completions = migrateCompletions();
37886
37887      document.querySelectorAll(".complete-btn").forEach((btn) => {
37888        if (btn.dataset.completionInit) return;
37889        btn.dataset.completionInit = "true";
37890        updateButtonState(btn, completions);
37891
37892        btn.addEventListener("click", (e) => {
37893          e.preventDefault();
37894          e.stopPropagation();
37895
37896          const id = btn.dataset.completionId;
37897          let list = getCompletions();
37898          const completed = isCompleted(id, list);
37899
37900          if (completed) {
37901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
37902            list = list.filter((c) =>
37902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
37903          } else {
37904            list.push(id);
37905          }
37906
37907          saveCompletions(list);
37908          updateButtonState(btn, list);
37909          checkModuleCompletion(list);
37910        });
37911      });
37912
37913      checkModuleCompletion(completions);
37914    }
37915
37916    if (document.readyState === "loading") {
37917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
37918    } else {
37919      initCompletionButtons();
37920    }
37921  })();
37922</script>
37922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="6906" data-end="7333" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Imports Maps</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-imports-maps" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
37922<script>
37923  (() => {
37924    if (window.__courseLazyPlayersInitialized) return;
37925    window.__courseLazyPlayersInitialized = true;
37926
37927    function createPlayer(details) {
37928      const videoId = details.dataset.videoId;
37929      const start = details.dataset.start;
37930      const end = details.dataset.end;
37931
37932      const container = document.createElement("div");
37933      container.className = "player-container";
37934
37935      const spinner = document.createElement("div");
37936      spinner.className = "spinner";
37937      container.appendChild(spinner);
37938
37939      const controller = document.createElement("media-controller");
37940      controller.setAttribute("defaultsubtitles", "");
37941
37942      const provider = document.createElement("youtube-media-provider");
37943      provider.setAttribute("slot", "media");
37944      provider.setAttribute("video-id", videoId);
37945      provider.setAttribute("start", start);
37946      provider.setAttribute("end", end);
37947
37948      const controlBar = document.createElement("media-control-bar");
37949
37950      const playBtn = document.createElement("media-play-button");
37951      const timeDisplay = document.createElement("media-time-display");
37952      timeDisplay.setAttribute("showduration", "");
37953      const timeRange = document.createElement("media-time-range");
37954      const playbackRate = document.createElement("media-playback-rate-button");
37955      const muteBtn = document.createElement("media-mute-button");
37956      const volumeRange = document.createElement("media-volume-range");
37957
37958      const captionsBtn = document.createElement("media-captions-button");
37959      captionsBtn.addEventListener("click", (e) => {
37960        e.stopPropagation();
37961        const provider = container.querySelector("youtube-media-provider");
37962        if (provider) provider.toggleCaptions();
37963      });
37964
37965      const qualityBtn = document.createElement("media-chrome-button");
37966      qualityBtn.textContent = "--";
37967      qualityBtn.addEventListener("click", () => {
37968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
37969      });
37970
37971      const fullscreenBtn = document.createElement("media-fullscreen-button");
37972
37973      controlBar.append(
37974        playBtn, timeDisplay, timeRange,
37975        playbackRate, muteBtn, volumeRange,
37976        captionsBtn, qualityBtn, fullscreenBtn
37977      );
37978
37979      const poster = document.createElement("media-poster-image");
37980      poster.setAttribute("slot", "poster");
37981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
37982
37983      controller.append(provider, poster, controlBar);
37984
37985      captionsBtn.setAttribute("aria-checked", "true");
37986      controller.setAttribute("mediasubtitlesshowing", "on");
37987
37988      container.appendChild(controller);
37989      details.appendChild(container);
37990
37991      provider.addEventListener("waiting", () => container.classList.add("loading"));
37992      provider.addEventListener("playing", () => container.classList.remove("loading"));
37993      provider.addEventListener("play", () => container.classList.remove("loading"));
37994
37995      provider._textTracks.addEventListener("change", () => {
37996        const enabled = provider._captionsEnabled;
37997        captionsBtn.setAttribute("aria-checked", enabled);
37998        if (enabled) {
37999          controller.setAttribute("mediasubtitlesshowing", "on");
38000        } else {
38001          controller.removeAttribute("mediasubtitlesshowing");
38002        }
38003      });
38004
38005      provider.addEventListener("qualitychange", (e) => {
38006        qualityBtn.textContent = e.detail.quality;
38007      });
38008    }
38009
38010    function initLazyPlayers() {
38011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
38012        if (details.dataset.lazyInit) return;
38013        details.dataset.lazyInit = "true";
38014        details.addEventListener("toggle", () => {
38015          if (details.open && !details.querySelector(".player-container")) {
38016            createPlayer(details);
38017          } else if (!details.open) {
38018            const provider = details.querySelector("youtube-media-provider");
38019            if (provider && typeof provider.pause === "function") {
38020              provider.pause();
38021            }
38022          }
38023        });
38024      });
38025    }
38026
38027    if (document.readyState === "loading") {
38028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
38029    } else {
38030      initLazyPlayers();
38031    }
38032  })();
38033</script>
38033<script>
38034  (() => {
38035    if (window.__courseCompletionsInitialized) return;
38036    window.__courseCompletionsInitialized = true;
38037
38038    const STORAGE_KEY = "course-completions";
38039
38040    function slugify(text) {
38041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
38042    }
38043
38044    function getCompletions() {
38045      try {
38046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
38047      } catch {
38048        return [];
38049      }
38050    }
38051
38052    function saveCompletions(completions) {
38053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
38054    }
38055
38056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
38057    function canonicalFromStored(storedId) {
38058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38059      if (!m) return slugify(storedId);
38060      const [, slug, p, mv, title] = m;
38061      return `${slug}-${p}-${mv}-${slugify(title)}`;
38062    }
38063
38064    function canonicalPlusOne(storedId) {
38065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38066      if (!m) return null;
38067      const [, slug, p, mv, title] = m;
38068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
38069    }
38070
38071    function isCompleted(canonicalId, completions) {
38072      if (completions.includes(canonicalId)) return true;
38073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
38074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
38075    }
38076
38077    function migrateCompletions() {
38078      const completions = getCompletions();
38079      let changed = false;
38080      const migrated = completions.map((c) => {
38081        const canon = canonicalFromStored(c);
38082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
38083        const plusOne = canonicalPlusOne(c);
38084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
38085          if (plusOne !== c) changed = true;
38086          return plusOne;
38087        }
38088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
38089          changed = true;
38090          return canon;
38091        }
38092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
38093        if (canon !== c) {
38094          changed = true;
38095          return canon;
38096        }
38097        return c;
38098      });
38099      // Elimina duplicados tras migración
38100      const deduped = [...new Set(migrated)];
38101      if (changed || deduped.length !== completions.length) {
38102        saveCompletions(deduped);
38103        return deduped;
38104      }
38105      return completions;
38106    }
38107
38108    function updateButtonState(btn, completions) {
38109      const id = btn.dataset.completionId;
38110      const list = completions ?? getCompletions();
38111      btn.classList.toggle("completed", isCompleted(id, list));
38112    }
38113
38114    function checkModuleCompletion(completions) {
38115      const list = completions ?? getCompletions();
38116      document.querySelectorAll(".course-video").forEach((module) => {
38117        const btns = module.querySelectorAll(".complete-btn");
38118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
38119        const check = module.querySelector(".module-check");
38120        if (check) check.classList.toggle("visible", allCompleted);
38121      });
38122    }
38123
38124    function initCompletionButtons() {
38125      const completions = migrateCompletions();
38126
38127      document.querySelectorAll(".complete-btn").forEach((btn) => {
38128        if (btn.dataset.completionInit) return;
38129        btn.dataset.completionInit = "true";
38130        updateButtonState(btn, completions);
38131
38132        btn.addEventListener("click", (e) => {
38133          e.preventDefault();
38134          e.stopPropagation();
38135
38136          const id = btn.dataset.completionId;
38137          let list = getCompletions();
38138          const completed = isCompleted(id, list);
38139
38140          if (completed) {
38141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
38142            list = list.filter((c) =>
38142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
38143          } else {
38144            list.push(id);
38145          }
38146
38147          saveCompletions(list);
38148          updateButtonState(btn, list);
38149          checkModuleCompletion(list);
38150        });
38151      });
38152
38153      checkModuleCompletion(completions);
38154    }
38155
38156    if (document.readyState === "loading") {
38157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
38158    } else {
38159      initCompletionButtons();
38160    }
38161  })();
38162</script>
38162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="7333" data-end="7820" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Barrels</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-barrels" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
38162<script>
38163  (() => {
38164    if (window.__courseLazyPlayersInitialized) return;
38165    window.__courseLazyPlayersInitialized = true;
38166
38167    function createPlayer(details) {
38168      const videoId = details.dataset.videoId;
38169      const start = details.dataset.start;
38170      const end = details.dataset.end;
38171
38172      const container = document.createElement("div");
38173      container.className = "player-container";
38174
38175      const spinner = document.createElement("div");
38176      spinner.className = "spinner";
38177      container.appendChild(spinner);
38178
38179      const controller = document.createElement("media-controller");
38180      controller.setAttribute("defaultsubtitles", "");
38181
38182      const provider = document.createElement("youtube-media-provider");
38183      provider.setAttribute("slot", "media");
38184      provider.setAttribute("video-id", videoId);
38185      provider.setAttribute("start", start);
38186      provider.setAttribute("end", end);
38187
38188      const controlBar = document.createElement("media-control-bar");
38189
38190      const playBtn = document.createElement("media-play-button");
38191      const timeDisplay = document.createElement("media-time-display");
38192      timeDisplay.setAttribute("showduration", "");
38193      const timeRange = document.createElement("media-time-range");
38194      const playbackRate = document.createElement("media-playback-rate-button");
38195      const muteBtn = document.createElement("media-mute-button");
38196      const volumeRange = document.createElement("media-volume-range");
38197
38198      const captionsBtn = document.createElement("media-captions-button");
38199      captionsBtn.addEventListener("click", (e) => {
38200        e.stopPropagation();
38201        const provider = container.querySelector("youtube-media-provider");
38202        if (provider) provider.toggleCaptions();
38203      });
38204
38205      const qualityBtn = document.createElement("media-chrome-button");
38206      qualityBtn.textContent = "--";
38207      qualityBtn.addEventListener("click", () => {
38208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
38209      });
38210
38211      const fullscreenBtn = document.createElement("media-fullscreen-button");
38212
38213      controlBar.append(
38214        playBtn, timeDisplay, timeRange,
38215        playbackRate, muteBtn, volumeRange,
38216        captionsBtn, qualityBtn, fullscreenBtn
38217      );
38218
38219      const poster = document.createElement("media-poster-image");
38220      poster.setAttribute("slot", "poster");
38221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
38222
38223      controller.append(provider, poster, controlBar);
38224
38225      captionsBtn.setAttribute("aria-checked", "true");
38226      controller.setAttribute("mediasubtitlesshowing", "on");
38227
38228      container.appendChild(controller);
38229      details.appendChild(container);
38230
38231      provider.addEventListener("waiting", () => container.classList.add("loading"));
38232      provider.addEventListener("playing", () => container.classList.remove("loading"));
38233      provider.addEventListener("play", () => container.classList.remove("loading"));
38234
38235      provider._textTracks.addEventListener("change", () => {
38236        const enabled = provider._captionsEnabled;
38237        captionsBtn.setAttribute("aria-checked", enabled);
38238        if (enabled) {
38239          controller.setAttribute("mediasubtitlesshowing", "on");
38240        } else {
38241          controller.removeAttribute("mediasubtitlesshowing");
38242        }
38243      });
38244
38245      provider.addEventListener("qualitychange", (e) => {
38246        qualityBtn.textContent = e.detail.quality;
38247      });
38248    }
38249
38250    function initLazyPlayers() {
38251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
38252        if (details.dataset.lazyInit) return;
38253        details.dataset.lazyInit = "true";
38254        details.addEventListener("toggle", () => {
38255          if (details.open && !details.querySelector(".player-container")) {
38256            createPlayer(details);
38257          } else if (!details.open) {
38258            const provider = details.querySelector("youtube-media-provider");
38259            if (provider && typeof provider.pause === "function") {
38260              provider.pause();
38261            }
38262          }
38263        });
38264      });
38265    }
38266
38267    if (document.readyState === "loading") {
38268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
38269    } else {
38270      initLazyPlayers();
38271    }
38272  })();
38273</script>
38273<script>
38274  (() => {
38275    if (window.__courseCompletionsInitialized) return;
38276    window.__courseCompletionsInitialized = true;
38277
38278    const STORAGE_KEY = "course-completions";
38279
38280    function slugify(text) {
38281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
38282    }
38283
38284    function getCompletions() {
38285      try {
38286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
38287      } catch {
38288        return [];
38289      }
38290    }
38291
38292    function saveCompletions(completions) {
38293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
38294    }
38295
38296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
38297    function canonicalFromStored(storedId) {
38298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38299      if (!m) return slugify(storedId);
38300      const [, slug, p, mv, title] = m;
38301      return `${slug}-${p}-${mv}-${slugify(title)}`;
38302    }
38303
38304    function canonicalPlusOne(storedId) {
38305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38306      if (!m) return null;
38307      const [, slug, p, mv, title] = m;
38308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
38309    }
38310
38311    function isCompleted(canonicalId, completions) {
38312      if (completions.includes(canonicalId)) return true;
38313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
38314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
38315    }
38316
38317    function migrateCompletions() {
38318      const completions = getCompletions();
38319      let changed = false;
38320      const migrated = completions.map((c) => {
38321        const canon = canonicalFromStored(c);
38322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
38323        const plusOne = canonicalPlusOne(c);
38324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
38325          if (plusOne !== c) changed = true;
38326          return plusOne;
38327        }
38328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
38329          changed = true;
38330          return canon;
38331        }
38332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
38333        if (canon !== c) {
38334          changed = true;
38335          return canon;
38336        }
38337        return c;
38338      });
38339      // Elimina duplicados tras migración
38340      const deduped = [...new Set(migrated)];
38341      if (changed || deduped.length !== completions.length) {
38342        saveCompletions(deduped);
38343        return deduped;
38344      }
38345      return completions;
38346    }
38347
38348    function updateButtonState(btn, completions) {
38349      const id = btn.dataset.completionId;
38350      const list = completions ?? getCompletions();
38351      btn.classList.toggle("completed", isCompleted(id, list));
38352    }
38353
38354    function checkModuleCompletion(completions) {
38355      const list = completions ?? getCompletions();
38356      document.querySelectorAll(".course-video").forEach((module) => {
38357        const btns = module.querySelectorAll(".complete-btn");
38358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
38359        const check = module.querySelector(".module-check");
38360        if (check) check.classList.toggle("visible", allCompleted);
38361      });
38362    }
38363
38364    function initCompletionButtons() {
38365      const completions = migrateCompletions();
38366
38367      document.querySelectorAll(".complete-btn").forEach((btn) => {
38368        if (btn.dataset.completionInit) return;
38369        btn.dataset.completionInit = "true";
38370        updateButtonState(btn, completions);
38371
38372        btn.addEventListener("click", (e) => {
38373          e.preventDefault();
38374          e.stopPropagation();
38375
38376          const id = btn.dataset.completionId;
38377          let list = getCompletions();
38378          const completed = isCompleted(id, list);
38379
38380          if (completed) {
38381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
38382            list = list.filter((c) =>
38382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
38383          } else {
38384            list.push(id);
38385          }
38386
38387          saveCompletions(list);
38388          updateButtonState(btn, list);
38389          checkModuleCompletion(list);
38390        });
38391      });
38392
38393      checkModuleCompletion(completions);
38394    }
38395
38396    if (document.readyState === "loading") {
38397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
38398    } else {
38399      initCompletionButtons();
38400    }
38401  })();
38402</script>
38402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="7820" data-end="9388" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Import estáticos vs Import dinámicos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>26 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-import-estaticos-vs-import-dinamicos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
38402<script>
38403  (() => {
38404    if (window.__courseLazyPlayersInitialized) return;
38405    window.__courseLazyPlayersInitialized = true;
38406
38407    function createPlayer(details) {
38408      const videoId = details.dataset.videoId;
38409      const start = details.dataset.start;
38410      const end = details.dataset.end;
38411
38412      const container = document.createElement("div");
38413      container.className = "player-container";
38414
38415      const spinner = document.createElement("div");
38416      spinner.className = "spinner";
38417      container.appendChild(spinner);
38418
38419      const controller = document.createElement("media-controller");
38420      controller.setAttribute("defaultsubtitles", "");
38421
38422      const provider = document.createElement("youtube-media-provider");
38423      provider.setAttribute("slot", "media");
38424      provider.setAttribute("video-id", videoId);
38425      provider.setAttribute("start", start);
38426      provider.setAttribute("end", end);
38427
38428      const controlBar = document.createElement("media-control-bar");
38429
38430      const playBtn = document.createElement("media-play-button");
38431      const timeDisplay = document.createElement("media-time-display");
38432      timeDisplay.setAttribute("showduration", "");
38433      const timeRange = document.createElement("media-time-range");
38434      const playbackRate = document.createElement("media-playback-rate-button");
38435      const muteBtn = document.createElement("media-mute-button");
38436      const volumeRange = document.createElement("media-volume-range");
38437
38438      const captionsBtn = document.createElement("media-captions-button");
38439      captionsBtn.addEventListener("click", (e) => {
38440        e.stopPropagation();
38441        const provider = container.querySelector("youtube-media-provider");
38442        if (provider) provider.toggleCaptions();
38443      });
38444
38445      const qualityBtn = document.createElement("media-chrome-button");
38446      qualityBtn.textContent = "--";
38447      qualityBtn.addEventListener("click", () => {
38448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
38449      });
38450
38451      const fullscreenBtn = document.createElement("media-fullscreen-button");
38452
38453      controlBar.append(
38454        playBtn, timeDisplay, timeRange,
38455        playbackRate, muteBtn, volumeRange,
38456        captionsBtn, qualityBtn, fullscreenBtn
38457      );
38458
38459      const poster = document.createElement("media-poster-image");
38460      poster.setAttribute("slot", "poster");
38461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
38462
38463      controller.append(provider, poster, controlBar);
38464
38465      captionsBtn.setAttribute("aria-checked", "true");
38466      controller.setAttribute("mediasubtitlesshowing", "on");
38467
38468      container.appendChild(controller);
38469      details.appendChild(container);
38470
38471      provider.addEventListener("waiting", () => container.classList.add("loading"));
38472      provider.addEventListener("playing", () => container.classList.remove("loading"));
38473      provider.addEventListener("play", () => container.classList.remove("loading"));
38474
38475      provider._textTracks.addEventListener("change", () => {
38476        const enabled = provider._captionsEnabled;
38477        captionsBtn.setAttribute("aria-checked", enabled);
38478        if (enabled) {
38479          controller.setAttribute("mediasubtitlesshowing", "on");
38480        } else {
38481          controller.removeAttribute("mediasubtitlesshowing");
38482        }
38483      });
38484
38485      provider.addEventListener("qualitychange", (e) => {
38486        qualityBtn.textContent = e.detail.quality;
38487      });
38488    }
38489
38490    function initLazyPlayers() {
38491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
38492        if (details.dataset.lazyInit) return;
38493        details.dataset.lazyInit = "true";
38494        details.addEventListener("toggle", () => {
38495          if (details.open && !details.querySelector(".player-container")) {
38496            createPlayer(details);
38497          } else if (!details.open) {
38498            const provider = details.querySelector("youtube-media-provider");
38499            if (provider && typeof provider.pause === "function") {
38500              provider.pause();
38501            }
38502          }
38503        });
38504      });
38505    }
38506
38507    if (document.readyState === "loading") {
38508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
38509    } else {
38510      initLazyPlayers();
38511    }
38512  })();
38513</script>
38513<script>
38514  (() => {
38515    if (window.__courseCompletionsInitialized) return;
38516    window.__courseCompletionsInitialized = true;
38517
38518    const STORAGE_KEY = "course-completions";
38519
38520    function slugify(text) {
38521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
38522    }
38523
38524    function getCompletions() {
38525      try {
38526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
38527      } catch {
38528        return [];
38529      }
38530    }
38531
38532    function saveCompletions(completions) {
38533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
38534    }
38535
38536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
38537    function canonicalFromStored(storedId) {
38538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38539      if (!m) return slugify(storedId);
38540      const [, slug, p, mv, title] = m;
38541      return `${slug}-${p}-${mv}-${slugify(title)}`;
38542    }
38543
38544    function canonicalPlusOne(storedId) {
38545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38546      if (!m) return null;
38547      const [, slug, p, mv, title] = m;
38548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
38549    }
38550
38551    function isCompleted(canonicalId, completions) {
38552      if (completions.includes(canonicalId)) return true;
38553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
38554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
38555    }
38556
38557    function migrateCompletions() {
38558      const completions = getCompletions();
38559      let changed = false;
38560      const migrated = completions.map((c) => {
38561        const canon = canonicalFromStored(c);
38562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
38563        const plusOne = canonicalPlusOne(c);
38564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
38565          if (plusOne !== c) changed = true;
38566          return plusOne;
38567        }
38568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
38569          changed = true;
38570          return canon;
38571        }
38572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
38573        if (canon !== c) {
38574          changed = true;
38575          return canon;
38576        }
38577        return c;
38578      });
38579      // Elimina duplicados tras migración
38580      const deduped = [...new Set(migrated)];
38581      if (changed || deduped.length !== completions.length) {
38582        saveCompletions(deduped);
38583        return deduped;
38584      }
38585      return completions;
38586    }
38587
38588    function updateButtonState(btn, completions) {
38589      const id = btn.dataset.completionId;
38590      const list = completions ?? getCompletions();
38591      btn.classList.toggle("completed", isCompleted(id, list));
38592    }
38593
38594    function checkModuleCompletion(completions) {
38595      const list = completions ?? getCompletions();
38596      document.querySelectorAll(".course-video").forEach((module) => {
38597        const btns = module.querySelectorAll(".complete-btn");
38598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
38599        const check = module.querySelector(".module-check");
38600        if (check) check.classList.toggle("visible", allCompleted);
38601      });
38602    }
38603
38604    function initCompletionButtons() {
38605      const completions = migrateCompletions();
38606
38607      document.querySelectorAll(".complete-btn").forEach((btn) => {
38608        if (btn.dataset.completionInit) return;
38609        btn.dataset.completionInit = "true";
38610        updateButtonState(btn, completions);
38611
38612        btn.addEventListener("click", (e) => {
38613          e.preventDefault();
38614          e.stopPropagation();
38615
38616          const id = btn.dataset.completionId;
38617          let list = getCompletions();
38618          const completed = isCompleted(id, list);
38619
38620          if (completed) {
38621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
38622            list = list.filter((c) =>
38622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
38623          } else {
38624            list.push(id);
38625          }
38626
38627          saveCompletions(list);
38628          updateButtonState(btn, list);
38629          checkModuleCompletion(list);
38630        });
38631      });
38632
38633      checkModuleCompletion(completions);
38634    }
38635
38636    if (document.readyState === "loading") {
38637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
38638    } else {
38639      initCompletionButtons();
38640    }
38641  })();
38642</script>
38642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="9645" data-end="10215" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Patrón nomodule + modos import</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>10 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-patron-nomodule-modos-import" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
38642<script>
38643  (() => {
38644    if (window.__courseLazyPlayersInitialized) return;
38645    window.__courseLazyPlayersInitialized = true;
38646
38647    function createPlayer(details) {
38648      const videoId = details.dataset.videoId;
38649      const start = details.dataset.start;
38650      const end = details.dataset.end;
38651
38652      const container = document.createElement("div");
38653      container.className = "player-container";
38654
38655      const spinner = document.createElement("div");
38656      spinner.className = "spinner";
38657      container.appendChild(spinner);
38658
38659      const controller = document.createElement("media-controller");
38660      controller.setAttribute("defaultsubtitles", "");
38661
38662      const provider = document.createElement("youtube-media-provider");
38663      provider.setAttribute("slot", "media");
38664      provider.setAttribute("video-id", videoId);
38665      provider.setAttribute("start", start);
38666      provider.setAttribute("end", end);
38667
38668      const controlBar = document.createElement("media-control-bar");
38669
38670      const playBtn = document.createElement("media-play-button");
38671      const timeDisplay = document.createElement("media-time-display");
38672      timeDisplay.setAttribute("showduration", "");
38673      const timeRange = document.createElement("media-time-range");
38674      const playbackRate = document.createElement("media-playback-rate-button");
38675      const muteBtn = document.createElement("media-mute-button");
38676      const volumeRange = document.createElement("media-volume-range");
38677
38678      const captionsBtn = document.createElement("media-captions-button");
38679      captionsBtn.addEventListener("click", (e) => {
38680        e.stopPropagation();
38681        const provider = container.querySelector("youtube-media-provider");
38682        if (provider) provider.toggleCaptions();
38683      });
38684
38685      const qualityBtn = document.createElement("media-chrome-button");
38686      qualityBtn.textContent = "--";
38687      qualityBtn.addEventListener("click", () => {
38688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
38689      });
38690
38691      const fullscreenBtn = document.createElement("media-fullscreen-button");
38692
38693      controlBar.append(
38694        playBtn, timeDisplay, timeRange,
38695        playbackRate, muteBtn, volumeRange,
38696        captionsBtn, qualityBtn, fullscreenBtn
38697      );
38698
38699      const poster = document.createElement("media-poster-image");
38700      poster.setAttribute("slot", "poster");
38701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
38702
38703      controller.append(provider, poster, controlBar);
38704
38705      captionsBtn.setAttribute("aria-checked", "true");
38706      controller.setAttribute("mediasubtitlesshowing", "on");
38707
38708      container.appendChild(controller);
38709      details.appendChild(container);
38710
38711      provider.addEventListener("waiting", () => container.classList.add("loading"));
38712      provider.addEventListener("playing", () => container.classList.remove("loading"));
38713      provider.addEventListener("play", () => container.classList.remove("loading"));
38714
38715      provider._textTracks.addEventListener("change", () => {
38716        const enabled = provider._captionsEnabled;
38717        captionsBtn.setAttribute("aria-checked", enabled);
38718        if (enabled) {
38719          controller.setAttribute("mediasubtitlesshowing", "on");
38720        } else {
38721          controller.removeAttribute("mediasubtitlesshowing");
38722        }
38723      });
38724
38725      provider.addEventListener("qualitychange", (e) => {
38726        qualityBtn.textContent = e.detail.quality;
38727      });
38728    }
38729
38730    function initLazyPlayers() {
38731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
38732        if (details.dataset.lazyInit) return;
38733        details.dataset.lazyInit = "true";
38734        details.addEventListener("toggle", () => {
38735          if (details.open && !details.querySelector(".player-container")) {
38736            createPlayer(details);
38737          } else if (!details.open) {
38738            const provider = details.querySelector("youtube-media-provider");
38739            if (provider && typeof provider.pause === "function") {
38740              provider.pause();
38741            }
38742          }
38743        });
38744      });
38745    }
38746
38747    if (document.readyState === "loading") {
38748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
38749    } else {
38750      initLazyPlayers();
38751    }
38752  })();
38753</script>
38753<script>
38754  (() => {
38755    if (window.__courseCompletionsInitialized) return;
38756    window.__courseCompletionsInitialized = true;
38757
38758    const STORAGE_KEY = "course-completions";
38759
38760    function slugify(text) {
38761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
38762    }
38763
38764    function getCompletions() {
38765      try {
38766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
38767      } catch {
38768        return [];
38769      }
38770    }
38771
38772    function saveCompletions(completions) {
38773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
38774    }
38775
38776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
38777    function canonicalFromStored(storedId) {
38778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38779      if (!m) return slugify(storedId);
38780      const [, slug, p, mv, title] = m;
38781      return `${slug}-${p}-${mv}-${slugify(title)}`;
38782    }
38783
38784    function canonicalPlusOne(storedId) {
38785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
38786      if (!m) return null;
38787      const [, slug, p, mv, title] = m;
38788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
38789    }
38790
38791    function isCompleted(canonicalId, completions) {
38792      if (completions.includes(canonicalId)) return true;
38793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
38794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
38795    }
38796
38797    function migrateCompletions() {
38798      const completions = getCompletions();
38799      let changed = false;
38800      const migrated = completions.map((c) => {
38801        const canon = canonicalFromStored(c);
38802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
38803        const plusOne = canonicalPlusOne(c);
38804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
38805          if (plusOne !== c) changed = true;
38806          return plusOne;
38807        }
38808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
38809          changed = true;
38810          return canon;
38811        }
38812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
38813        if (canon !== c) {
38814          changed = true;
38815          return canon;
38816        }
38817        return c;
38818      });
38819      // Elimina duplicados tras migración
38820      const deduped = [...new Set(migrated)];
38821      if (changed || deduped.length !== completions.length) {
38822        saveCompletions(deduped);
38823        return deduped;
38824      }
38825      return completions;
38826    }
38827
38828    function updateButtonState(btn, completions) {
38829      const id = btn.dataset.completionId;
38830      const list = completions ?? getCompletions();
38831      btn.classList.toggle("completed", isCompleted(id, list));
38832    }
38833
38834    function checkModuleCompletion(completions) {
38835      const list = completions ?? getCompletions();
38836      document.querySelectorAll(".course-video").forEach((module) => {
38837        const btns = module.querySelectorAll(".complete-btn");
38838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
38839        const check = module.querySelector(".module-check");
38840        if (check) check.classList.toggle("visible", allCompleted);
38841      });
38842    }
38843
38844    function initCompletionButtons() {
38845      const completions = migrateCompletions();
38846
38847      document.querySelectorAll(".complete-btn").forEach((btn) => {
38848        if (btn.dataset.completionInit) return;
38849        btn.dataset.completionInit = "true";
38850        updateButtonState(btn, completions);
38851
38852        btn.addEventListener("click", (e) => {
38853          e.preventDefault();
38854          e.stopPropagation();
38855
38856          const id = btn.dataset.completionId;
38857          let list = getCompletions();
38858          const completed = isCompleted(id, list);
38859
38860          if (completed) {
38861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
38862            list = list.filter((c) =>
38862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
38863          } else {
38864            list.push(id);
38865          }
38866
38867          saveCompletions(list);
38868          updateButtonState(btn, list);
38869          checkModuleCompletion(list);
38870        });
38871      });
38872
38873      checkModuleCompletion(completions);
38874    }
38875
38876    if (document.readyState === "loading") {
38877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
38878    } else {
38879      initCompletionButtons();
38880    }
38881  })();
38882</script>
38882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="10215" data-end="10669" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Gestor de estados + Bus de Eventos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-gestor-de-estados-bus-de-eventos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
38882<script>
38883  (() => {
38884    if (window.__courseLazyPlayersInitialized) return;
38885    window.__courseLazyPlayersInitialized = true;
38886
38887    function createPlayer(details) {
38888      const videoId = details.dataset.videoId;
38889      const start = details.dataset.start;
38890      const end = details.dataset.end;
38891
38892      const container = document.createElement("div");
38893      container.className = "player-container";
38894
38895      const spinner = document.createElement("div");
38896      spinner.className = "spinner";
38897      container.appendChild(spinner);
38898
38899      const controller = document.createElement("media-controller");
38900      controller.setAttribute("defaultsubtitles", "");
38901
38902      const provider = document.createElement("youtube-media-provider");
38903      provider.setAttribute("slot", "media");
38904      provider.setAttribute("video-id", videoId);
38905      provider.setAttribute("start", start);
38906      provider.setAttribute("end", end);
38907
38908      const controlBar = document.createElement("media-control-bar");
38909
38910      const playBtn = document.createElement("media-play-button");
38911      const timeDisplay = document.createElement("media-time-display");
38912      timeDisplay.setAttribute("showduration", "");
38913      const timeRange = document.createElement("media-time-range");
38914      const playbackRate = document.createElement("media-playback-rate-button");
38915      const muteBtn = document.createElement("media-mute-button");
38916      const volumeRange = document.createElement("media-volume-range");
38917
38918      const captionsBtn = document.createElement("media-captions-button");
38919      captionsBtn.addEventListener("click", (e) => {
38920        e.stopPropagation();
38921        const provider = container.querySelector("youtube-media-provider");
38922        if (provider) provider.toggleCaptions();
38923      });
38924
38925      const qualityBtn = document.createElement("media-chrome-button");
38926      qualityBtn.textContent = "--";
38927      qualityBtn.addEventListener("click", () => {
38928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
38929      });
38930
38931      const fullscreenBtn = document.createElement("media-fullscreen-button");
38932
38933      controlBar.append(
38934        playBtn, timeDisplay, timeRange,
38935        playbackRate, muteBtn, volumeRange,
38936        captionsBtn, qualityBtn, fullscreenBtn
38937      );
38938
38939      const poster = document.createElement("media-poster-image");
38940      poster.setAttribute("slot", "poster");
38941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
38942
38943      controller.append(provider, poster, controlBar);
38944
38945      captionsBtn.setAttribute("aria-checked", "true");
38946      controller.setAttribute("mediasubtitlesshowing", "on");
38947
38948      container.appendChild(controller);
38949      details.appendChild(container);
38950
38951      provider.addEventListener("waiting", () => container.classList.add("loading"));
38952      provider.addEventListener("playing", () => container.classList.remove("loading"));
38953      provider.addEventListener("play", () => container.classList.remove("loading"));
38954
38955      provider._textTracks.addEventListener("change", () => {
38956        const enabled = provider._captionsEnabled;
38957        captionsBtn.setAttribute("aria-checked", enabled);
38958        if (enabled) {
38959          controller.setAttribute("mediasubtitlesshowing", "on");
38960        } else {
38961          controller.removeAttribute("mediasubtitlesshowing");
38962        }
38963      });
38964
38965      provider.addEventListener("qualitychange", (e) => {
38966        qualityBtn.textContent = e.detail.quality;
38967      });
38968    }
38969
38970    function initLazyPlayers() {
38971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
38972        if (details.dataset.lazyInit) return;
38973        details.dataset.lazyInit = "true";
38974        details.addEventListener("toggle", () => {
38975          if (details.open && !details.querySelector(".player-container")) {
38976            createPlayer(details);
38977          } else if (!details.open) {
38978            const provider = details.querySelector("youtube-media-provider");
38979            if (provider && typeof provider.pause === "function") {
38980              provider.pause();
38981            }
38982          }
38983        });
38984      });
38985    }
38986
38987    if (document.readyState === "loading") {
38988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
38989    } else {
38990      initLazyPlayers();
38991    }
38992  })();
38993</script>
38993<script>
38994  (() => {
38995    if (window.__courseCompletionsInitialized) return;
38996    window.__courseCompletionsInitialized = true;
38997
38998    const STORAGE_KEY = "course-completions";
38999
39000    function slugify(text) {
39001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39002    }
39003
39004    function getCompletions() {
39005      try {
39006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
39007      } catch {
39008        return [];
39009      }
39010    }
39011
39012    function saveCompletions(completions) {
39013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
39014    }
39015
39016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
39017    function canonicalFromStored(storedId) {
39018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39019      if (!m) return slugify(storedId);
39020      const [, slug, p, mv, title] = m;
39021      return `${slug}-${p}-${mv}-${slugify(title)}`;
39022    }
39023
39024    function canonicalPlusOne(storedId) {
39025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39026      if (!m) return null;
39027      const [, slug, p, mv, title] = m;
39028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
39029    }
39030
39031    function isCompleted(canonicalId, completions) {
39032      if (completions.includes(canonicalId)) return true;
39033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
39034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
39035    }
39036
39037    function migrateCompletions() {
39038      const completions = getCompletions();
39039      let changed = false;
39040      const migrated = completions.map((c) => {
39041        const canon = canonicalFromStored(c);
39042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
39043        const plusOne = canonicalPlusOne(c);
39044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
39045          if (plusOne !== c) changed = true;
39046          return plusOne;
39047        }
39048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
39049          changed = true;
39050          return canon;
39051        }
39052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
39053        if (canon !== c) {
39054          changed = true;
39055          return canon;
39056        }
39057        return c;
39058      });
39059      // Elimina duplicados tras migración
39060      const deduped = [...new Set(migrated)];
39061      if (changed || deduped.length !== completions.length) {
39062        saveCompletions(deduped);
39063        return deduped;
39064      }
39065      return completions;
39066    }
39067
39068    function updateButtonState(btn, completions) {
39069      const id = btn.dataset.completionId;
39070      const list = completions ?? getCompletions();
39071      btn.classList.toggle("completed", isCompleted(id, list));
39072    }
39073
39074    function checkModuleCompletion(completions) {
39075      const list = completions ?? getCompletions();
39076      document.querySelectorAll(".course-video").forEach((module) => {
39077        const btns = module.querySelectorAll(".complete-btn");
39078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
39079        const check = module.querySelector(".module-check");
39080        if (check) check.classList.toggle("visible", allCompleted);
39081      });
39082    }
39083
39084    function initCompletionButtons() {
39085      const completions = migrateCompletions();
39086
39087      document.querySelectorAll(".complete-btn").forEach((btn) => {
39088        if (btn.dataset.completionInit) return;
39089        btn.dataset.completionInit = "true";
39090        updateButtonState(btn, completions);
39091
39092        btn.addEventListener("click", (e) => {
39093          e.preventDefault();
39094          e.stopPropagation();
39095
39096          const id = btn.dataset.completionId;
39097          let list = getCompletions();
39098          const completed = isCompleted(id, list);
39099
39100          if (completed) {
39101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
39102            list = list.filter((c) =>
39102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
39103          } else {
39104            list.push(id);
39105          }
39106
39107          saveCompletions(list);
39108          updateButtonState(btn, list);
39109          checkModuleCompletion(list);
39110        });
39111      });
39112
39113      checkModuleCompletion(completions);
39114    }
39115
39116    if (document.readyState === "loading") {
39117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
39118    } else {
39119      initCompletionButtons();
39120    }
39121  })();
39122</script>
39122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="10669" data-end="11100" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Estructura de carpetas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-estructura-de-carpetas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
39122<script>
39123  (() => {
39124    if (window.__courseLazyPlayersInitialized) return;
39125    window.__courseLazyPlayersInitialized = true;
39126
39127    function createPlayer(details) {
39128      const videoId = details.dataset.videoId;
39129      const start = details.dataset.start;
39130      const end = details.dataset.end;
39131
39132      const container = document.createElement("div");
39133      container.className = "player-container";
39134
39135      const spinner = document.createElement("div");
39136      spinner.className = "spinner";
39137      container.appendChild(spinner);
39138
39139      const controller = document.createElement("media-controller");
39140      controller.setAttribute("defaultsubtitles", "");
39141
39142      const provider = document.createElement("youtube-media-provider");
39143      provider.setAttribute("slot", "media");
39144      provider.setAttribute("video-id", videoId);
39145      provider.setAttribute("start", start);
39146      provider.setAttribute("end", end);
39147
39148      const controlBar = document.createElement("media-control-bar");
39149
39150      const playBtn = document.createElement("media-play-button");
39151      const timeDisplay = document.createElement("media-time-display");
39152      timeDisplay.setAttribute("showduration", "");
39153      const timeRange = document.createElement("media-time-range");
39154      const playbackRate = document.createElement("media-playback-rate-button");
39155      const muteBtn = document.createElement("media-mute-button");
39156      const volumeRange = document.createElement("media-volume-range");
39157
39158      const captionsBtn = document.createElement("media-captions-button");
39159      captionsBtn.addEventListener("click", (e) => {
39160        e.stopPropagation();
39161        const provider = container.querySelector("youtube-media-provider");
39162        if (provider) provider.toggleCaptions();
39163      });
39164
39165      const qualityBtn = document.createElement("media-chrome-button");
39166      qualityBtn.textContent = "--";
39167      qualityBtn.addEventListener("click", () => {
39168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
39169      });
39170
39171      const fullscreenBtn = document.createElement("media-fullscreen-button");
39172
39173      controlBar.append(
39174        playBtn, timeDisplay, timeRange,
39175        playbackRate, muteBtn, volumeRange,
39176        captionsBtn, qualityBtn, fullscreenBtn
39177      );
39178
39179      const poster = document.createElement("media-poster-image");
39180      poster.setAttribute("slot", "poster");
39181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
39182
39183      controller.append(provider, poster, controlBar);
39184
39185      captionsBtn.setAttribute("aria-checked", "true");
39186      controller.setAttribute("mediasubtitlesshowing", "on");
39187
39188      container.appendChild(controller);
39189      details.appendChild(container);
39190
39191      provider.addEventListener("waiting", () => container.classList.add("loading"));
39192      provider.addEventListener("playing", () => container.classList.remove("loading"));
39193      provider.addEventListener("play", () => container.classList.remove("loading"));
39194
39195      provider._textTracks.addEventListener("change", () => {
39196        const enabled = provider._captionsEnabled;
39197        captionsBtn.setAttribute("aria-checked", enabled);
39198        if (enabled) {
39199          controller.setAttribute("mediasubtitlesshowing", "on");
39200        } else {
39201          controller.removeAttribute("mediasubtitlesshowing");
39202        }
39203      });
39204
39205      provider.addEventListener("qualitychange", (e) => {
39206        qualityBtn.textContent = e.detail.quality;
39207      });
39208    }
39209
39210    function initLazyPlayers() {
39211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
39212        if (details.dataset.lazyInit) return;
39213        details.dataset.lazyInit = "true";
39214        details.addEventListener("toggle", () => {
39215          if (details.open && !details.querySelector(".player-container")) {
39216            createPlayer(details);
39217          } else if (!details.open) {
39218            const provider = details.querySelector("youtube-media-provider");
39219            if (provider && typeof provider.pause === "function") {
39220              provider.pause();
39221            }
39222          }
39223        });
39224      });
39225    }
39226
39227    if (document.readyState === "loading") {
39228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
39229    } else {
39230      initLazyPlayers();
39231    }
39232  })();
39233</script>
39233<script>
39234  (() => {
39235    if (window.__courseCompletionsInitialized) return;
39236    window.__courseCompletionsInitialized = true;
39237
39238    const STORAGE_KEY = "course-completions";
39239
39240    function slugify(text) {
39241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39242    }
39243
39244    function getCompletions() {
39245      try {
39246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
39247      } catch {
39248        return [];
39249      }
39250    }
39251
39252    function saveCompletions(completions) {
39253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
39254    }
39255
39256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
39257    function canonicalFromStored(storedId) {
39258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39259      if (!m) return slugify(storedId);
39260      const [, slug, p, mv, title] = m;
39261      return `${slug}-${p}-${mv}-${slugify(title)}`;
39262    }
39263
39264    function canonicalPlusOne(storedId) {
39265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39266      if (!m) return null;
39267      const [, slug, p, mv, title] = m;
39268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
39269    }
39270
39271    function isCompleted(canonicalId, completions) {
39272      if (completions.includes(canonicalId)) return true;
39273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
39274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
39275    }
39276
39277    function migrateCompletions() {
39278      const completions = getCompletions();
39279      let changed = false;
39280      const migrated = completions.map((c) => {
39281        const canon = canonicalFromStored(c);
39282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
39283        const plusOne = canonicalPlusOne(c);
39284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
39285          if (plusOne !== c) changed = true;
39286          return plusOne;
39287        }
39288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
39289          changed = true;
39290          return canon;
39291        }
39292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
39293        if (canon !== c) {
39294          changed = true;
39295          return canon;
39296        }
39297        return c;
39298      });
39299      // Elimina duplicados tras migración
39300      const deduped = [...new Set(migrated)];
39301      if (changed || deduped.length !== completions.length) {
39302        saveCompletions(deduped);
39303        return deduped;
39304      }
39305      return completions;
39306    }
39307
39308    function updateButtonState(btn, completions) {
39309      const id = btn.dataset.completionId;
39310      const list = completions ?? getCompletions();
39311      btn.classList.toggle("completed", isCompleted(id, list));
39312    }
39313
39314    function checkModuleCompletion(completions) {
39315      const list = completions ?? getCompletions();
39316      document.querySelectorAll(".course-video").forEach((module) => {
39317        const btns = module.querySelectorAll(".complete-btn");
39318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
39319        const check = module.querySelector(".module-check");
39320        if (check) check.classList.toggle("visible", allCompleted);
39321      });
39322    }
39323
39324    function initCompletionButtons() {
39325      const completions = migrateCompletions();
39326
39327      document.querySelectorAll(".complete-btn").forEach((btn) => {
39328        if (btn.dataset.completionInit) return;
39329        btn.dataset.completionInit = "true";
39330        updateButtonState(btn, completions);
39331
39332        btn.addEventListener("click", (e) => {
39333          e.preventDefault();
39334          e.stopPropagation();
39335
39336          const id = btn.dataset.completionId;
39337          let list = getCompletions();
39338          const completed = isCompleted(id, list);
39339
39340          if (completed) {
39341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
39342            list = list.filter((c) =>
39342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
39343          } else {
39344            list.push(id);
39345          }
39346
39347          saveCompletions(list);
39348          updateButtonState(btn, list);
39349          checkModuleCompletion(list);
39350        });
39351      });
39352
39353      checkModuleCompletion(completions);
39354    }
39355
39356    if (document.readyState === "loading") {
39357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
39358    } else {
39359      initCompletionButtons();
39360    }
39361  })();
39362</script>
39362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="xgPm-KcYzVA" data-start="11100" data-end="14243" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Ejercicio práctico</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>52 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-1-ejercicio-practico" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
39362<script>
39363  (() => {
39364    if (window.__courseLazyPlayersInitialized) return;
39365    window.__courseLazyPlayersInitialized = true;
39366
39367    function createPlayer(details) {
39368      const videoId = details.dataset.videoId;
39369      const start = details.dataset.start;
39370      const end = details.dataset.end;
39371
39372      const container = document.createElement("div");
39373      container.className = "player-container";
39374
39375      const spinner = document.createElement("div");
39376      spinner.className = "spinner";
39377      container.appendChild(spinner);
39378
39379      const controller = document.createElement("media-controller");
39380      controller.setAttribute("defaultsubtitles", "");
39381
39382      const provider = document.createElement("youtube-media-provider");
39383      provider.setAttribute("slot", "media");
39384      provider.setAttribute("video-id", videoId);
39385      provider.setAttribute("start", start);
39386      provider.setAttribute("end", end);
39387
39388      const controlBar = document.createElement("media-control-bar");
39389
39390      const playBtn = document.createElement("media-play-button");
39391      const timeDisplay = document.createElement("media-time-display");
39392      timeDisplay.setAttribute("showduration", "");
39393      const timeRange = document.createElement("media-time-range");
39394      const playbackRate = document.createElement("media-playback-rate-button");
39395      const muteBtn = document.createElement("media-mute-button");
39396      const volumeRange = document.createElement("media-volume-range");
39397
39398      const captionsBtn = document.createElement("media-captions-button");
39399      captionsBtn.addEventListener("click", (e) => {
39400        e.stopPropagation();
39401        const provider = container.querySelector("youtube-media-provider");
39402        if (provider) provider.toggleCaptions();
39403      });
39404
39405      const qualityBtn = document.createElement("media-chrome-button");
39406      qualityBtn.textContent = "--";
39407      qualityBtn.addEventListener("click", () => {
39408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
39409      });
39410
39411      const fullscreenBtn = document.createElement("media-fullscreen-button");
39412
39413      controlBar.append(
39414        playBtn, timeDisplay, timeRange,
39415        playbackRate, muteBtn, volumeRange,
39416        captionsBtn, qualityBtn, fullscreenBtn
39417      );
39418
39419      const poster = document.createElement("media-poster-image");
39420      poster.setAttribute("slot", "poster");
39421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
39422
39423      controller.append(provider, poster, controlBar);
39424
39425      captionsBtn.setAttribute("aria-checked", "true");
39426      controller.setAttribute("mediasubtitlesshowing", "on");
39427
39428      container.appendChild(controller);
39429      details.appendChild(container);
39430
39431      provider.addEventListener("waiting", () => container.classList.add("loading"));
39432      provider.addEventListener("playing", () => container.classList.remove("loading"));
39433      provider.addEventListener("play", () => container.classList.remove("loading"));
39434
39435      provider._textTracks.addEventListener("change", () => {
39436        const enabled = provider._captionsEnabled;
39437        captionsBtn.setAttribute("aria-checked", enabled);
39438        if (enabled) {
39439          controller.setAttribute("mediasubtitlesshowing", "on");
39440        } else {
39441          controller.removeAttribute("mediasubtitlesshowing");
39442        }
39443      });
39444
39445      provider.addEventListener("qualitychange", (e) => {
39446        qualityBtn.textContent = e.detail.quality;
39447      });
39448    }
39449
39450    function initLazyPlayers() {
39451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
39452        if (details.dataset.lazyInit) return;
39453        details.dataset.lazyInit = "true";
39454        details.addEventListener("toggle", () => {
39455          if (details.open && !details.querySelector(".player-container")) {
39456            createPlayer(details);
39457          } else if (!details.open) {
39458            const provider = details.querySelector("youtube-media-provider");
39459            if (provider && typeof provider.pause === "function") {
39460              provider.pause();
39461            }
39462          }
39463        });
39464      });
39465    }
39466
39467    if (document.readyState === "loading") {
39468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
39469    } else {
39470      initLazyPlayers();
39471    }
39472  })();
39473</script>
39473<script>
39474  (() => {
39475    if (window.__courseCompletionsInitialized) return;
39476    window.__courseCompletionsInitialized = true;
39477
39478    const STORAGE_KEY = "course-completions";
39479
39480    function slugify(text) {
39481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39482    }
39483
39484    function getCompletions() {
39485      try {
39486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
39487      } catch {
39488        return [];
39489      }
39490    }
39491
39492    function saveCompletions(completions) {
39493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
39494    }
39495
39496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
39497    function canonicalFromStored(storedId) {
39498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39499      if (!m) return slugify(storedId);
39500      const [, slug, p, mv, title] = m;
39501      return `${slug}-${p}-${mv}-${slugify(title)}`;
39502    }
39503
39504    function canonicalPlusOne(storedId) {
39505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39506      if (!m) return null;
39507      const [, slug, p, mv, title] = m;
39508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
39509    }
39510
39511    function isCompleted(canonicalId, completions) {
39512      if (completions.includes(canonicalId)) return true;
39513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
39514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
39515    }
39516
39517    function migrateCompletions() {
39518      const completions = getCompletions();
39519      let changed = false;
39520      const migrated = completions.map((c) => {
39521        const canon = canonicalFromStored(c);
39522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
39523        const plusOne = canonicalPlusOne(c);
39524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
39525          if (plusOne !== c) changed = true;
39526          return plusOne;
39527        }
39528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
39529          changed = true;
39530          return canon;
39531        }
39532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
39533        if (canon !== c) {
39534          changed = true;
39535          return canon;
39536        }
39537        return c;
39538      });
39539      // Elimina duplicados tras migración
39540      const deduped = [...new Set(migrated)];
39541      if (changed || deduped.length !== completions.length) {
39542        saveCompletions(deduped);
39543        return deduped;
39544      }
39545      return completions;
39546    }
39547
39548    function updateButtonState(btn, completions) {
39549      const id = btn.dataset.completionId;
39550      const list = completions ?? getCompletions();
39551      btn.classList.toggle("completed", isCompleted(id, list));
39552    }
39553
39554    function checkModuleCompletion(completions) {
39555      const list = completions ?? getCompletions();
39556      document.querySelectorAll(".course-video").forEach((module) => {
39557        const btns = module.querySelectorAll(".complete-btn");
39558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
39559        const check = module.querySelector(".module-check");
39560        if (check) check.classList.toggle("visible", allCompleted);
39561      });
39562    }
39563
39564    function initCompletionButtons() {
39565      const completions = migrateCompletions();
39566
39567      document.querySelectorAll(".complete-btn").forEach((btn) => {
39568        if (btn.dataset.completionInit) return;
39569        btn.dataset.completionInit = "true";
39570        updateButtonState(btn, completions);
39571
39572        btn.addEventListener("click", (e) => {
39573          e.preventDefault();
39574          e.stopPropagation();
39575
39576          const id = btn.dataset.completionId;
39577          let list = getCompletions();
39578          const completed = isCompleted(id, list);
39579
39580          if (completed) {
39581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
39582            list = list.filter((c) =>
39582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
39583          } else {
39584            list.push(id);
39585          }
39586
39587          saveCompletions(list);
39588          updateButtonState(btn, list);
39589          checkModuleCompletion(list);
39590        });
39591      });
39592
39593      checkModuleCompletion(completions);
39594    }
39595
39596    if (document.readyState === "loading") {
39597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
39598    } else {
39599      initCompletionButtons();
39600    }
39601  })();
39602</script>
39602</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 14 📘 Asincronía<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="1322" data-end="1570" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>4 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-introduccion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
39602<script>
39603  (() => {
39604    if (window.__courseLazyPlayersInitialized) return;
39605    window.__courseLazyPlayersInitialized = true;
39606
39607    function createPlayer(details) {
39608      const videoId = details.dataset.videoId;
39609      const start = details.dataset.start;
39610      const end = details.dataset.end;
39611
39612      const container = document.createElement("div");
39613      container.className = "player-container";
39614
39615      const spinner = document.createElement("div");
39616      spinner.className = "spinner";
39617      container.appendChild(spinner);
39618
39619      const controller = document.createElement("media-controller");
39620      controller.setAttribute("defaultsubtitles", "");
39621
39622      const provider = document.createElement("youtube-media-provider");
39623      provider.setAttribute("slot", "media");
39624      provider.setAttribute("video-id", videoId);
39625      provider.setAttribute("start", start);
39626      provider.setAttribute("end", end);
39627
39628      const controlBar = document.createElement("media-control-bar");
39629
39630      const playBtn = document.createElement("media-play-button");
39631      const timeDisplay = document.createElement("media-time-display");
39632      timeDisplay.setAttribute("showduration", "");
39633      const timeRange = document.createElement("media-time-range");
39634      const playbackRate = document.createElement("media-playback-rate-button");
39635      const muteBtn = document.createElement("media-mute-button");
39636      const volumeRange = document.createElement("media-volume-range");
39637
39638      const captionsBtn = document.createElement("media-captions-button");
39639      captionsBtn.addEventListener("click", (e) => {
39640        e.stopPropagation();
39641        const provider = container.querySelector("youtube-media-provider");
39642        if (provider) provider.toggleCaptions();
39643      });
39644
39645      const qualityBtn = document.createElement("media-chrome-button");
39646      qualityBtn.textContent = "--";
39647      qualityBtn.addEventListener("click", () => {
39648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
39649      });
39650
39651      const fullscreenBtn = document.createElement("media-fullscreen-button");
39652
39653      controlBar.append(
39654        playBtn, timeDisplay, timeRange,
39655        playbackRate, muteBtn, volumeRange,
39656        captionsBtn, qualityBtn, fullscreenBtn
39657      );
39658
39659      const poster = document.createElement("media-poster-image");
39660      poster.setAttribute("slot", "poster");
39661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
39662
39663      controller.append(provider, poster, controlBar);
39664
39665      captionsBtn.setAttribute("aria-checked", "true");
39666      controller.setAttribute("mediasubtitlesshowing", "on");
39667
39668      container.appendChild(controller);
39669      details.appendChild(container);
39670
39671      provider.addEventListener("waiting", () => container.classList.add("loading"));
39672      provider.addEventListener("playing", () => container.classList.remove("loading"));
39673      provider.addEventListener("play", () => container.classList.remove("loading"));
39674
39675      provider._textTracks.addEventListener("change", () => {
39676        const enabled = provider._captionsEnabled;
39677        captionsBtn.setAttribute("aria-checked", enabled);
39678        if (enabled) {
39679          controller.setAttribute("mediasubtitlesshowing", "on");
39680        } else {
39681          controller.removeAttribute("mediasubtitlesshowing");
39682        }
39683      });
39684
39685      provider.addEventListener("qualitychange", (e) => {
39686        qualityBtn.textContent = e.detail.quality;
39687      });
39688    }
39689
39690    function initLazyPlayers() {
39691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
39692        if (details.dataset.lazyInit) return;
39693        details.dataset.lazyInit = "true";
39694        details.addEventListener("toggle", () => {
39695          if (details.open && !details.querySelector(".player-container")) {
39696            createPlayer(details);
39697          } else if (!details.open) {
39698            const provider = details.querySelector("youtube-media-provider");
39699            if (provider && typeof provider.pause === "function") {
39700              provider.pause();
39701            }
39702          }
39703        });
39704      });
39705    }
39706
39707    if (document.readyState === "loading") {
39708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
39709    } else {
39710      initLazyPlayers();
39711    }
39712  })();
39713</script>
39713<script>
39714  (() => {
39715    if (window.__courseCompletionsInitialized) return;
39716    window.__courseCompletionsInitialized = true;
39717
39718    const STORAGE_KEY = "course-completions";
39719
39720    function slugify(text) {
39721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39722    }
39723
39724    function getCompletions() {
39725      try {
39726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
39727      } catch {
39728        return [];
39729      }
39730    }
39731
39732    function saveCompletions(completions) {
39733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
39734    }
39735
39736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
39737    function canonicalFromStored(storedId) {
39738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39739      if (!m) return slugify(storedId);
39740      const [, slug, p, mv, title] = m;
39741      return `${slug}-${p}-${mv}-${slugify(title)}`;
39742    }
39743
39744    function canonicalPlusOne(storedId) {
39745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39746      if (!m) return null;
39747      const [, slug, p, mv, title] = m;
39748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
39749    }
39750
39751    function isCompleted(canonicalId, completions) {
39752      if (completions.includes(canonicalId)) return true;
39753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
39754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
39755    }
39756
39757    function migrateCompletions() {
39758      const completions = getCompletions();
39759      let changed = false;
39760      const migrated = completions.map((c) => {
39761        const canon = canonicalFromStored(c);
39762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
39763        const plusOne = canonicalPlusOne(c);
39764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
39765          if (plusOne !== c) changed = true;
39766          return plusOne;
39767        }
39768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
39769          changed = true;
39770          return canon;
39771        }
39772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
39773        if (canon !== c) {
39774          changed = true;
39775          return canon;
39776        }
39777        return c;
39778      });
39779      // Elimina duplicados tras migración
39780      const deduped = [...new Set(migrated)];
39781      if (changed || deduped.length !== completions.length) {
39782        saveCompletions(deduped);
39783        return deduped;
39784      }
39785      return completions;
39786    }
39787
39788    function updateButtonState(btn, completions) {
39789      const id = btn.dataset.completionId;
39790      const list = completions ?? getCompletions();
39791      btn.classList.toggle("completed", isCompleted(id, list));
39792    }
39793
39794    function checkModuleCompletion(completions) {
39795      const list = completions ?? getCompletions();
39796      document.querySelectorAll(".course-video").forEach((module) => {
39797        const btns = module.querySelectorAll(".complete-btn");
39798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
39799        const check = module.querySelector(".module-check");
39800        if (check) check.classList.toggle("visible", allCompleted);
39801      });
39802    }
39803
39804    function initCompletionButtons() {
39805      const completions = migrateCompletions();
39806
39807      document.querySelectorAll(".complete-btn").forEach((btn) => {
39808        if (btn.dataset.completionInit) return;
39809        btn.dataset.completionInit = "true";
39810        updateButtonState(btn, completions);
39811
39812        btn.addEventListener("click", (e) => {
39813          e.preventDefault();
39814          e.stopPropagation();
39815
39816          const id = btn.dataset.completionId;
39817          let list = getCompletions();
39818          const completed = isCompleted(id, list);
39819
39820          if (completed) {
39821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
39822            list = list.filter((c) =>
39822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
39823          } else {
39824            list.push(id);
39825          }
39826
39827          saveCompletions(list);
39828          updateButtonState(btn, list);
39829          checkModuleCompletion(list);
39830        });
39831      });
39832
39833      checkModuleCompletion(completions);
39834    }
39835
39836    if (document.readyState === "loading") {
39837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
39838    } else {
39839      initCompletionButtons();
39840    }
39841  })();
39842</script>
39842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="1570" data-end="2340" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Promesas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-promesas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
39842<script>
39843  (() => {
39844    if (window.__courseLazyPlayersInitialized) return;
39845    window.__courseLazyPlayersInitialized = true;
39846
39847    function createPlayer(details) {
39848      const videoId = details.dataset.videoId;
39849      const start = details.dataset.start;
39850      const end = details.dataset.end;
39851
39852      const container = document.createElement("div");
39853      container.className = "player-container";
39854
39855      const spinner = document.createElement("div");
39856      spinner.className = "spinner";
39857      container.appendChild(spinner);
39858
39859      const controller = document.createElement("media-controller");
39860      controller.setAttribute("defaultsubtitles", "");
39861
39862      const provider = document.createElement("youtube-media-provider");
39863      provider.setAttribute("slot", "media");
39864      provider.setAttribute("video-id", videoId);
39865      provider.setAttribute("start", start);
39866      provider.setAttribute("end", end);
39867
39868      const controlBar = document.createElement("media-control-bar");
39869
39870      const playBtn = document.createElement("media-play-button");
39871      const timeDisplay = document.createElement("media-time-display");
39872      timeDisplay.setAttribute("showduration", "");
39873      const timeRange = document.createElement("media-time-range");
39874      const playbackRate = document.createElement("media-playback-rate-button");
39875      const muteBtn = document.createElement("media-mute-button");
39876      const volumeRange = document.createElement("media-volume-range");
39877
39878      const captionsBtn = document.createElement("media-captions-button");
39879      captionsBtn.addEventListener("click", (e) => {
39880        e.stopPropagation();
39881        const provider = container.querySelector("youtube-media-provider");
39882        if (provider) provider.toggleCaptions();
39883      });
39884
39885      const qualityBtn = document.createElement("media-chrome-button");
39886      qualityBtn.textContent = "--";
39887      qualityBtn.addEventListener("click", () => {
39888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
39889      });
39890
39891      const fullscreenBtn = document.createElement("media-fullscreen-button");
39892
39893      controlBar.append(
39894        playBtn, timeDisplay, timeRange,
39895        playbackRate, muteBtn, volumeRange,
39896        captionsBtn, qualityBtn, fullscreenBtn
39897      );
39898
39899      const poster = document.createElement("media-poster-image");
39900      poster.setAttribute("slot", "poster");
39901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
39902
39903      controller.append(provider, poster, controlBar);
39904
39905      captionsBtn.setAttribute("aria-checked", "true");
39906      controller.setAttribute("mediasubtitlesshowing", "on");
39907
39908      container.appendChild(controller);
39909      details.appendChild(container);
39910
39911      provider.addEventListener("waiting", () => container.classList.add("loading"));
39912      provider.addEventListener("playing", () => container.classList.remove("loading"));
39913      provider.addEventListener("play", () => container.classList.remove("loading"));
39914
39915      provider._textTracks.addEventListener("change", () => {
39916        const enabled = provider._captionsEnabled;
39917        captionsBtn.setAttribute("aria-checked", enabled);
39918        if (enabled) {
39919          controller.setAttribute("mediasubtitlesshowing", "on");
39920        } else {
39921          controller.removeAttribute("mediasubtitlesshowing");
39922        }
39923      });
39924
39925      provider.addEventListener("qualitychange", (e) => {
39926        qualityBtn.textContent = e.detail.quality;
39927      });
39928    }
39929
39930    function initLazyPlayers() {
39931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
39932        if (details.dataset.lazyInit) return;
39933        details.dataset.lazyInit = "true";
39934        details.addEventListener("toggle", () => {
39935          if (details.open && !details.querySelector(".player-container")) {
39936            createPlayer(details);
39937          } else if (!details.open) {
39938            const provider = details.querySelector("youtube-media-provider");
39939            if (provider && typeof provider.pause === "function") {
39940              provider.pause();
39941            }
39942          }
39943        });
39944      });
39945    }
39946
39947    if (document.readyState === "loading") {
39948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
39949    } else {
39950      initLazyPlayers();
39951    }
39952  })();
39953</script>
39953<script>
39954  (() => {
39955    if (window.__courseCompletionsInitialized) return;
39956    window.__courseCompletionsInitialized = true;
39957
39958    const STORAGE_KEY = "course-completions";
39959
39960    function slugify(text) {
39961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39962    }
39963
39964    function getCompletions() {
39965      try {
39966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
39967      } catch {
39968        return [];
39969      }
39970    }
39971
39972    function saveCompletions(completions) {
39973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
39974    }
39975
39976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
39977    function canonicalFromStored(storedId) {
39978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39979      if (!m) return slugify(storedId);
39980      const [, slug, p, mv, title] = m;
39981      return `${slug}-${p}-${mv}-${slugify(title)}`;
39982    }
39983
39984    function canonicalPlusOne(storedId) {
39985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
39986      if (!m) return null;
39987      const [, slug, p, mv, title] = m;
39988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
39989    }
39990
39991    function isCompleted(canonicalId, completions) {
39992      if (completions.includes(canonicalId)) return true;
39993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
39994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
39995    }
39996
39997    function migrateCompletions() {
39998      const completions = getCompletions();
39999      let changed = false;
40000      const migrated = completions.map((c) => {
40001        const canon = canonicalFromStored(c);
40002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
40003        const plusOne = canonicalPlusOne(c);
40004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
40005          if (plusOne !== c) changed = true;
40006          return plusOne;
40007        }
40008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
40009          changed = true;
40010          return canon;
40011        }
40012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
40013        if (canon !== c) {
40014          changed = true;
40015          return canon;
40016        }
40017        return c;
40018      });
40019      // Elimina duplicados tras migración
40020      const deduped = [...new Set(migrated)];
40021      if (changed || deduped.length !== completions.length) {
40022        saveCompletions(deduped);
40023        return deduped;
40024      }
40025      return completions;
40026    }
40027
40028    function updateButtonState(btn, completions) {
40029      const id = btn.dataset.completionId;
40030      const list = completions ?? getCompletions();
40031      btn.classList.toggle("completed", isCompleted(id, list));
40032    }
40033
40034    function checkModuleCompletion(completions) {
40035      const list = completions ?? getCompletions();
40036      document.querySelectorAll(".course-video").forEach((module) => {
40037        const btns = module.querySelectorAll(".complete-btn");
40038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
40039        const check = module.querySelector(".module-check");
40040        if (check) check.classList.toggle("visible", allCompleted);
40041      });
40042    }
40043
40044    function initCompletionButtons() {
40045      const completions = migrateCompletions();
40046
40047      document.querySelectorAll(".complete-btn").forEach((btn) => {
40048        if (btn.dataset.completionInit) return;
40049        btn.dataset.completionInit = "true";
40050        updateButtonState(btn, completions);
40051
40052        btn.addEventListener("click", (e) => {
40053          e.preventDefault();
40054          e.stopPropagation();
40055
40056          const id = btn.dataset.completionId;
40057          let list = getCompletions();
40058          const completed = isCompleted(id, list);
40059
40060          if (completed) {
40061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
40062            list = list.filter((c) =>
40062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
40063          } else {
40064            list.push(id);
40065          }
40066
40067          saveCompletions(list);
40068          updateButtonState(btn, list);
40069          checkModuleCompletion(list);
40070        });
40071      });
40072
40073      checkModuleCompletion(completions);
40074    }
40075
40076    if (document.readyState === "loading") {
40077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
40078    } else {
40079      initCompletionButtons();
40080    }
40081  })();
40082</script>
40082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="2340" data-end="3053" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Thennables</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>12 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-thennables" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
40082<script>
40083  (() => {
40084    if (window.__courseLazyPlayersInitialized) return;
40085    window.__courseLazyPlayersInitialized = true;
40086
40087    function createPlayer(details) {
40088      const videoId = details.dataset.videoId;
40089      const start = details.dataset.start;
40090      const end = details.dataset.end;
40091
40092      const container = document.createElement("div");
40093      container.className = "player-container";
40094
40095      const spinner = document.createElement("div");
40096      spinner.className = "spinner";
40097      container.appendChild(spinner);
40098
40099      const controller = document.createElement("media-controller");
40100      controller.setAttribute("defaultsubtitles", "");
40101
40102      const provider = document.createElement("youtube-media-provider");
40103      provider.setAttribute("slot", "media");
40104      provider.setAttribute("video-id", videoId);
40105      provider.setAttribute("start", start);
40106      provider.setAttribute("end", end);
40107
40108      const controlBar = document.createElement("media-control-bar");
40109
40110      const playBtn = document.createElement("media-play-button");
40111      const timeDisplay = document.createElement("media-time-display");
40112      timeDisplay.setAttribute("showduration", "");
40113      const timeRange = document.createElement("media-time-range");
40114      const playbackRate = document.createElement("media-playback-rate-button");
40115      const muteBtn = document.createElement("media-mute-button");
40116      const volumeRange = document.createElement("media-volume-range");
40117
40118      const captionsBtn = document.createElement("media-captions-button");
40119      captionsBtn.addEventListener("click", (e) => {
40120        e.stopPropagation();
40121        const provider = container.querySelector("youtube-media-provider");
40122        if (provider) provider.toggleCaptions();
40123      });
40124
40125      const qualityBtn = document.createElement("media-chrome-button");
40126      qualityBtn.textContent = "--";
40127      qualityBtn.addEventListener("click", () => {
40128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
40129      });
40130
40131      const fullscreenBtn = document.createElement("media-fullscreen-button");
40132
40133      controlBar.append(
40134        playBtn, timeDisplay, timeRange,
40135        playbackRate, muteBtn, volumeRange,
40136        captionsBtn, qualityBtn, fullscreenBtn
40137      );
40138
40139      const poster = document.createElement("media-poster-image");
40140      poster.setAttribute("slot", "poster");
40141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
40142
40143      controller.append(provider, poster, controlBar);
40144
40145      captionsBtn.setAttribute("aria-checked", "true");
40146      controller.setAttribute("mediasubtitlesshowing", "on");
40147
40148      container.appendChild(controller);
40149      details.appendChild(container);
40150
40151      provider.addEventListener("waiting", () => container.classList.add("loading"));
40152      provider.addEventListener("playing", () => container.classList.remove("loading"));
40153      provider.addEventListener("play", () => container.classList.remove("loading"));
40154
40155      provider._textTracks.addEventListener("change", () => {
40156        const enabled = provider._captionsEnabled;
40157        captionsBtn.setAttribute("aria-checked", enabled);
40158        if (enabled) {
40159          controller.setAttribute("mediasubtitlesshowing", "on");
40160        } else {
40161          controller.removeAttribute("mediasubtitlesshowing");
40162        }
40163      });
40164
40165      provider.addEventListener("qualitychange", (e) => {
40166        qualityBtn.textContent = e.detail.quality;
40167      });
40168    }
40169
40170    function initLazyPlayers() {
40171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
40172        if (details.dataset.lazyInit) return;
40173        details.dataset.lazyInit = "true";
40174        details.addEventListener("toggle", () => {
40175          if (details.open && !details.querySelector(".player-container")) {
40176            createPlayer(details);
40177          } else if (!details.open) {
40178            const provider = details.querySelector("youtube-media-provider");
40179            if (provider && typeof provider.pause === "function") {
40180              provider.pause();
40181            }
40182          }
40183        });
40184      });
40185    }
40186
40187    if (document.readyState === "loading") {
40188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
40189    } else {
40190      initLazyPlayers();
40191    }
40192  })();
40193</script>
40193<script>
40194  (() => {
40195    if (window.__courseCompletionsInitialized) return;
40196    window.__courseCompletionsInitialized = true;
40197
40198    const STORAGE_KEY = "course-completions";
40199
40200    function slugify(text) {
40201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
40202    }
40203
40204    function getCompletions() {
40205      try {
40206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
40207      } catch {
40208        return [];
40209      }
40210    }
40211
40212    function saveCompletions(completions) {
40213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
40214    }
40215
40216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
40217    function canonicalFromStored(storedId) {
40218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40219      if (!m) return slugify(storedId);
40220      const [, slug, p, mv, title] = m;
40221      return `${slug}-${p}-${mv}-${slugify(title)}`;
40222    }
40223
40224    function canonicalPlusOne(storedId) {
40225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40226      if (!m) return null;
40227      const [, slug, p, mv, title] = m;
40228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
40229    }
40230
40231    function isCompleted(canonicalId, completions) {
40232      if (completions.includes(canonicalId)) return true;
40233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
40234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
40235    }
40236
40237    function migrateCompletions() {
40238      const completions = getCompletions();
40239      let changed = false;
40240      const migrated = completions.map((c) => {
40241        const canon = canonicalFromStored(c);
40242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
40243        const plusOne = canonicalPlusOne(c);
40244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
40245          if (plusOne !== c) changed = true;
40246          return plusOne;
40247        }
40248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
40249          changed = true;
40250          return canon;
40251        }
40252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
40253        if (canon !== c) {
40254          changed = true;
40255          return canon;
40256        }
40257        return c;
40258      });
40259      // Elimina duplicados tras migración
40260      const deduped = [...new Set(migrated)];
40261      if (changed || deduped.length !== completions.length) {
40262        saveCompletions(deduped);
40263        return deduped;
40264      }
40265      return completions;
40266    }
40267
40268    function updateButtonState(btn, completions) {
40269      const id = btn.dataset.completionId;
40270      const list = completions ?? getCompletions();
40271      btn.classList.toggle("completed", isCompleted(id, list));
40272    }
40273
40274    function checkModuleCompletion(completions) {
40275      const list = completions ?? getCompletions();
40276      document.querySelectorAll(".course-video").forEach((module) => {
40277        const btns = module.querySelectorAll(".complete-btn");
40278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
40279        const check = module.querySelector(".module-check");
40280        if (check) check.classList.toggle("visible", allCompleted);
40281      });
40282    }
40283
40284    function initCompletionButtons() {
40285      const completions = migrateCompletions();
40286
40287      document.querySelectorAll(".complete-btn").forEach((btn) => {
40288        if (btn.dataset.completionInit) return;
40289        btn.dataset.completionInit = "true";
40290        updateButtonState(btn, completions);
40291
40292        btn.addEventListener("click", (e) => {
40293          e.preventDefault();
40294          e.stopPropagation();
40295
40296          const id = btn.dataset.completionId;
40297          let list = getCompletions();
40298          const completed = isCompleted(id, list);
40299
40300          if (completed) {
40301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
40302            list = list.filter((c) =>
40302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
40303          } else {
40304            list.push(id);
40305          }
40306
40307          saveCompletions(list);
40308          updateButtonState(btn, list);
40309          checkModuleCompletion(list);
40310        });
40311      });
40312
40313      checkModuleCompletion(completions);
40314    }
40315
40316    if (document.readyState === "loading") {
40317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
40318    } else {
40319      initCompletionButtons();
40320    }
40321  })();
40322</script>
40322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="3053" data-end="4253" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Crear nuestras promesas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>20 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-crear-nuestras-promesas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
40322<script>
40323  (() => {
40324    if (window.__courseLazyPlayersInitialized) return;
40325    window.__courseLazyPlayersInitialized = true;
40326
40327    function createPlayer(details) {
40328      const videoId = details.dataset.videoId;
40329      const start = details.dataset.start;
40330      const end = details.dataset.end;
40331
40332      const container = document.createElement("div");
40333      container.className = "player-container";
40334
40335      const spinner = document.createElement("div");
40336      spinner.className = "spinner";
40337      container.appendChild(spinner);
40338
40339      const controller = document.createElement("media-controller");
40340      controller.setAttribute("defaultsubtitles", "");
40341
40342      const provider = document.createElement("youtube-media-provider");
40343      provider.setAttribute("slot", "media");
40344      provider.setAttribute("video-id", videoId);
40345      provider.setAttribute("start", start);
40346      provider.setAttribute("end", end);
40347
40348      const controlBar = document.createElement("media-control-bar");
40349
40350      const playBtn = document.createElement("media-play-button");
40351      const timeDisplay = document.createElement("media-time-display");
40352      timeDisplay.setAttribute("showduration", "");
40353      const timeRange = document.createElement("media-time-range");
40354      const playbackRate = document.createElement("media-playback-rate-button");
40355      const muteBtn = document.createElement("media-mute-button");
40356      const volumeRange = document.createElement("media-volume-range");
40357
40358      const captionsBtn = document.createElement("media-captions-button");
40359      captionsBtn.addEventListener("click", (e) => {
40360        e.stopPropagation();
40361        const provider = container.querySelector("youtube-media-provider");
40362        if (provider) provider.toggleCaptions();
40363      });
40364
40365      const qualityBtn = document.createElement("media-chrome-button");
40366      qualityBtn.textContent = "--";
40367      qualityBtn.addEventListener("click", () => {
40368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
40369      });
40370
40371      const fullscreenBtn = document.createElement("media-fullscreen-button");
40372
40373      controlBar.append(
40374        playBtn, timeDisplay, timeRange,
40375        playbackRate, muteBtn, volumeRange,
40376        captionsBtn, qualityBtn, fullscreenBtn
40377      );
40378
40379      const poster = document.createElement("media-poster-image");
40380      poster.setAttribute("slot", "poster");
40381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
40382
40383      controller.append(provider, poster, controlBar);
40384
40385      captionsBtn.setAttribute("aria-checked", "true");
40386      controller.setAttribute("mediasubtitlesshowing", "on");
40387
40388      container.appendChild(controller);
40389      details.appendChild(container);
40390
40391      provider.addEventListener("waiting", () => container.classList.add("loading"));
40392      provider.addEventListener("playing", () => container.classList.remove("loading"));
40393      provider.addEventListener("play", () => container.classList.remove("loading"));
40394
40395      provider._textTracks.addEventListener("change", () => {
40396        const enabled = provider._captionsEnabled;
40397        captionsBtn.setAttribute("aria-checked", enabled);
40398        if (enabled) {
40399          controller.setAttribute("mediasubtitlesshowing", "on");
40400        } else {
40401          controller.removeAttribute("mediasubtitlesshowing");
40402        }
40403      });
40404
40405      provider.addEventListener("qualitychange", (e) => {
40406        qualityBtn.textContent = e.detail.quality;
40407      });
40408    }
40409
40410    function initLazyPlayers() {
40411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
40412        if (details.dataset.lazyInit) return;
40413        details.dataset.lazyInit = "true";
40414        details.addEventListener("toggle", () => {
40415          if (details.open && !details.querySelector(".player-container")) {
40416            createPlayer(details);
40417          } else if (!details.open) {
40418            const provider = details.querySelector("youtube-media-provider");
40419            if (provider && typeof provider.pause === "function") {
40420              provider.pause();
40421            }
40422          }
40423        });
40424      });
40425    }
40426
40427    if (document.readyState === "loading") {
40428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
40429    } else {
40430      initLazyPlayers();
40431    }
40432  })();
40433</script>
40433<script>
40434  (() => {
40435    if (window.__courseCompletionsInitialized) return;
40436    window.__courseCompletionsInitialized = true;
40437
40438    const STORAGE_KEY = "course-completions";
40439
40440    function slugify(text) {
40441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
40442    }
40443
40444    function getCompletions() {
40445      try {
40446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
40447      } catch {
40448        return [];
40449      }
40450    }
40451
40452    function saveCompletions(completions) {
40453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
40454    }
40455
40456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
40457    function canonicalFromStored(storedId) {
40458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40459      if (!m) return slugify(storedId);
40460      const [, slug, p, mv, title] = m;
40461      return `${slug}-${p}-${mv}-${slugify(title)}`;
40462    }
40463
40464    function canonicalPlusOne(storedId) {
40465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40466      if (!m) return null;
40467      const [, slug, p, mv, title] = m;
40468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
40469    }
40470
40471    function isCompleted(canonicalId, completions) {
40472      if (completions.includes(canonicalId)) return true;
40473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
40474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
40475    }
40476
40477    function migrateCompletions() {
40478      const completions = getCompletions();
40479      let changed = false;
40480      const migrated = completions.map((c) => {
40481        const canon = canonicalFromStored(c);
40482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
40483        const plusOne = canonicalPlusOne(c);
40484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
40485          if (plusOne !== c) changed = true;
40486          return plusOne;
40487        }
40488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
40489          changed = true;
40490          return canon;
40491        }
40492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
40493        if (canon !== c) {
40494          changed = true;
40495          return canon;
40496        }
40497        return c;
40498      });
40499      // Elimina duplicados tras migración
40500      const deduped = [...new Set(migrated)];
40501      if (changed || deduped.length !== completions.length) {
40502        saveCompletions(deduped);
40503        return deduped;
40504      }
40505      return completions;
40506    }
40507
40508    function updateButtonState(btn, completions) {
40509      const id = btn.dataset.completionId;
40510      const list = completions ?? getCompletions();
40511      btn.classList.toggle("completed", isCompleted(id, list));
40512    }
40513
40514    function checkModuleCompletion(completions) {
40515      const list = completions ?? getCompletions();
40516      document.querySelectorAll(".course-video").forEach((module) => {
40517        const btns = module.querySelectorAll(".complete-btn");
40518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
40519        const check = module.querySelector(".module-check");
40520        if (check) check.classList.toggle("visible", allCompleted);
40521      });
40522    }
40523
40524    function initCompletionButtons() {
40525      const completions = migrateCompletions();
40526
40527      document.querySelectorAll(".complete-btn").forEach((btn) => {
40528        if (btn.dataset.completionInit) return;
40529        btn.dataset.completionInit = "true";
40530        updateButtonState(btn, completions);
40531
40532        btn.addEventListener("click", (e) => {
40533          e.preventDefault();
40534          e.stopPropagation();
40535
40536          const id = btn.dataset.completionId;
40537          let list = getCompletions();
40538          const completed = isCompleted(id, list);
40539
40540          if (completed) {
40541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
40542            list = list.filter((c) =>
40542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
40543          } else {
40544            list.push(id);
40545          }
40546
40547          saveCompletions(list);
40548          updateButtonState(btn, list);
40549          checkModuleCompletion(list);
40550        });
40551      });
40552
40553      checkModuleCompletion(completions);
40554    }
40555
40556    if (document.readyState === "loading") {
40557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
40558    } else {
40559      initCompletionButtons();
40560    }
40561  })();
40562</script>
40562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="4253" data-end="5808" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Async/await</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>26 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-async-await" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
40562<script>
40563  (() => {
40564    if (window.__courseLazyPlayersInitialized) return;
40565    window.__courseLazyPlayersInitialized = true;
40566
40567    function createPlayer(details) {
40568      const videoId = details.dataset.videoId;
40569      const start = details.dataset.start;
40570      const end = details.dataset.end;
40571
40572      const container = document.createElement("div");
40573      container.className = "player-container";
40574
40575      const spinner = document.createElement("div");
40576      spinner.className = "spinner";
40577      container.appendChild(spinner);
40578
40579      const controller = document.createElement("media-controller");
40580      controller.setAttribute("defaultsubtitles", "");
40581
40582      const provider = document.createElement("youtube-media-provider");
40583      provider.setAttribute("slot", "media");
40584      provider.setAttribute("video-id", videoId);
40585      provider.setAttribute("start", start);
40586      provider.setAttribute("end", end);
40587
40588      const controlBar = document.createElement("media-control-bar");
40589
40590      const playBtn = document.createElement("media-play-button");
40591      const timeDisplay = document.createElement("media-time-display");
40592      timeDisplay.setAttribute("showduration", "");
40593      const timeRange = document.createElement("media-time-range");
40594      const playbackRate = document.createElement("media-playback-rate-button");
40595      const muteBtn = document.createElement("media-mute-button");
40596      const volumeRange = document.createElement("media-volume-range");
40597
40598      const captionsBtn = document.createElement("media-captions-button");
40599      captionsBtn.addEventListener("click", (e) => {
40600        e.stopPropagation();
40601        const provider = container.querySelector("youtube-media-provider");
40602        if (provider) provider.toggleCaptions();
40603      });
40604
40605      const qualityBtn = document.createElement("media-chrome-button");
40606      qualityBtn.textContent = "--";
40607      qualityBtn.addEventListener("click", () => {
40608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
40609      });
40610
40611      const fullscreenBtn = document.createElement("media-fullscreen-button");
40612
40613      controlBar.append(
40614        playBtn, timeDisplay, timeRange,
40615        playbackRate, muteBtn, volumeRange,
40616        captionsBtn, qualityBtn, fullscreenBtn
40617      );
40618
40619      const poster = document.createElement("media-poster-image");
40620      poster.setAttribute("slot", "poster");
40621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
40622
40623      controller.append(provider, poster, controlBar);
40624
40625      captionsBtn.setAttribute("aria-checked", "true");
40626      controller.setAttribute("mediasubtitlesshowing", "on");
40627
40628      container.appendChild(controller);
40629      details.appendChild(container);
40630
40631      provider.addEventListener("waiting", () => container.classList.add("loading"));
40632      provider.addEventListener("playing", () => container.classList.remove("loading"));
40633      provider.addEventListener("play", () => container.classList.remove("loading"));
40634
40635      provider._textTracks.addEventListener("change", () => {
40636        const enabled = provider._captionsEnabled;
40637        captionsBtn.setAttribute("aria-checked", enabled);
40638        if (enabled) {
40639          controller.setAttribute("mediasubtitlesshowing", "on");
40640        } else {
40641          controller.removeAttribute("mediasubtitlesshowing");
40642        }
40643      });
40644
40645      provider.addEventListener("qualitychange", (e) => {
40646        qualityBtn.textContent = e.detail.quality;
40647      });
40648    }
40649
40650    function initLazyPlayers() {
40651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
40652        if (details.dataset.lazyInit) return;
40653        details.dataset.lazyInit = "true";
40654        details.addEventListener("toggle", () => {
40655          if (details.open && !details.querySelector(".player-container")) {
40656            createPlayer(details);
40657          } else if (!details.open) {
40658            const provider = details.querySelector("youtube-media-provider");
40659            if (provider && typeof provider.pause === "function") {
40660              provider.pause();
40661            }
40662          }
40663        });
40664      });
40665    }
40666
40667    if (document.readyState === "loading") {
40668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
40669    } else {
40670      initLazyPlayers();
40671    }
40672  })();
40673</script>
40673<script>
40674  (() => {
40675    if (window.__courseCompletionsInitialized) return;
40676    window.__courseCompletionsInitialized = true;
40677
40678    const STORAGE_KEY = "course-completions";
40679
40680    function slugify(text) {
40681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
40682    }
40683
40684    function getCompletions() {
40685      try {
40686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
40687      } catch {
40688        return [];
40689      }
40690    }
40691
40692    function saveCompletions(completions) {
40693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
40694    }
40695
40696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
40697    function canonicalFromStored(storedId) {
40698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40699      if (!m) return slugify(storedId);
40700      const [, slug, p, mv, title] = m;
40701      return `${slug}-${p}-${mv}-${slugify(title)}`;
40702    }
40703
40704    function canonicalPlusOne(storedId) {
40705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40706      if (!m) return null;
40707      const [, slug, p, mv, title] = m;
40708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
40709    }
40710
40711    function isCompleted(canonicalId, completions) {
40712      if (completions.includes(canonicalId)) return true;
40713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
40714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
40715    }
40716
40717    function migrateCompletions() {
40718      const completions = getCompletions();
40719      let changed = false;
40720      const migrated = completions.map((c) => {
40721        const canon = canonicalFromStored(c);
40722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
40723        const plusOne = canonicalPlusOne(c);
40724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
40725          if (plusOne !== c) changed = true;
40726          return plusOne;
40727        }
40728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
40729          changed = true;
40730          return canon;
40731        }
40732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
40733        if (canon !== c) {
40734          changed = true;
40735          return canon;
40736        }
40737        return c;
40738      });
40739      // Elimina duplicados tras migración
40740      const deduped = [...new Set(migrated)];
40741      if (changed || deduped.length !== completions.length) {
40742        saveCompletions(deduped);
40743        return deduped;
40744      }
40745      return completions;
40746    }
40747
40748    function updateButtonState(btn, completions) {
40749      const id = btn.dataset.completionId;
40750      const list = completions ?? getCompletions();
40751      btn.classList.toggle("completed", isCompleted(id, list));
40752    }
40753
40754    function checkModuleCompletion(completions) {
40755      const list = completions ?? getCompletions();
40756      document.querySelectorAll(".course-video").forEach((module) => {
40757        const btns = module.querySelectorAll(".complete-btn");
40758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
40759        const check = module.querySelector(".module-check");
40760        if (check) check.classList.toggle("visible", allCompleted);
40761      });
40762    }
40763
40764    function initCompletionButtons() {
40765      const completions = migrateCompletions();
40766
40767      document.querySelectorAll(".complete-btn").forEach((btn) => {
40768        if (btn.dataset.completionInit) return;
40769        btn.dataset.completionInit = "true";
40770        updateButtonState(btn, completions);
40771
40772        btn.addEventListener("click", (e) => {
40773          e.preventDefault();
40774          e.stopPropagation();
40775
40776          const id = btn.dataset.completionId;
40777          let list = getCompletions();
40778          const completed = isCompleted(id, list);
40779
40780          if (completed) {
40781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
40782            list = list.filter((c) =>
40782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
40783          } else {
40784            list.push(id);
40785          }
40786
40787          saveCompletions(list);
40788          updateButtonState(btn, list);
40789          checkModuleCompletion(list);
40790        });
40791      });
40792
40793      checkModuleCompletion(completions);
40794    }
40795
40796    if (document.readyState === "loading") {
40797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
40798    } else {
40799      initCompletionButtons();
40800    }
40801  })();
40802</script>
40802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="6120" data-end="7140" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Promesas en grupo</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-promesas-en-grupo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
40802<script>
40803  (() => {
40804    if (window.__courseLazyPlayersInitialized) return;
40805    window.__courseLazyPlayersInitialized = true;
40806
40807    function createPlayer(details) {
40808      const videoId = details.dataset.videoId;
40809      const start = details.dataset.start;
40810      const end = details.dataset.end;
40811
40812      const container = document.createElement("div");
40813      container.className = "player-container";
40814
40815      const spinner = document.createElement("div");
40816      spinner.className = "spinner";
40817      container.appendChild(spinner);
40818
40819      const controller = document.createElement("media-controller");
40820      controller.setAttribute("defaultsubtitles", "");
40821
40822      const provider = document.createElement("youtube-media-provider");
40823      provider.setAttribute("slot", "media");
40824      provider.setAttribute("video-id", videoId);
40825      provider.setAttribute("start", start);
40826      provider.setAttribute("end", end);
40827
40828      const controlBar = document.createElement("media-control-bar");
40829
40830      const playBtn = document.createElement("media-play-button");
40831      const timeDisplay = document.createElement("media-time-display");
40832      timeDisplay.setAttribute("showduration", "");
40833      const timeRange = document.createElement("media-time-range");
40834      const playbackRate = document.createElement("media-playback-rate-button");
40835      const muteBtn = document.createElement("media-mute-button");
40836      const volumeRange = document.createElement("media-volume-range");
40837
40838      const captionsBtn = document.createElement("media-captions-button");
40839      captionsBtn.addEventListener("click", (e) => {
40840        e.stopPropagation();
40841        const provider = container.querySelector("youtube-media-provider");
40842        if (provider) provider.toggleCaptions();
40843      });
40844
40845      const qualityBtn = document.createElement("media-chrome-button");
40846      qualityBtn.textContent = "--";
40847      qualityBtn.addEventListener("click", () => {
40848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
40849      });
40850
40851      const fullscreenBtn = document.createElement("media-fullscreen-button");
40852
40853      controlBar.append(
40854        playBtn, timeDisplay, timeRange,
40855        playbackRate, muteBtn, volumeRange,
40856        captionsBtn, qualityBtn, fullscreenBtn
40857      );
40858
40859      const poster = document.createElement("media-poster-image");
40860      poster.setAttribute("slot", "poster");
40861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
40862
40863      controller.append(provider, poster, controlBar);
40864
40865      captionsBtn.setAttribute("aria-checked", "true");
40866      controller.setAttribute("mediasubtitlesshowing", "on");
40867
40868      container.appendChild(controller);
40869      details.appendChild(container);
40870
40871      provider.addEventListener("waiting", () => container.classList.add("loading"));
40872      provider.addEventListener("playing", () => container.classList.remove("loading"));
40873      provider.addEventListener("play", () => container.classList.remove("loading"));
40874
40875      provider._textTracks.addEventListener("change", () => {
40876        const enabled = provider._captionsEnabled;
40877        captionsBtn.setAttribute("aria-checked", enabled);
40878        if (enabled) {
40879          controller.setAttribute("mediasubtitlesshowing", "on");
40880        } else {
40881          controller.removeAttribute("mediasubtitlesshowing");
40882        }
40883      });
40884
40885      provider.addEventListener("qualitychange", (e) => {
40886        qualityBtn.textContent = e.detail.quality;
40887      });
40888    }
40889
40890    function initLazyPlayers() {
40891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
40892        if (details.dataset.lazyInit) return;
40893        details.dataset.lazyInit = "true";
40894        details.addEventListener("toggle", () => {
40895          if (details.open && !details.querySelector(".player-container")) {
40896            createPlayer(details);
40897          } else if (!details.open) {
40898            const provider = details.querySelector("youtube-media-provider");
40899            if (provider && typeof provider.pause === "function") {
40900              provider.pause();
40901            }
40902          }
40903        });
40904      });
40905    }
40906
40907    if (document.readyState === "loading") {
40908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
40909    } else {
40910      initLazyPlayers();
40911    }
40912  })();
40913</script>
40913<script>
40914  (() => {
40915    if (window.__courseCompletionsInitialized) return;
40916    window.__courseCompletionsInitialized = true;
40917
40918    const STORAGE_KEY = "course-completions";
40919
40920    function slugify(text) {
40921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
40922    }
40923
40924    function getCompletions() {
40925      try {
40926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
40927      } catch {
40928        return [];
40929      }
40930    }
40931
40932    function saveCompletions(completions) {
40933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
40934    }
40935
40936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
40937    function canonicalFromStored(storedId) {
40938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40939      if (!m) return slugify(storedId);
40940      const [, slug, p, mv, title] = m;
40941      return `${slug}-${p}-${mv}-${slugify(title)}`;
40942    }
40943
40944    function canonicalPlusOne(storedId) {
40945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
40946      if (!m) return null;
40947      const [, slug, p, mv, title] = m;
40948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
40949    }
40950
40951    function isCompleted(canonicalId, completions) {
40952      if (completions.includes(canonicalId)) return true;
40953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
40954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
40955    }
40956
40957    function migrateCompletions() {
40958      const completions = getCompletions();
40959      let changed = false;
40960      const migrated = completions.map((c) => {
40961        const canon = canonicalFromStored(c);
40962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
40963        const plusOne = canonicalPlusOne(c);
40964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
40965          if (plusOne !== c) changed = true;
40966          return plusOne;
40967        }
40968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
40969          changed = true;
40970          return canon;
40971        }
40972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
40973        if (canon !== c) {
40974          changed = true;
40975          return canon;
40976        }
40977        return c;
40978      });
40979      // Elimina duplicados tras migración
40980      const deduped = [...new Set(migrated)];
40981      if (changed || deduped.length !== completions.length) {
40982        saveCompletions(deduped);
40983        return deduped;
40984      }
40985      return completions;
40986    }
40987
40988    function updateButtonState(btn, completions) {
40989      const id = btn.dataset.completionId;
40990      const list = completions ?? getCompletions();
40991      btn.classList.toggle("completed", isCompleted(id, list));
40992    }
40993
40994    function checkModuleCompletion(completions) {
40995      const list = completions ?? getCompletions();
40996      document.querySelectorAll(".course-video").forEach((module) => {
40997        const btns = module.querySelectorAll(".complete-btn");
40998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
40999        const check = module.querySelector(".module-check");
41000        if (check) check.classList.toggle("visible", allCompleted);
41001      });
41002    }
41003
41004    function initCompletionButtons() {
41005      const completions = migrateCompletions();
41006
41007      document.querySelectorAll(".complete-btn").forEach((btn) => {
41008        if (btn.dataset.completionInit) return;
41009        btn.dataset.completionInit = "true";
41010        updateButtonState(btn, completions);
41011
41012        btn.addEventListener("click", (e) => {
41013          e.preventDefault();
41014          e.stopPropagation();
41015
41016          const id = btn.dataset.completionId;
41017          let list = getCompletions();
41018          const completed = isCompleted(id, list);
41019
41020          if (completed) {
41021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
41022            list = list.filter((c) =>
41022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
41023          } else {
41024            list.push(id);
41025          }
41026
41027          saveCompletions(list);
41028          updateButtonState(btn, list);
41029          checkModuleCompletion(list);
41030        });
41031      });
41032
41033      checkModuleCompletion(completions);
41034    }
41035
41036    if (document.readyState === "loading") {
41037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
41038    } else {
41039      initCompletionButtons();
41040    }
41041  })();
41042</script>
41042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="7140" data-end="8658" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Fetching y URL</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>25 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-fetching-y-url" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
41042<script>
41043  (() => {
41044    if (window.__courseLazyPlayersInitialized) return;
41045    window.__courseLazyPlayersInitialized = true;
41046
41047    function createPlayer(details) {
41048      const videoId = details.dataset.videoId;
41049      const start = details.dataset.start;
41050      const end = details.dataset.end;
41051
41052      const container = document.createElement("div");
41053      container.className = "player-container";
41054
41055      const spinner = document.createElement("div");
41056      spinner.className = "spinner";
41057      container.appendChild(spinner);
41058
41059      const controller = document.createElement("media-controller");
41060      controller.setAttribute("defaultsubtitles", "");
41061
41062      const provider = document.createElement("youtube-media-provider");
41063      provider.setAttribute("slot", "media");
41064      provider.setAttribute("video-id", videoId);
41065      provider.setAttribute("start", start);
41066      provider.setAttribute("end", end);
41067
41068      const controlBar = document.createElement("media-control-bar");
41069
41070      const playBtn = document.createElement("media-play-button");
41071      const timeDisplay = document.createElement("media-time-display");
41072      timeDisplay.setAttribute("showduration", "");
41073      const timeRange = document.createElement("media-time-range");
41074      const playbackRate = document.createElement("media-playback-rate-button");
41075      const muteBtn = document.createElement("media-mute-button");
41076      const volumeRange = document.createElement("media-volume-range");
41077
41078      const captionsBtn = document.createElement("media-captions-button");
41079      captionsBtn.addEventListener("click", (e) => {
41080        e.stopPropagation();
41081        const provider = container.querySelector("youtube-media-provider");
41082        if (provider) provider.toggleCaptions();
41083      });
41084
41085      const qualityBtn = document.createElement("media-chrome-button");
41086      qualityBtn.textContent = "--";
41087      qualityBtn.addEventListener("click", () => {
41088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
41089      });
41090
41091      const fullscreenBtn = document.createElement("media-fullscreen-button");
41092
41093      controlBar.append(
41094        playBtn, timeDisplay, timeRange,
41095        playbackRate, muteBtn, volumeRange,
41096        captionsBtn, qualityBtn, fullscreenBtn
41097      );
41098
41099      const poster = document.createElement("media-poster-image");
41100      poster.setAttribute("slot", "poster");
41101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
41102
41103      controller.append(provider, poster, controlBar);
41104
41105      captionsBtn.setAttribute("aria-checked", "true");
41106      controller.setAttribute("mediasubtitlesshowing", "on");
41107
41108      container.appendChild(controller);
41109      details.appendChild(container);
41110
41111      provider.addEventListener("waiting", () => container.classList.add("loading"));
41112      provider.addEventListener("playing", () => container.classList.remove("loading"));
41113      provider.addEventListener("play", () => container.classList.remove("loading"));
41114
41115      provider._textTracks.addEventListener("change", () => {
41116        const enabled = provider._captionsEnabled;
41117        captionsBtn.setAttribute("aria-checked", enabled);
41118        if (enabled) {
41119          controller.setAttribute("mediasubtitlesshowing", "on");
41120        } else {
41121          controller.removeAttribute("mediasubtitlesshowing");
41122        }
41123      });
41124
41125      provider.addEventListener("qualitychange", (e) => {
41126        qualityBtn.textContent = e.detail.quality;
41127      });
41128    }
41129
41130    function initLazyPlayers() {
41131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
41132        if (details.dataset.lazyInit) return;
41133        details.dataset.lazyInit = "true";
41134        details.addEventListener("toggle", () => {
41135          if (details.open && !details.querySelector(".player-container")) {
41136            createPlayer(details);
41137          } else if (!details.open) {
41138            const provider = details.querySelector("youtube-media-provider");
41139            if (provider && typeof provider.pause === "function") {
41140              provider.pause();
41141            }
41142          }
41143        });
41144      });
41145    }
41146
41147    if (document.readyState === "loading") {
41148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
41149    } else {
41150      initLazyPlayers();
41151    }
41152  })();
41153</script>
41153<script>
41154  (() => {
41155    if (window.__courseCompletionsInitialized) return;
41156    window.__courseCompletionsInitialized = true;
41157
41158    const STORAGE_KEY = "course-completions";
41159
41160    function slugify(text) {
41161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
41162    }
41163
41164    function getCompletions() {
41165      try {
41166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
41167      } catch {
41168        return [];
41169      }
41170    }
41171
41172    function saveCompletions(completions) {
41173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
41174    }
41175
41176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
41177    function canonicalFromStored(storedId) {
41178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41179      if (!m) return slugify(storedId);
41180      const [, slug, p, mv, title] = m;
41181      return `${slug}-${p}-${mv}-${slugify(title)}`;
41182    }
41183
41184    function canonicalPlusOne(storedId) {
41185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41186      if (!m) return null;
41187      const [, slug, p, mv, title] = m;
41188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
41189    }
41190
41191    function isCompleted(canonicalId, completions) {
41192      if (completions.includes(canonicalId)) return true;
41193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
41194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
41195    }
41196
41197    function migrateCompletions() {
41198      const completions = getCompletions();
41199      let changed = false;
41200      const migrated = completions.map((c) => {
41201        const canon = canonicalFromStored(c);
41202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
41203        const plusOne = canonicalPlusOne(c);
41204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
41205          if (plusOne !== c) changed = true;
41206          return plusOne;
41207        }
41208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
41209          changed = true;
41210          return canon;
41211        }
41212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
41213        if (canon !== c) {
41214          changed = true;
41215          return canon;
41216        }
41217        return c;
41218      });
41219      // Elimina duplicados tras migración
41220      const deduped = [...new Set(migrated)];
41221      if (changed || deduped.length !== completions.length) {
41222        saveCompletions(deduped);
41223        return deduped;
41224      }
41225      return completions;
41226    }
41227
41228    function updateButtonState(btn, completions) {
41229      const id = btn.dataset.completionId;
41230      const list = completions ?? getCompletions();
41231      btn.classList.toggle("completed", isCompleted(id, list));
41232    }
41233
41234    function checkModuleCompletion(completions) {
41235      const list = completions ?? getCompletions();
41236      document.querySelectorAll(".course-video").forEach((module) => {
41237        const btns = module.querySelectorAll(".complete-btn");
41238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
41239        const check = module.querySelector(".module-check");
41240        if (check) check.classList.toggle("visible", allCompleted);
41241      });
41242    }
41243
41244    function initCompletionButtons() {
41245      const completions = migrateCompletions();
41246
41247      document.querySelectorAll(".complete-btn").forEach((btn) => {
41248        if (btn.dataset.completionInit) return;
41249        btn.dataset.completionInit = "true";
41250        updateButtonState(btn, completions);
41251
41252        btn.addEventListener("click", (e) => {
41253          e.preventDefault();
41254          e.stopPropagation();
41255
41256          const id = btn.dataset.completionId;
41257          let list = getCompletions();
41258          const completed = isCompleted(id, list);
41259
41260          if (completed) {
41261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
41262            list = list.filter((c) =>
41262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
41263          } else {
41264            list.push(id);
41265          }
41266
41267          saveCompletions(list);
41268          updateButtonState(btn, list);
41269          checkModuleCompletion(list);
41270        });
41271      });
41272
41273      checkModuleCompletion(completions);
41274    }
41275
41276    if (document.readyState === "loading") {
41277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
41278    } else {
41279      initCompletionButtons();
41280    }
41281  })();
41282</script>
41282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="8658" data-end="9952" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Fetch (Peticiones HTTP)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>22 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-fetch-peticiones-http" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
41282<script>
41283  (() => {
41284    if (window.__courseLazyPlayersInitialized) return;
41285    window.__courseLazyPlayersInitialized = true;
41286
41287    function createPlayer(details) {
41288      const videoId = details.dataset.videoId;
41289      const start = details.dataset.start;
41290      const end = details.dataset.end;
41291
41292      const container = document.createElement("div");
41293      container.className = "player-container";
41294
41295      const spinner = document.createElement("div");
41296      spinner.className = "spinner";
41297      container.appendChild(spinner);
41298
41299      const controller = document.createElement("media-controller");
41300      controller.setAttribute("defaultsubtitles", "");
41301
41302      const provider = document.createElement("youtube-media-provider");
41303      provider.setAttribute("slot", "media");
41304      provider.setAttribute("video-id", videoId);
41305      provider.setAttribute("start", start);
41306      provider.setAttribute("end", end);
41307
41308      const controlBar = document.createElement("media-control-bar");
41309
41310      const playBtn = document.createElement("media-play-button");
41311      const timeDisplay = document.createElement("media-time-display");
41312      timeDisplay.setAttribute("showduration", "");
41313      const timeRange = document.createElement("media-time-range");
41314      const playbackRate = document.createElement("media-playback-rate-button");
41315      const muteBtn = document.createElement("media-mute-button");
41316      const volumeRange = document.createElement("media-volume-range");
41317
41318      const captionsBtn = document.createElement("media-captions-button");
41319      captionsBtn.addEventListener("click", (e) => {
41320        e.stopPropagation();
41321        const provider = container.querySelector("youtube-media-provider");
41322        if (provider) provider.toggleCaptions();
41323      });
41324
41325      const qualityBtn = document.createElement("media-chrome-button");
41326      qualityBtn.textContent = "--";
41327      qualityBtn.addEventListener("click", () => {
41328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
41329      });
41330
41331      const fullscreenBtn = document.createElement("media-fullscreen-button");
41332
41333      controlBar.append(
41334        playBtn, timeDisplay, timeRange,
41335        playbackRate, muteBtn, volumeRange,
41336        captionsBtn, qualityBtn, fullscreenBtn
41337      );
41338
41339      const poster = document.createElement("media-poster-image");
41340      poster.setAttribute("slot", "poster");
41341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
41342
41343      controller.append(provider, poster, controlBar);
41344
41345      captionsBtn.setAttribute("aria-checked", "true");
41346      controller.setAttribute("mediasubtitlesshowing", "on");
41347
41348      container.appendChild(controller);
41349      details.appendChild(container);
41350
41351      provider.addEventListener("waiting", () => container.classList.add("loading"));
41352      provider.addEventListener("playing", () => container.classList.remove("loading"));
41353      provider.addEventListener("play", () => container.classList.remove("loading"));
41354
41355      provider._textTracks.addEventListener("change", () => {
41356        const enabled = provider._captionsEnabled;
41357        captionsBtn.setAttribute("aria-checked", enabled);
41358        if (enabled) {
41359          controller.setAttribute("mediasubtitlesshowing", "on");
41360        } else {
41361          controller.removeAttribute("mediasubtitlesshowing");
41362        }
41363      });
41364
41365      provider.addEventListener("qualitychange", (e) => {
41366        qualityBtn.textContent = e.detail.quality;
41367      });
41368    }
41369
41370    function initLazyPlayers() {
41371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
41372        if (details.dataset.lazyInit) return;
41373        details.dataset.lazyInit = "true";
41374        details.addEventListener("toggle", () => {
41375          if (details.open && !details.querySelector(".player-container")) {
41376            createPlayer(details);
41377          } else if (!details.open) {
41378            const provider = details.querySelector("youtube-media-provider");
41379            if (provider && typeof provider.pause === "function") {
41380              provider.pause();
41381            }
41382          }
41383        });
41384      });
41385    }
41386
41387    if (document.readyState === "loading") {
41388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
41389    } else {
41390      initLazyPlayers();
41391    }
41392  })();
41393</script>
41393<script>
41394  (() => {
41395    if (window.__courseCompletionsInitialized) return;
41396    window.__courseCompletionsInitialized = true;
41397
41398    const STORAGE_KEY = "course-completions";
41399
41400    function slugify(text) {
41401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
41402    }
41403
41404    function getCompletions() {
41405      try {
41406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
41407      } catch {
41408        return [];
41409      }
41410    }
41411
41412    function saveCompletions(completions) {
41413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
41414    }
41415
41416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
41417    function canonicalFromStored(storedId) {
41418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41419      if (!m) return slugify(storedId);
41420      const [, slug, p, mv, title] = m;
41421      return `${slug}-${p}-${mv}-${slugify(title)}`;
41422    }
41423
41424    function canonicalPlusOne(storedId) {
41425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41426      if (!m) return null;
41427      const [, slug, p, mv, title] = m;
41428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
41429    }
41430
41431    function isCompleted(canonicalId, completions) {
41432      if (completions.includes(canonicalId)) return true;
41433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
41434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
41435    }
41436
41437    function migrateCompletions() {
41438      const completions = getCompletions();
41439      let changed = false;
41440      const migrated = completions.map((c) => {
41441        const canon = canonicalFromStored(c);
41442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
41443        const plusOne = canonicalPlusOne(c);
41444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
41445          if (plusOne !== c) changed = true;
41446          return plusOne;
41447        }
41448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
41449          changed = true;
41450          return canon;
41451        }
41452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
41453        if (canon !== c) {
41454          changed = true;
41455          return canon;
41456        }
41457        return c;
41458      });
41459      // Elimina duplicados tras migración
41460      const deduped = [...new Set(migrated)];
41461      if (changed || deduped.length !== completions.length) {
41462        saveCompletions(deduped);
41463        return deduped;
41464      }
41465      return completions;
41466    }
41467
41468    function updateButtonState(btn, completions) {
41469      const id = btn.dataset.completionId;
41470      const list = completions ?? getCompletions();
41471      btn.classList.toggle("completed", isCompleted(id, list));
41472    }
41473
41474    function checkModuleCompletion(completions) {
41475      const list = completions ?? getCompletions();
41476      document.querySelectorAll(".course-video").forEach((module) => {
41477        const btns = module.querySelectorAll(".complete-btn");
41478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
41479        const check = module.querySelector(".module-check");
41480        if (check) check.classList.toggle("visible", allCompleted);
41481      });
41482    }
41483
41484    function initCompletionButtons() {
41485      const completions = migrateCompletions();
41486
41487      document.querySelectorAll(".complete-btn").forEach((btn) => {
41488        if (btn.dataset.completionInit) return;
41489        btn.dataset.completionInit = "true";
41490        updateButtonState(btn, completions);
41491
41492        btn.addEventListener("click", (e) => {
41493          e.preventDefault();
41494          e.stopPropagation();
41495
41496          const id = btn.dataset.completionId;
41497          let list = getCompletions();
41498          const completed = isCompleted(id, list);
41499
41500          if (completed) {
41501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
41502            list = list.filter((c) =>
41502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
41503          } else {
41504            list.push(id);
41505          }
41506
41507          saveCompletions(list);
41508          updateButtonState(btn, list);
41509          checkModuleCompletion(list);
41510        });
41511      });
41512
41513      checkModuleCompletion(completions);
41514    }
41515
41516    if (document.readyState === "loading") {
41517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
41518    } else {
41519      initCompletionButtons();
41520    }
41521  })();
41522</script>
41522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="10200" data-end="11340" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Request / Response</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-request-response" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
41522<script>
41523  (() => {
41524    if (window.__courseLazyPlayersInitialized) return;
41525    window.__courseLazyPlayersInitialized = true;
41526
41527    function createPlayer(details) {
41528      const videoId = details.dataset.videoId;
41529      const start = details.dataset.start;
41530      const end = details.dataset.end;
41531
41532      const container = document.createElement("div");
41533      container.className = "player-container";
41534
41535      const spinner = document.createElement("div");
41536      spinner.className = "spinner";
41537      container.appendChild(spinner);
41538
41539      const controller = document.createElement("media-controller");
41540      controller.setAttribute("defaultsubtitles", "");
41541
41542      const provider = document.createElement("youtube-media-provider");
41543      provider.setAttribute("slot", "media");
41544      provider.setAttribute("video-id", videoId);
41545      provider.setAttribute("start", start);
41546      provider.setAttribute("end", end);
41547
41548      const controlBar = document.createElement("media-control-bar");
41549
41550      const playBtn = document.createElement("media-play-button");
41551      const timeDisplay = document.createElement("media-time-display");
41552      timeDisplay.setAttribute("showduration", "");
41553      const timeRange = document.createElement("media-time-range");
41554      const playbackRate = document.createElement("media-playback-rate-button");
41555      const muteBtn = document.createElement("media-mute-button");
41556      const volumeRange = document.createElement("media-volume-range");
41557
41558      const captionsBtn = document.createElement("media-captions-button");
41559      captionsBtn.addEventListener("click", (e) => {
41560        e.stopPropagation();
41561        const provider = container.querySelector("youtube-media-provider");
41562        if (provider) provider.toggleCaptions();
41563      });
41564
41565      const qualityBtn = document.createElement("media-chrome-button");
41566      qualityBtn.textContent = "--";
41567      qualityBtn.addEventListener("click", () => {
41568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
41569      });
41570
41571      const fullscreenBtn = document.createElement("media-fullscreen-button");
41572
41573      controlBar.append(
41574        playBtn, timeDisplay, timeRange,
41575        playbackRate, muteBtn, volumeRange,
41576        captionsBtn, qualityBtn, fullscreenBtn
41577      );
41578
41579      const poster = document.createElement("media-poster-image");
41580      poster.setAttribute("slot", "poster");
41581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
41582
41583      controller.append(provider, poster, controlBar);
41584
41585      captionsBtn.setAttribute("aria-checked", "true");
41586      controller.setAttribute("mediasubtitlesshowing", "on");
41587
41588      container.appendChild(controller);
41589      details.appendChild(container);
41590
41591      provider.addEventListener("waiting", () => container.classList.add("loading"));
41592      provider.addEventListener("playing", () => container.classList.remove("loading"));
41593      provider.addEventListener("play", () => container.classList.remove("loading"));
41594
41595      provider._textTracks.addEventListener("change", () => {
41596        const enabled = provider._captionsEnabled;
41597        captionsBtn.setAttribute("aria-checked", enabled);
41598        if (enabled) {
41599          controller.setAttribute("mediasubtitlesshowing", "on");
41600        } else {
41601          controller.removeAttribute("mediasubtitlesshowing");
41602        }
41603      });
41604
41605      provider.addEventListener("qualitychange", (e) => {
41606        qualityBtn.textContent = e.detail.quality;
41607      });
41608    }
41609
41610    function initLazyPlayers() {
41611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
41612        if (details.dataset.lazyInit) return;
41613        details.dataset.lazyInit = "true";
41614        details.addEventListener("toggle", () => {
41615          if (details.open && !details.querySelector(".player-container")) {
41616            createPlayer(details);
41617          } else if (!details.open) {
41618            const provider = details.querySelector("youtube-media-provider");
41619            if (provider && typeof provider.pause === "function") {
41620              provider.pause();
41621            }
41622          }
41623        });
41624      });
41625    }
41626
41627    if (document.readyState === "loading") {
41628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
41629    } else {
41630      initLazyPlayers();
41631    }
41632  })();
41633</script>
41633<script>
41634  (() => {
41635    if (window.__courseCompletionsInitialized) return;
41636    window.__courseCompletionsInitialized = true;
41637
41638    const STORAGE_KEY = "course-completions";
41639
41640    function slugify(text) {
41641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
41642    }
41643
41644    function getCompletions() {
41645      try {
41646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
41647      } catch {
41648        return [];
41649      }
41650    }
41651
41652    function saveCompletions(completions) {
41653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
41654    }
41655
41656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
41657    function canonicalFromStored(storedId) {
41658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41659      if (!m) return slugify(storedId);
41660      const [, slug, p, mv, title] = m;
41661      return `${slug}-${p}-${mv}-${slugify(title)}`;
41662    }
41663
41664    function canonicalPlusOne(storedId) {
41665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41666      if (!m) return null;
41667      const [, slug, p, mv, title] = m;
41668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
41669    }
41670
41671    function isCompleted(canonicalId, completions) {
41672      if (completions.includes(canonicalId)) return true;
41673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
41674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
41675    }
41676
41677    function migrateCompletions() {
41678      const completions = getCompletions();
41679      let changed = false;
41680      const migrated = completions.map((c) => {
41681        const canon = canonicalFromStored(c);
41682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
41683        const plusOne = canonicalPlusOne(c);
41684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
41685          if (plusOne !== c) changed = true;
41686          return plusOne;
41687        }
41688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
41689          changed = true;
41690          return canon;
41691        }
41692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
41693        if (canon !== c) {
41694          changed = true;
41695          return canon;
41696        }
41697        return c;
41698      });
41699      // Elimina duplicados tras migración
41700      const deduped = [...new Set(migrated)];
41701      if (changed || deduped.length !== completions.length) {
41702        saveCompletions(deduped);
41703        return deduped;
41704      }
41705      return completions;
41706    }
41707
41708    function updateButtonState(btn, completions) {
41709      const id = btn.dataset.completionId;
41710      const list = completions ?? getCompletions();
41711      btn.classList.toggle("completed", isCompleted(id, list));
41712    }
41713
41714    function checkModuleCompletion(completions) {
41715      const list = completions ?? getCompletions();
41716      document.querySelectorAll(".course-video").forEach((module) => {
41717        const btns = module.querySelectorAll(".complete-btn");
41718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
41719        const check = module.querySelector(".module-check");
41720        if (check) check.classList.toggle("visible", allCompleted);
41721      });
41722    }
41723
41724    function initCompletionButtons() {
41725      const completions = migrateCompletions();
41726
41727      document.querySelectorAll(".complete-btn").forEach((btn) => {
41728        if (btn.dataset.completionInit) return;
41729        btn.dataset.completionInit = "true";
41730        updateButtonState(btn, completions);
41731
41732        btn.addEventListener("click", (e) => {
41733          e.preventDefault();
41734          e.stopPropagation();
41735
41736          const id = btn.dataset.completionId;
41737          let list = getCompletions();
41738          const completed = isCompleted(id, list);
41739
41740          if (completed) {
41741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
41742            list = list.filter((c) =>
41742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
41743          } else {
41744            list.push(id);
41745          }
41746
41747          saveCompletions(list);
41748          updateButtonState(btn, list);
41749          checkModuleCompletion(list);
41750        });
41751      });
41752
41753      checkModuleCompletion(completions);
41754    }
41755
41756    if (document.readyState === "loading") {
41757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
41758    } else {
41759      initCompletionButtons();
41760    }
41761  })();
41762</script>
41762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="t4JV5wwNG64" data-start="11340" data-end="13500" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Ejercicio propuesto</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>36 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-2-ejercicio-propuesto" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
41762<script>
41763  (() => {
41764    if (window.__courseLazyPlayersInitialized) return;
41765    window.__courseLazyPlayersInitialized = true;
41766
41767    function createPlayer(details) {
41768      const videoId = details.dataset.videoId;
41769      const start = details.dataset.start;
41770      const end = details.dataset.end;
41771
41772      const container = document.createElement("div");
41773      container.className = "player-container";
41774
41775      const spinner = document.createElement("div");
41776      spinner.className = "spinner";
41777      container.appendChild(spinner);
41778
41779      const controller = document.createElement("media-controller");
41780      controller.setAttribute("defaultsubtitles", "");
41781
41782      const provider = document.createElement("youtube-media-provider");
41783      provider.setAttribute("slot", "media");
41784      provider.setAttribute("video-id", videoId);
41785      provider.setAttribute("start", start);
41786      provider.setAttribute("end", end);
41787
41788      const controlBar = document.createElement("media-control-bar");
41789
41790      const playBtn = document.createElement("media-play-button");
41791      const timeDisplay = document.createElement("media-time-display");
41792      timeDisplay.setAttribute("showduration", "");
41793      const timeRange = document.createElement("media-time-range");
41794      const playbackRate = document.createElement("media-playback-rate-button");
41795      const muteBtn = document.createElement("media-mute-button");
41796      const volumeRange = document.createElement("media-volume-range");
41797
41798      const captionsBtn = document.createElement("media-captions-button");
41799      captionsBtn.addEventListener("click", (e) => {
41800        e.stopPropagation();
41801        const provider = container.querySelector("youtube-media-provider");
41802        if (provider) provider.toggleCaptions();
41803      });
41804
41805      const qualityBtn = document.createElement("media-chrome-button");
41806      qualityBtn.textContent = "--";
41807      qualityBtn.addEventListener("click", () => {
41808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
41809      });
41810
41811      const fullscreenBtn = document.createElement("media-fullscreen-button");
41812
41813      controlBar.append(
41814        playBtn, timeDisplay, timeRange,
41815        playbackRate, muteBtn, volumeRange,
41816        captionsBtn, qualityBtn, fullscreenBtn
41817      );
41818
41819      const poster = document.createElement("media-poster-image");
41820      poster.setAttribute("slot", "poster");
41821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
41822
41823      controller.append(provider, poster, controlBar);
41824
41825      captionsBtn.setAttribute("aria-checked", "true");
41826      controller.setAttribute("mediasubtitlesshowing", "on");
41827
41828      container.appendChild(controller);
41829      details.appendChild(container);
41830
41831      provider.addEventListener("waiting", () => container.classList.add("loading"));
41832      provider.addEventListener("playing", () => container.classList.remove("loading"));
41833      provider.addEventListener("play", () => container.classList.remove("loading"));
41834
41835      provider._textTracks.addEventListener("change", () => {
41836        const enabled = provider._captionsEnabled;
41837        captionsBtn.setAttribute("aria-checked", enabled);
41838        if (enabled) {
41839          controller.setAttribute("mediasubtitlesshowing", "on");
41840        } else {
41841          controller.removeAttribute("mediasubtitlesshowing");
41842        }
41843      });
41844
41845      provider.addEventListener("qualitychange", (e) => {
41846        qualityBtn.textContent = e.detail.quality;
41847      });
41848    }
41849
41850    function initLazyPlayers() {
41851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
41852        if (details.dataset.lazyInit) return;
41853        details.dataset.lazyInit = "true";
41854        details.addEventListener("toggle", () => {
41855          if (details.open && !details.querySelector(".player-container")) {
41856            createPlayer(details);
41857          } else if (!details.open) {
41858            const provider = details.querySelector("youtube-media-provider");
41859            if (provider && typeof provider.pause === "function") {
41860              provider.pause();
41861            }
41862          }
41863        });
41864      });
41865    }
41866
41867    if (document.readyState === "loading") {
41868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
41869    } else {
41870      initLazyPlayers();
41871    }
41872  })();
41873</script>
41873<script>
41874  (() => {
41875    if (window.__courseCompletionsInitialized) return;
41876    window.__courseCompletionsInitialized = true;
41877
41878    const STORAGE_KEY = "course-completions";
41879
41880    function slugify(text) {
41881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
41882    }
41883
41884    function getCompletions() {
41885      try {
41886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
41887      } catch {
41888        return [];
41889      }
41890    }
41891
41892    function saveCompletions(completions) {
41893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
41894    }
41895
41896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
41897    function canonicalFromStored(storedId) {
41898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41899      if (!m) return slugify(storedId);
41900      const [, slug, p, mv, title] = m;
41901      return `${slug}-${p}-${mv}-${slugify(title)}`;
41902    }
41903
41904    function canonicalPlusOne(storedId) {
41905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
41906      if (!m) return null;
41907      const [, slug, p, mv, title] = m;
41908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
41909    }
41910
41911    function isCompleted(canonicalId, completions) {
41912      if (completions.includes(canonicalId)) return true;
41913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
41914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
41915    }
41916
41917    function migrateCompletions() {
41918      const completions = getCompletions();
41919      let changed = false;
41920      const migrated = completions.map((c) => {
41921        const canon = canonicalFromStored(c);
41922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
41923        const plusOne = canonicalPlusOne(c);
41924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
41925          if (plusOne !== c) changed = true;
41926          return plusOne;
41927        }
41928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
41929          changed = true;
41930          return canon;
41931        }
41932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
41933        if (canon !== c) {
41934          changed = true;
41935          return canon;
41936        }
41937        return c;
41938      });
41939      // Elimina duplicados tras migración
41940      const deduped = [...new Set(migrated)];
41941      if (changed || deduped.length !== completions.length) {
41942        saveCompletions(deduped);
41943        return deduped;
41944      }
41945      return completions;
41946    }
41947
41948    function updateButtonState(btn, completions) {
41949      const id = btn.dataset.completionId;
41950      const list = completions ?? getCompletions();
41951      btn.classList.toggle("completed", isCompleted(id, list));
41952    }
41953
41954    function checkModuleCompletion(completions) {
41955      const list = completions ?? getCompletions();
41956      document.querySelectorAll(".course-video").forEach((module) => {
41957        const btns = module.querySelectorAll(".complete-btn");
41958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
41959        const check = module.querySelector(".module-check");
41960        if (check) check.classList.toggle("visible", allCompleted);
41961      });
41962    }
41963
41964    function initCompletionButtons() {
41965      const completions = migrateCompletions();
41966
41967      document.querySelectorAll(".complete-btn").forEach((btn) => {
41968        if (btn.dataset.completionInit) return;
41969        btn.dataset.completionInit = "true";
41970        updateButtonState(btn, completions);
41971
41972        btn.addEventListener("click", (e) => {
41973          e.preventDefault();
41974          e.stopPropagation();
41975
41976          const id = btn.dataset.completionId;
41977          let list = getCompletions();
41978          const completed = isCompleted(id, list);
41979
41980          if (completed) {
41981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
41982            list = list.filter((c) =>
41982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
41983          } else {
41984            list.push(id);
41985          }
41986
41987          saveCompletions(list);
41988          updateButtonState(btn, list);
41989          checkModuleCompletion(list);
41990        });
41991      });
41992
41993      checkModuleCompletion(completions);
41994    }
41995
41996    if (document.readyState === "loading") {
41997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
41998    } else {
41999      initCompletionButtons();
42000    }
42001  })();
42002</script>
42002</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 15 📘 WebComponents<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="1720" data-end="2953" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-introduccion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
42002<script>
42003  (() => {
42004    if (window.__courseLazyPlayersInitialized) return;
42005    window.__courseLazyPlayersInitialized = true;
42006
42007    function createPlayer(details) {
42008      const videoId = details.dataset.videoId;
42009      const start = details.dataset.start;
42010      const end = details.dataset.end;
42011
42012      const container = document.createElement("div");
42013      container.className = "player-container";
42014
42015      const spinner = document.createElement("div");
42016      spinner.className = "spinner";
42017      container.appendChild(spinner);
42018
42019      const controller = document.createElement("media-controller");
42020      controller.setAttribute("defaultsubtitles", "");
42021
42022      const provider = document.createElement("youtube-media-provider");
42023      provider.setAttribute("slot", "media");
42024      provider.setAttribute("video-id", videoId);
42025      provider.setAttribute("start", start);
42026      provider.setAttribute("end", end);
42027
42028      const controlBar = document.createElement("media-control-bar");
42029
42030      const playBtn = document.createElement("media-play-button");
42031      const timeDisplay = document.createElement("media-time-display");
42032      timeDisplay.setAttribute("showduration", "");
42033      const timeRange = document.createElement("media-time-range");
42034      const playbackRate = document.createElement("media-playback-rate-button");
42035      const muteBtn = document.createElement("media-mute-button");
42036      const volumeRange = document.createElement("media-volume-range");
42037
42038      const captionsBtn = document.createElement("media-captions-button");
42039      captionsBtn.addEventListener("click", (e) => {
42040        e.stopPropagation();
42041        const provider = container.querySelector("youtube-media-provider");
42042        if (provider) provider.toggleCaptions();
42043      });
42044
42045      const qualityBtn = document.createElement("media-chrome-button");
42046      qualityBtn.textContent = "--";
42047      qualityBtn.addEventListener("click", () => {
42048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
42049      });
42050
42051      const fullscreenBtn = document.createElement("media-fullscreen-button");
42052
42053      controlBar.append(
42054        playBtn, timeDisplay, timeRange,
42055        playbackRate, muteBtn, volumeRange,
42056        captionsBtn, qualityBtn, fullscreenBtn
42057      );
42058
42059      const poster = document.createElement("media-poster-image");
42060      poster.setAttribute("slot", "poster");
42061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
42062
42063      controller.append(provider, poster, controlBar);
42064
42065      captionsBtn.setAttribute("aria-checked", "true");
42066      controller.setAttribute("mediasubtitlesshowing", "on");
42067
42068      container.appendChild(controller);
42069      details.appendChild(container);
42070
42071      provider.addEventListener("waiting", () => container.classList.add("loading"));
42072      provider.addEventListener("playing", () => container.classList.remove("loading"));
42073      provider.addEventListener("play", () => container.classList.remove("loading"));
42074
42075      provider._textTracks.addEventListener("change", () => {
42076        const enabled = provider._captionsEnabled;
42077        captionsBtn.setAttribute("aria-checked", enabled);
42078        if (enabled) {
42079          controller.setAttribute("mediasubtitlesshowing", "on");
42080        } else {
42081          controller.removeAttribute("mediasubtitlesshowing");
42082        }
42083      });
42084
42085      provider.addEventListener("qualitychange", (e) => {
42086        qualityBtn.textContent = e.detail.quality;
42087      });
42088    }
42089
42090    function initLazyPlayers() {
42091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
42092        if (details.dataset.lazyInit) return;
42093        details.dataset.lazyInit = "true";
42094        details.addEventListener("toggle", () => {
42095          if (details.open && !details.querySelector(".player-container")) {
42096            createPlayer(details);
42097          } else if (!details.open) {
42098            const provider = details.querySelector("youtube-media-provider");
42099            if (provider && typeof provider.pause === "function") {
42100              provider.pause();
42101            }
42102          }
42103        });
42104      });
42105    }
42106
42107    if (document.readyState === "loading") {
42108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
42109    } else {
42110      initLazyPlayers();
42111    }
42112  })();
42113</script>
42113<script>
42114  (() => {
42115    if (window.__courseCompletionsInitialized) return;
42116    window.__courseCompletionsInitialized = true;
42117
42118    const STORAGE_KEY = "course-completions";
42119
42120    function slugify(text) {
42121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
42122    }
42123
42124    function getCompletions() {
42125      try {
42126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
42127      } catch {
42128        return [];
42129      }
42130    }
42131
42132    function saveCompletions(completions) {
42133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
42134    }
42135
42136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
42137    function canonicalFromStored(storedId) {
42138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42139      if (!m) return slugify(storedId);
42140      const [, slug, p, mv, title] = m;
42141      return `${slug}-${p}-${mv}-${slugify(title)}`;
42142    }
42143
42144    function canonicalPlusOne(storedId) {
42145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42146      if (!m) return null;
42147      const [, slug, p, mv, title] = m;
42148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
42149    }
42150
42151    function isCompleted(canonicalId, completions) {
42152      if (completions.includes(canonicalId)) return true;
42153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
42154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
42155    }
42156
42157    function migrateCompletions() {
42158      const completions = getCompletions();
42159      let changed = false;
42160      const migrated = completions.map((c) => {
42161        const canon = canonicalFromStored(c);
42162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
42163        const plusOne = canonicalPlusOne(c);
42164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
42165          if (plusOne !== c) changed = true;
42166          return plusOne;
42167        }
42168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
42169          changed = true;
42170          return canon;
42171        }
42172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
42173        if (canon !== c) {
42174          changed = true;
42175          return canon;
42176        }
42177        return c;
42178      });
42179      // Elimina duplicados tras migración
42180      const deduped = [...new Set(migrated)];
42181      if (changed || deduped.length !== completions.length) {
42182        saveCompletions(deduped);
42183        return deduped;
42184      }
42185      return completions;
42186    }
42187
42188    function updateButtonState(btn, completions) {
42189      const id = btn.dataset.completionId;
42190      const list = completions ?? getCompletions();
42191      btn.classList.toggle("completed", isCompleted(id, list));
42192    }
42193
42194    function checkModuleCompletion(completions) {
42195      const list = completions ?? getCompletions();
42196      document.querySelectorAll(".course-video").forEach((module) => {
42197        const btns = module.querySelectorAll(".complete-btn");
42198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
42199        const check = module.querySelector(".module-check");
42200        if (check) check.classList.toggle("visible", allCompleted);
42201      });
42202    }
42203
42204    function initCompletionButtons() {
42205      const completions = migrateCompletions();
42206
42207      document.querySelectorAll(".complete-btn").forEach((btn) => {
42208        if (btn.dataset.completionInit) return;
42209        btn.dataset.completionInit = "true";
42210        updateButtonState(btn, completions);
42211
42212        btn.addEventListener("click", (e) => {
42213          e.preventDefault();
42214          e.stopPropagation();
42215
42216          const id = btn.dataset.completionId;
42217          let list = getCompletions();
42218          const completed = isCompleted(id, list);
42219
42220          if (completed) {
42221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
42222            list = list.filter((c) =>
42222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
42223          } else {
42224            list.push(id);
42225          }
42226
42227          saveCompletions(list);
42228          updateButtonState(btn, list);
42229          checkModuleCompletion(list);
42230        });
42231      });
42232
42233      checkModuleCompletion(completions);
42234    }
42235
42236    if (document.readyState === "loading") {
42237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
42238    } else {
42239      initCompletionButtons();
42240    }
42241  })();
42242</script>
42242<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="2953" data-end="4340" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Custom Elements</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>23 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-custom-elements" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
42242<script>
42243  (() => {
42244    if (window.__courseLazyPlayersInitialized) return;
42245    window.__courseLazyPlayersInitialized = true;
42246
42247    function createPlayer(details) {
42248      const videoId = details.dataset.videoId;
42249      const start = details.dataset.start;
42250      const end = details.dataset.end;
42251
42252      const container = document.createElement("div");
42253      container.className = "player-container";
42254
42255      const spinner = document.createElement("div");
42256      spinner.className = "spinner";
42257      container.appendChild(spinner);
42258
42259      const controller = document.createElement("media-controller");
42260      controller.setAttribute("defaultsubtitles", "");
42261
42262      const provider = document.createElement("youtube-media-provider");
42263      provider.setAttribute("slot", "media");
42264      provider.setAttribute("video-id", videoId);
42265      provider.setAttribute("start", start);
42266      provider.setAttribute("end", end);
42267
42268      const controlBar = document.createElement("media-control-bar");
42269
42270      const playBtn = document.createElement("media-play-button");
42271      const timeDisplay = document.createElement("media-time-display");
42272      timeDisplay.setAttribute("showduration", "");
42273      const timeRange = document.createElement("media-time-range");
42274      const playbackRate = document.createElement("media-playback-rate-button");
42275      const muteBtn = document.createElement("media-mute-button");
42276      const volumeRange = document.createElement("media-volume-range");
42277
42278      const captionsBtn = document.createElement("media-captions-button");
42279      captionsBtn.addEventListener("click", (e) => {
42280        e.stopPropagation();
42281        const provider = container.querySelector("youtube-media-provider");
42282        if (provider) provider.toggleCaptions();
42283      });
42284
42285      const qualityBtn = document.createElement("media-chrome-button");
42286      qualityBtn.textContent = "--";
42287      qualityBtn.addEventListener("click", () => {
42288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
42289      });
42290
42291      const fullscreenBtn = document.createElement("media-fullscreen-button");
42292
42293      controlBar.append(
42294        playBtn, timeDisplay, timeRange,
42295        playbackRate, muteBtn, volumeRange,
42296        captionsBtn, qualityBtn, fullscreenBtn
42297      );
42298
42299      const poster = document.createElement("media-poster-image");
42300      poster.setAttribute("slot", "poster");
42301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
42302
42303      controller.append(provider, poster, controlBar);
42304
42305      captionsBtn.setAttribute("aria-checked", "true");
42306      controller.setAttribute("mediasubtitlesshowing", "on");
42307
42308      container.appendChild(controller);
42309      details.appendChild(container);
42310
42311      provider.addEventListener("waiting", () => container.classList.add("loading"));
42312      provider.addEventListener("playing", () => container.classList.remove("loading"));
42313      provider.addEventListener("play", () => container.classList.remove("loading"));
42314
42315      provider._textTracks.addEventListener("change", () => {
42316        const enabled = provider._captionsEnabled;
42317        captionsBtn.setAttribute("aria-checked", enabled);
42318        if (enabled) {
42319          controller.setAttribute("mediasubtitlesshowing", "on");
42320        } else {
42321          controller.removeAttribute("mediasubtitlesshowing");
42322        }
42323      });
42324
42325      provider.addEventListener("qualitychange", (e) => {
42326        qualityBtn.textContent = e.detail.quality;
42327      });
42328    }
42329
42330    function initLazyPlayers() {
42331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
42332        if (details.dataset.lazyInit) return;
42333        details.dataset.lazyInit = "true";
42334        details.addEventListener("toggle", () => {
42335          if (details.open && !details.querySelector(".player-container")) {
42336            createPlayer(details);
42337          } else if (!details.open) {
42338            const provider = details.querySelector("youtube-media-provider");
42339            if (provider && typeof provider.pause === "function") {
42340              provider.pause();
42341            }
42342          }
42343        });
42344      });
42345    }
42346
42347    if (document.readyState === "loading") {
42348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
42349    } else {
42350      initLazyPlayers();
42351    }
42352  })();
42353</script>
42353<script>
42354  (() => {
42355    if (window.__courseCompletionsInitialized) return;
42356    window.__courseCompletionsInitialized = true;
42357
42358    const STORAGE_KEY = "course-completions";
42359
42360    function slugify(text) {
42361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
42362    }
42363
42364    function getCompletions() {
42365      try {
42366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
42367      } catch {
42368        return [];
42369      }
42370    }
42371
42372    function saveCompletions(completions) {
42373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
42374    }
42375
42376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
42377    function canonicalFromStored(storedId) {
42378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42379      if (!m) return slugify(storedId);
42380      const [, slug, p, mv, title] = m;
42381      return `${slug}-${p}-${mv}-${slugify(title)}`;
42382    }
42383
42384    function canonicalPlusOne(storedId) {
42385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42386      if (!m) return null;
42387      const [, slug, p, mv, title] = m;
42388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
42389    }
42390
42391    function isCompleted(canonicalId, completions) {
42392      if (completions.includes(canonicalId)) return true;
42393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
42394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
42395    }
42396
42397    function migrateCompletions() {
42398      const completions = getCompletions();
42399      let changed = false;
42400      const migrated = completions.map((c) => {
42401        const canon = canonicalFromStored(c);
42402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
42403        const plusOne = canonicalPlusOne(c);
42404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
42405          if (plusOne !== c) changed = true;
42406          return plusOne;
42407        }
42408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
42409          changed = true;
42410          return canon;
42411        }
42412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
42413        if (canon !== c) {
42414          changed = true;
42415          return canon;
42416        }
42417        return c;
42418      });
42419      // Elimina duplicados tras migración
42420      const deduped = [...new Set(migrated)];
42421      if (changed || deduped.length !== completions.length) {
42422        saveCompletions(deduped);
42423        return deduped;
42424      }
42425      return completions;
42426    }
42427
42428    function updateButtonState(btn, completions) {
42429      const id = btn.dataset.completionId;
42430      const list = completions ?? getCompletions();
42431      btn.classList.toggle("completed", isCompleted(id, list));
42432    }
42433
42434    function checkModuleCompletion(completions) {
42435      const list = completions ?? getCompletions();
42436      document.querySelectorAll(".course-video").forEach((module) => {
42437        const btns = module.querySelectorAll(".complete-btn");
42438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
42439        const check = module.querySelector(".module-check");
42440        if (check) check.classList.toggle("visible", allCompleted);
42441      });
42442    }
42443
42444    function initCompletionButtons() {
42445      const completions = migrateCompletions();
42446
42447      document.querySelectorAll(".complete-btn").forEach((btn) => {
42448        if (btn.dataset.completionInit) return;
42449        btn.dataset.completionInit = "true";
42450        updateButtonState(btn, completions);
42451
42452        btn.addEventListener("click", (e) => {
42453          e.preventDefault();
42454          e.stopPropagation();
42455
42456          const id = btn.dataset.completionId;
42457          let list = getCompletions();
42458          const completed = isCompleted(id, list);
42459
42460          if (completed) {
42461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
42462            list = list.filter((c) =>
42462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
42463          } else {
42464            list.push(id);
42465          }
42466
42467          saveCompletions(list);
42468          updateButtonState(btn, list);
42469          checkModuleCompletion(list);
42470        });
42471      });
42472
42473      checkModuleCompletion(completions);
42474    }
42475
42476    if (document.readyState === "loading") {
42477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
42478    } else {
42479      initCompletionButtons();
42480    }
42481  })();
42482</script>
42482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="4340" data-end="6914" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Custom Elements + Javascript</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>43 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-custom-elements-javascript" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
42482<script>
42483  (() => {
42484    if (window.__courseLazyPlayersInitialized) return;
42485    window.__courseLazyPlayersInitialized = true;
42486
42487    function createPlayer(details) {
42488      const videoId = details.dataset.videoId;
42489      const start = details.dataset.start;
42490      const end = details.dataset.end;
42491
42492      const container = document.createElement("div");
42493      container.className = "player-container";
42494
42495      const spinner = document.createElement("div");
42496      spinner.className = "spinner";
42497      container.appendChild(spinner);
42498
42499      const controller = document.createElement("media-controller");
42500      controller.setAttribute("defaultsubtitles", "");
42501
42502      const provider = document.createElement("youtube-media-provider");
42503      provider.setAttribute("slot", "media");
42504      provider.setAttribute("video-id", videoId);
42505      provider.setAttribute("start", start);
42506      provider.setAttribute("end", end);
42507
42508      const controlBar = document.createElement("media-control-bar");
42509
42510      const playBtn = document.createElement("media-play-button");
42511      const timeDisplay = document.createElement("media-time-display");
42512      timeDisplay.setAttribute("showduration", "");
42513      const timeRange = document.createElement("media-time-range");
42514      const playbackRate = document.createElement("media-playback-rate-button");
42515      const muteBtn = document.createElement("media-mute-button");
42516      const volumeRange = document.createElement("media-volume-range");
42517
42518      const captionsBtn = document.createElement("media-captions-button");
42519      captionsBtn.addEventListener("click", (e) => {
42520        e.stopPropagation();
42521        const provider = container.querySelector("youtube-media-provider");
42522        if (provider) provider.toggleCaptions();
42523      });
42524
42525      const qualityBtn = document.createElement("media-chrome-button");
42526      qualityBtn.textContent = "--";
42527      qualityBtn.addEventListener("click", () => {
42528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
42529      });
42530
42531      const fullscreenBtn = document.createElement("media-fullscreen-button");
42532
42533      controlBar.append(
42534        playBtn, timeDisplay, timeRange,
42535        playbackRate, muteBtn, volumeRange,
42536        captionsBtn, qualityBtn, fullscreenBtn
42537      );
42538
42539      const poster = document.createElement("media-poster-image");
42540      poster.setAttribute("slot", "poster");
42541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
42542
42543      controller.append(provider, poster, controlBar);
42544
42545      captionsBtn.setAttribute("aria-checked", "true");
42546      controller.setAttribute("mediasubtitlesshowing", "on");
42547
42548      container.appendChild(controller);
42549      details.appendChild(container);
42550
42551      provider.addEventListener("waiting", () => container.classList.add("loading"));
42552      provider.addEventListener("playing", () => container.classList.remove("loading"));
42553      provider.addEventListener("play", () => container.classList.remove("loading"));
42554
42555      provider._textTracks.addEventListener("change", () => {
42556        const enabled = provider._captionsEnabled;
42557        captionsBtn.setAttribute("aria-checked", enabled);
42558        if (enabled) {
42559          controller.setAttribute("mediasubtitlesshowing", "on");
42560        } else {
42561          controller.removeAttribute("mediasubtitlesshowing");
42562        }
42563      });
42564
42565      provider.addEventListener("qualitychange", (e) => {
42566        qualityBtn.textContent = e.detail.quality;
42567      });
42568    }
42569
42570    function initLazyPlayers() {
42571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
42572        if (details.dataset.lazyInit) return;
42573        details.dataset.lazyInit = "true";
42574        details.addEventListener("toggle", () => {
42575          if (details.open && !details.querySelector(".player-container")) {
42576            createPlayer(details);
42577          } else if (!details.open) {
42578            const provider = details.querySelector("youtube-media-provider");
42579            if (provider && typeof provider.pause === "function") {
42580              provider.pause();
42581            }
42582          }
42583        });
42584      });
42585    }
42586
42587    if (document.readyState === "loading") {
42588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
42589    } else {
42590      initLazyPlayers();
42591    }
42592  })();
42593</script>
42593<script>
42594  (() => {
42595    if (window.__courseCompletionsInitialized) return;
42596    window.__courseCompletionsInitialized = true;
42597
42598    const STORAGE_KEY = "course-completions";
42599
42600    function slugify(text) {
42601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
42602    }
42603
42604    function getCompletions() {
42605      try {
42606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
42607      } catch {
42608        return [];
42609      }
42610    }
42611
42612    function saveCompletions(completions) {
42613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
42614    }
42615
42616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
42617    function canonicalFromStored(storedId) {
42618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42619      if (!m) return slugify(storedId);
42620      const [, slug, p, mv, title] = m;
42621      return `${slug}-${p}-${mv}-${slugify(title)}`;
42622    }
42623
42624    function canonicalPlusOne(storedId) {
42625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42626      if (!m) return null;
42627      const [, slug, p, mv, title] = m;
42628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
42629    }
42630
42631    function isCompleted(canonicalId, completions) {
42632      if (completions.includes(canonicalId)) return true;
42633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
42634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
42635    }
42636
42637    function migrateCompletions() {
42638      const completions = getCompletions();
42639      let changed = false;
42640      const migrated = completions.map((c) => {
42641        const canon = canonicalFromStored(c);
42642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
42643        const plusOne = canonicalPlusOne(c);
42644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
42645          if (plusOne !== c) changed = true;
42646          return plusOne;
42647        }
42648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
42649          changed = true;
42650          return canon;
42651        }
42652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
42653        if (canon !== c) {
42654          changed = true;
42655          return canon;
42656        }
42657        return c;
42658      });
42659      // Elimina duplicados tras migración
42660      const deduped = [...new Set(migrated)];
42661      if (changed || deduped.length !== completions.length) {
42662        saveCompletions(deduped);
42663        return deduped;
42664      }
42665      return completions;
42666    }
42667
42668    function updateButtonState(btn, completions) {
42669      const id = btn.dataset.completionId;
42670      const list = completions ?? getCompletions();
42671      btn.classList.toggle("completed", isCompleted(id, list));
42672    }
42673
42674    function checkModuleCompletion(completions) {
42675      const list = completions ?? getCompletions();
42676      document.querySelectorAll(".course-video").forEach((module) => {
42677        const btns = module.querySelectorAll(".complete-btn");
42678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
42679        const check = module.querySelector(".module-check");
42680        if (check) check.classList.toggle("visible", allCompleted);
42681      });
42682    }
42683
42684    function initCompletionButtons() {
42685      const completions = migrateCompletions();
42686
42687      document.querySelectorAll(".complete-btn").forEach((btn) => {
42688        if (btn.dataset.completionInit) return;
42689        btn.dataset.completionInit = "true";
42690        updateButtonState(btn, completions);
42691
42692        btn.addEventListener("click", (e) => {
42693          e.preventDefault();
42694          e.stopPropagation();
42695
42696          const id = btn.dataset.completionId;
42697          let list = getCompletions();
42698          const completed = isCompleted(id, list);
42699
42700          if (completed) {
42701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
42702            list = list.filter((c) =>
42702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
42703          } else {
42704            list.push(id);
42705          }
42706
42707          saveCompletions(list);
42708          updateButtonState(btn, list);
42709          checkModuleCompletion(list);
42710        });
42711      });
42712
42713      checkModuleCompletion(completions);
42714    }
42715
42716    if (document.readyState === "loading") {
42717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
42718    } else {
42719      initCompletionButtons();
42720    }
42721  })();
42722</script>
42722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="7326" data-end="9420" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. HTML/DOM en WebComponents</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>35 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-html-dom-en-webcomponents" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
42722<script>
42723  (() => {
42724    if (window.__courseLazyPlayersInitialized) return;
42725    window.__courseLazyPlayersInitialized = true;
42726
42727    function createPlayer(details) {
42728      const videoId = details.dataset.videoId;
42729      const start = details.dataset.start;
42730      const end = details.dataset.end;
42731
42732      const container = document.createElement("div");
42733      container.className = "player-container";
42734
42735      const spinner = document.createElement("div");
42736      spinner.className = "spinner";
42737      container.appendChild(spinner);
42738
42739      const controller = document.createElement("media-controller");
42740      controller.setAttribute("defaultsubtitles", "");
42741
42742      const provider = document.createElement("youtube-media-provider");
42743      provider.setAttribute("slot", "media");
42744      provider.setAttribute("video-id", videoId);
42745      provider.setAttribute("start", start);
42746      provider.setAttribute("end", end);
42747
42748      const controlBar = document.createElement("media-control-bar");
42749
42750      const playBtn = document.createElement("media-play-button");
42751      const timeDisplay = document.createElement("media-time-display");
42752      timeDisplay.setAttribute("showduration", "");
42753      const timeRange = document.createElement("media-time-range");
42754      const playbackRate = document.createElement("media-playback-rate-button");
42755      const muteBtn = document.createElement("media-mute-button");
42756      const volumeRange = document.createElement("media-volume-range");
42757
42758      const captionsBtn = document.createElement("media-captions-button");
42759      captionsBtn.addEventListener("click", (e) => {
42760        e.stopPropagation();
42761        const provider = container.querySelector("youtube-media-provider");
42762        if (provider) provider.toggleCaptions();
42763      });
42764
42765      const qualityBtn = document.createElement("media-chrome-button");
42766      qualityBtn.textContent = "--";
42767      qualityBtn.addEventListener("click", () => {
42768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
42769      });
42770
42771      const fullscreenBtn = document.createElement("media-fullscreen-button");
42772
42773      controlBar.append(
42774        playBtn, timeDisplay, timeRange,
42775        playbackRate, muteBtn, volumeRange,
42776        captionsBtn, qualityBtn, fullscreenBtn
42777      );
42778
42779      const poster = document.createElement("media-poster-image");
42780      poster.setAttribute("slot", "poster");
42781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
42782
42783      controller.append(provider, poster, controlBar);
42784
42785      captionsBtn.setAttribute("aria-checked", "true");
42786      controller.setAttribute("mediasubtitlesshowing", "on");
42787
42788      container.appendChild(controller);
42789      details.appendChild(container);
42790
42791      provider.addEventListener("waiting", () => container.classList.add("loading"));
42792      provider.addEventListener("playing", () => container.classList.remove("loading"));
42793      provider.addEventListener("play", () => container.classList.remove("loading"));
42794
42795      provider._textTracks.addEventListener("change", () => {
42796        const enabled = provider._captionsEnabled;
42797        captionsBtn.setAttribute("aria-checked", enabled);
42798        if (enabled) {
42799          controller.setAttribute("mediasubtitlesshowing", "on");
42800        } else {
42801          controller.removeAttribute("mediasubtitlesshowing");
42802        }
42803      });
42804
42805      provider.addEventListener("qualitychange", (e) => {
42806        qualityBtn.textContent = e.detail.quality;
42807      });
42808    }
42809
42810    function initLazyPlayers() {
42811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
42812        if (details.dataset.lazyInit) return;
42813        details.dataset.lazyInit = "true";
42814        details.addEventListener("toggle", () => {
42815          if (details.open && !details.querySelector(".player-container")) {
42816            createPlayer(details);
42817          } else if (!details.open) {
42818            const provider = details.querySelector("youtube-media-provider");
42819            if (provider && typeof provider.pause === "function") {
42820              provider.pause();
42821            }
42822          }
42823        });
42824      });
42825    }
42826
42827    if (document.readyState === "loading") {
42828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
42829    } else {
42830      initLazyPlayers();
42831    }
42832  })();
42833</script>
42833<script>
42834  (() => {
42835    if (window.__courseCompletionsInitialized) return;
42836    window.__courseCompletionsInitialized = true;
42837
42838    const STORAGE_KEY = "course-completions";
42839
42840    function slugify(text) {
42841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
42842    }
42843
42844    function getCompletions() {
42845      try {
42846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
42847      } catch {
42848        return [];
42849      }
42850    }
42851
42852    function saveCompletions(completions) {
42853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
42854    }
42855
42856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
42857    function canonicalFromStored(storedId) {
42858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42859      if (!m) return slugify(storedId);
42860      const [, slug, p, mv, title] = m;
42861      return `${slug}-${p}-${mv}-${slugify(title)}`;
42862    }
42863
42864    function canonicalPlusOne(storedId) {
42865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
42866      if (!m) return null;
42867      const [, slug, p, mv, title] = m;
42868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
42869    }
42870
42871    function isCompleted(canonicalId, completions) {
42872      if (completions.includes(canonicalId)) return true;
42873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
42874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
42875    }
42876
42877    function migrateCompletions() {
42878      const completions = getCompletions();
42879      let changed = false;
42880      const migrated = completions.map((c) => {
42881        const canon = canonicalFromStored(c);
42882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
42883        const plusOne = canonicalPlusOne(c);
42884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
42885          if (plusOne !== c) changed = true;
42886          return plusOne;
42887        }
42888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
42889          changed = true;
42890          return canon;
42891        }
42892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
42893        if (canon !== c) {
42894          changed = true;
42895          return canon;
42896        }
42897        return c;
42898      });
42899      // Elimina duplicados tras migración
42900      const deduped = [...new Set(migrated)];
42901      if (changed || deduped.length !== completions.length) {
42902        saveCompletions(deduped);
42903        return deduped;
42904      }
42905      return completions;
42906    }
42907
42908    function updateButtonState(btn, completions) {
42909      const id = btn.dataset.completionId;
42910      const list = completions ?? getCompletions();
42911      btn.classList.toggle("completed", isCompleted(id, list));
42912    }
42913
42914    function checkModuleCompletion(completions) {
42915      const list = completions ?? getCompletions();
42916      document.querySelectorAll(".course-video").forEach((module) => {
42917        const btns = module.querySelectorAll(".complete-btn");
42918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
42919        const check = module.querySelector(".module-check");
42920        if (check) check.classList.toggle("visible", allCompleted);
42921      });
42922    }
42923
42924    function initCompletionButtons() {
42925      const completions = migrateCompletions();
42926
42927      document.querySelectorAll(".complete-btn").forEach((btn) => {
42928        if (btn.dataset.completionInit) return;
42929        btn.dataset.completionInit = "true";
42930        updateButtonState(btn, completions);
42931
42932        btn.addEventListener("click", (e) => {
42933          e.preventDefault();
42934          e.stopPropagation();
42935
42936          const id = btn.dataset.completionId;
42937          let list = getCompletions();
42938          const completed = isCompleted(id, list);
42939
42940          if (completed) {
42941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
42942            list = list.filter((c) =>
42942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
42943          } else {
42944            list.push(id);
42945          }
42946
42947          saveCompletions(list);
42948          updateButtonState(btn, list);
42949          checkModuleCompletion(list);
42950        });
42951      });
42952
42953      checkModuleCompletion(completions);
42954    }
42955
42956    if (document.readyState === "loading") {
42957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
42958    } else {
42959      initCompletionButtons();
42960    }
42961  })();
42962</script>
42962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="9720" data-end="12080" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. CSS + Shadow DOM</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>39 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-css-shadow-dom" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
42962<script>
42963  (() => {
42964    if (window.__courseLazyPlayersInitialized) return;
42965    window.__courseLazyPlayersInitialized = true;
42966
42967    function createPlayer(details) {
42968      const videoId = details.dataset.videoId;
42969      const start = details.dataset.start;
42970      const end = details.dataset.end;
42971
42972      const container = document.createElement("div");
42973      container.className = "player-container";
42974
42975      const spinner = document.createElement("div");
42976      spinner.className = "spinner";
42977      container.appendChild(spinner);
42978
42979      const controller = document.createElement("media-controller");
42980      controller.setAttribute("defaultsubtitles", "");
42981
42982      const provider = document.createElement("youtube-media-provider");
42983      provider.setAttribute("slot", "media");
42984      provider.setAttribute("video-id", videoId);
42985      provider.setAttribute("start", start);
42986      provider.setAttribute("end", end);
42987
42988      const controlBar = document.createElement("media-control-bar");
42989
42990      const playBtn = document.createElement("media-play-button");
42991      const timeDisplay = document.createElement("media-time-display");
42992      timeDisplay.setAttribute("showduration", "");
42993      const timeRange = document.createElement("media-time-range");
42994      const playbackRate = document.createElement("media-playback-rate-button");
42995      const muteBtn = document.createElement("media-mute-button");
42996      const volumeRange = document.createElement("media-volume-range");
42997
42998      const captionsBtn = document.createElement("media-captions-button");
42999      captionsBtn.addEventListener("click", (e) => {
43000        e.stopPropagation();
43001        const provider = container.querySelector("youtube-media-provider");
43002        if (provider) provider.toggleCaptions();
43003      });
43004
43005      const qualityBtn = document.createElement("media-chrome-button");
43006      qualityBtn.textContent = "--";
43007      qualityBtn.addEventListener("click", () => {
43008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
43009      });
43010
43011      const fullscreenBtn = document.createElement("media-fullscreen-button");
43012
43013      controlBar.append(
43014        playBtn, timeDisplay, timeRange,
43015        playbackRate, muteBtn, volumeRange,
43016        captionsBtn, qualityBtn, fullscreenBtn
43017      );
43018
43019      const poster = document.createElement("media-poster-image");
43020      poster.setAttribute("slot", "poster");
43021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
43022
43023      controller.append(provider, poster, controlBar);
43024
43025      captionsBtn.setAttribute("aria-checked", "true");
43026      controller.setAttribute("mediasubtitlesshowing", "on");
43027
43028      container.appendChild(controller);
43029      details.appendChild(container);
43030
43031      provider.addEventListener("waiting", () => container.classList.add("loading"));
43032      provider.addEventListener("playing", () => container.classList.remove("loading"));
43033      provider.addEventListener("play", () => container.classList.remove("loading"));
43034
43035      provider._textTracks.addEventListener("change", () => {
43036        const enabled = provider._captionsEnabled;
43037        captionsBtn.setAttribute("aria-checked", enabled);
43038        if (enabled) {
43039          controller.setAttribute("mediasubtitlesshowing", "on");
43040        } else {
43041          controller.removeAttribute("mediasubtitlesshowing");
43042        }
43043      });
43044
43045      provider.addEventListener("qualitychange", (e) => {
43046        qualityBtn.textContent = e.detail.quality;
43047      });
43048    }
43049
43050    function initLazyPlayers() {
43051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
43052        if (details.dataset.lazyInit) return;
43053        details.dataset.lazyInit = "true";
43054        details.addEventListener("toggle", () => {
43055          if (details.open && !details.querySelector(".player-container")) {
43056            createPlayer(details);
43057          } else if (!details.open) {
43058            const provider = details.querySelector("youtube-media-provider");
43059            if (provider && typeof provider.pause === "function") {
43060              provider.pause();
43061            }
43062          }
43063        });
43064      });
43065    }
43066
43067    if (document.readyState === "loading") {
43068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
43069    } else {
43070      initLazyPlayers();
43071    }
43072  })();
43073</script>
43073<script>
43074  (() => {
43075    if (window.__courseCompletionsInitialized) return;
43076    window.__courseCompletionsInitialized = true;
43077
43078    const STORAGE_KEY = "course-completions";
43079
43080    function slugify(text) {
43081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
43082    }
43083
43084    function getCompletions() {
43085      try {
43086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
43087      } catch {
43088        return [];
43089      }
43090    }
43091
43092    function saveCompletions(completions) {
43093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
43094    }
43095
43096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
43097    function canonicalFromStored(storedId) {
43098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43099      if (!m) return slugify(storedId);
43100      const [, slug, p, mv, title] = m;
43101      return `${slug}-${p}-${mv}-${slugify(title)}`;
43102    }
43103
43104    function canonicalPlusOne(storedId) {
43105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43106      if (!m) return null;
43107      const [, slug, p, mv, title] = m;
43108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
43109    }
43110
43111    function isCompleted(canonicalId, completions) {
43112      if (completions.includes(canonicalId)) return true;
43113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
43114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
43115    }
43116
43117    function migrateCompletions() {
43118      const completions = getCompletions();
43119      let changed = false;
43120      const migrated = completions.map((c) => {
43121        const canon = canonicalFromStored(c);
43122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
43123        const plusOne = canonicalPlusOne(c);
43124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
43125          if (plusOne !== c) changed = true;
43126          return plusOne;
43127        }
43128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
43129          changed = true;
43130          return canon;
43131        }
43132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
43133        if (canon !== c) {
43134          changed = true;
43135          return canon;
43136        }
43137        return c;
43138      });
43139      // Elimina duplicados tras migración
43140      const deduped = [...new Set(migrated)];
43141      if (changed || deduped.length !== completions.length) {
43142        saveCompletions(deduped);
43143        return deduped;
43144      }
43145      return completions;
43146    }
43147
43148    function updateButtonState(btn, completions) {
43149      const id = btn.dataset.completionId;
43150      const list = completions ?? getCompletions();
43151      btn.classList.toggle("completed", isCompleted(id, list));
43152    }
43153
43154    function checkModuleCompletion(completions) {
43155      const list = completions ?? getCompletions();
43156      document.querySelectorAll(".course-video").forEach((module) => {
43157        const btns = module.querySelectorAll(".complete-btn");
43158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
43159        const check = module.querySelector(".module-check");
43160        if (check) check.classList.toggle("visible", allCompleted);
43161      });
43162    }
43163
43164    function initCompletionButtons() {
43165      const completions = migrateCompletions();
43166
43167      document.querySelectorAll(".complete-btn").forEach((btn) => {
43168        if (btn.dataset.completionInit) return;
43169        btn.dataset.completionInit = "true";
43170        updateButtonState(btn, completions);
43171
43172        btn.addEventListener("click", (e) => {
43173          e.preventDefault();
43174          e.stopPropagation();
43175
43176          const id = btn.dataset.completionId;
43177          let list = getCompletions();
43178          const completed = isCompleted(id, list);
43179
43180          if (completed) {
43181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
43182            list = list.filter((c) =>
43182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
43183          } else {
43184            list.push(id);
43185          }
43186
43187          saveCompletions(list);
43188          updateButtonState(btn, list);
43189          checkModuleCompletion(list);
43190        });
43191      });
43192
43193      checkModuleCompletion(completions);
43194    }
43195
43196    if (document.readyState === "loading") {
43197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
43198    } else {
43199      initCompletionButtons();
43200    }
43201  })();
43202</script>
43202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="12100" data-end="13996" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Slots + Scoping/Parts</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>32 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-slots-scoping-parts" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
43202<script>
43203  (() => {
43204    if (window.__courseLazyPlayersInitialized) return;
43205    window.__courseLazyPlayersInitialized = true;
43206
43207    function createPlayer(details) {
43208      const videoId = details.dataset.videoId;
43209      const start = details.dataset.start;
43210      const end = details.dataset.end;
43211
43212      const container = document.createElement("div");
43213      container.className = "player-container";
43214
43215      const spinner = document.createElement("div");
43216      spinner.className = "spinner";
43217      container.appendChild(spinner);
43218
43219      const controller = document.createElement("media-controller");
43220      controller.setAttribute("defaultsubtitles", "");
43221
43222      const provider = document.createElement("youtube-media-provider");
43223      provider.setAttribute("slot", "media");
43224      provider.setAttribute("video-id", videoId);
43225      provider.setAttribute("start", start);
43226      provider.setAttribute("end", end);
43227
43228      const controlBar = document.createElement("media-control-bar");
43229
43230      const playBtn = document.createElement("media-play-button");
43231      const timeDisplay = document.createElement("media-time-display");
43232      timeDisplay.setAttribute("showduration", "");
43233      const timeRange = document.createElement("media-time-range");
43234      const playbackRate = document.createElement("media-playback-rate-button");
43235      const muteBtn = document.createElement("media-mute-button");
43236      const volumeRange = document.createElement("media-volume-range");
43237
43238      const captionsBtn = document.createElement("media-captions-button");
43239      captionsBtn.addEventListener("click", (e) => {
43240        e.stopPropagation();
43241        const provider = container.querySelector("youtube-media-provider");
43242        if (provider) provider.toggleCaptions();
43243      });
43244
43245      const qualityBtn = document.createElement("media-chrome-button");
43246      qualityBtn.textContent = "--";
43247      qualityBtn.addEventListener("click", () => {
43248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
43249      });
43250
43251      const fullscreenBtn = document.createElement("media-fullscreen-button");
43252
43253      controlBar.append(
43254        playBtn, timeDisplay, timeRange,
43255        playbackRate, muteBtn, volumeRange,
43256        captionsBtn, qualityBtn, fullscreenBtn
43257      );
43258
43259      const poster = document.createElement("media-poster-image");
43260      poster.setAttribute("slot", "poster");
43261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
43262
43263      controller.append(provider, poster, controlBar);
43264
43265      captionsBtn.setAttribute("aria-checked", "true");
43266      controller.setAttribute("mediasubtitlesshowing", "on");
43267
43268      container.appendChild(controller);
43269      details.appendChild(container);
43270
43271      provider.addEventListener("waiting", () => container.classList.add("loading"));
43272      provider.addEventListener("playing", () => container.classList.remove("loading"));
43273      provider.addEventListener("play", () => container.classList.remove("loading"));
43274
43275      provider._textTracks.addEventListener("change", () => {
43276        const enabled = provider._captionsEnabled;
43277        captionsBtn.setAttribute("aria-checked", enabled);
43278        if (enabled) {
43279          controller.setAttribute("mediasubtitlesshowing", "on");
43280        } else {
43281          controller.removeAttribute("mediasubtitlesshowing");
43282        }
43283      });
43284
43285      provider.addEventListener("qualitychange", (e) => {
43286        qualityBtn.textContent = e.detail.quality;
43287      });
43288    }
43289
43290    function initLazyPlayers() {
43291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
43292        if (details.dataset.lazyInit) return;
43293        details.dataset.lazyInit = "true";
43294        details.addEventListener("toggle", () => {
43295          if (details.open && !details.querySelector(".player-container")) {
43296            createPlayer(details);
43297          } else if (!details.open) {
43298            const provider = details.querySelector("youtube-media-provider");
43299            if (provider && typeof provider.pause === "function") {
43300              provider.pause();
43301            }
43302          }
43303        });
43304      });
43305    }
43306
43307    if (document.readyState === "loading") {
43308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
43309    } else {
43310      initLazyPlayers();
43311    }
43312  })();
43313</script>
43313<script>
43314  (() => {
43315    if (window.__courseCompletionsInitialized) return;
43316    window.__courseCompletionsInitialized = true;
43317
43318    const STORAGE_KEY = "course-completions";
43319
43320    function slugify(text) {
43321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
43322    }
43323
43324    function getCompletions() {
43325      try {
43326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
43327      } catch {
43328        return [];
43329      }
43330    }
43331
43332    function saveCompletions(completions) {
43333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
43334    }
43335
43336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
43337    function canonicalFromStored(storedId) {
43338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43339      if (!m) return slugify(storedId);
43340      const [, slug, p, mv, title] = m;
43341      return `${slug}-${p}-${mv}-${slugify(title)}`;
43342    }
43343
43344    function canonicalPlusOne(storedId) {
43345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43346      if (!m) return null;
43347      const [, slug, p, mv, title] = m;
43348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
43349    }
43350
43351    function isCompleted(canonicalId, completions) {
43352      if (completions.includes(canonicalId)) return true;
43353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
43354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
43355    }
43356
43357    function migrateCompletions() {
43358      const completions = getCompletions();
43359      let changed = false;
43360      const migrated = completions.map((c) => {
43361        const canon = canonicalFromStored(c);
43362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
43363        const plusOne = canonicalPlusOne(c);
43364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
43365          if (plusOne !== c) changed = true;
43366          return plusOne;
43367        }
43368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
43369          changed = true;
43370          return canon;
43371        }
43372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
43373        if (canon !== c) {
43374          changed = true;
43375          return canon;
43376        }
43377        return c;
43378      });
43379      // Elimina duplicados tras migración
43380      const deduped = [...new Set(migrated)];
43381      if (changed || deduped.length !== completions.length) {
43382        saveCompletions(deduped);
43383        return deduped;
43384      }
43385      return completions;
43386    }
43387
43388    function updateButtonState(btn, completions) {
43389      const id = btn.dataset.completionId;
43390      const list = completions ?? getCompletions();
43391      btn.classList.toggle("completed", isCompleted(id, list));
43392    }
43393
43394    function checkModuleCompletion(completions) {
43395      const list = completions ?? getCompletions();
43396      document.querySelectorAll(".course-video").forEach((module) => {
43397        const btns = module.querySelectorAll(".complete-btn");
43398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
43399        const check = module.querySelector(".module-check");
43400        if (check) check.classList.toggle("visible", allCompleted);
43401      });
43402    }
43403
43404    function initCompletionButtons() {
43405      const completions = migrateCompletions();
43406
43407      document.querySelectorAll(".complete-btn").forEach((btn) => {
43408        if (btn.dataset.completionInit) return;
43409        btn.dataset.completionInit = "true";
43410        updateButtonState(btn, completions);
43411
43412        btn.addEventListener("click", (e) => {
43413          e.preventDefault();
43414          e.stopPropagation();
43415
43416          const id = btn.dataset.completionId;
43417          let list = getCompletions();
43418          const completed = isCompleted(id, list);
43419
43420          if (completed) {
43421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
43422            list = list.filter((c) =>
43422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
43423          } else {
43424            list.push(id);
43425          }
43426
43427          saveCompletions(list);
43428          updateButtonState(btn, list);
43429          checkModuleCompletion(list);
43430        });
43431      });
43432
43433      checkModuleCompletion(completions);
43434    }
43435
43436    if (document.readyState === "loading") {
43437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
43438    } else {
43439      initCompletionButtons();
43440    }
43441  })();
43442</script>
43442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="9lhsLDNaVLs" data-start="14295" data-end="15407" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Comunicar componentes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-3-comunicar-componentes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
43442<script>
43443  (() => {
43444    if (window.__courseLazyPlayersInitialized) return;
43445    window.__courseLazyPlayersInitialized = true;
43446
43447    function createPlayer(details) {
43448      const videoId = details.dataset.videoId;
43449      const start = details.dataset.start;
43450      const end = details.dataset.end;
43451
43452      const container = document.createElement("div");
43453      container.className = "player-container";
43454
43455      const spinner = document.createElement("div");
43456      spinner.className = "spinner";
43457      container.appendChild(spinner);
43458
43459      const controller = document.createElement("media-controller");
43460      controller.setAttribute("defaultsubtitles", "");
43461
43462      const provider = document.createElement("youtube-media-provider");
43463      provider.setAttribute("slot", "media");
43464      provider.setAttribute("video-id", videoId);
43465      provider.setAttribute("start", start);
43466      provider.setAttribute("end", end);
43467
43468      const controlBar = document.createElement("media-control-bar");
43469
43470      const playBtn = document.createElement("media-play-button");
43471      const timeDisplay = document.createElement("media-time-display");
43472      timeDisplay.setAttribute("showduration", "");
43473      const timeRange = document.createElement("media-time-range");
43474      const playbackRate = document.createElement("media-playback-rate-button");
43475      const muteBtn = document.createElement("media-mute-button");
43476      const volumeRange = document.createElement("media-volume-range");
43477
43478      const captionsBtn = document.createElement("media-captions-button");
43479      captionsBtn.addEventListener("click", (e) => {
43480        e.stopPropagation();
43481        const provider = container.querySelector("youtube-media-provider");
43482        if (provider) provider.toggleCaptions();
43483      });
43484
43485      const qualityBtn = document.createElement("media-chrome-button");
43486      qualityBtn.textContent = "--";
43487      qualityBtn.addEventListener("click", () => {
43488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
43489      });
43490
43491      const fullscreenBtn = document.createElement("media-fullscreen-button");
43492
43493      controlBar.append(
43494        playBtn, timeDisplay, timeRange,
43495        playbackRate, muteBtn, volumeRange,
43496        captionsBtn, qualityBtn, fullscreenBtn
43497      );
43498
43499      const poster = document.createElement("media-poster-image");
43500      poster.setAttribute("slot", "poster");
43501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
43502
43503      controller.append(provider, poster, controlBar);
43504
43505      captionsBtn.setAttribute("aria-checked", "true");
43506      controller.setAttribute("mediasubtitlesshowing", "on");
43507
43508      container.appendChild(controller);
43509      details.appendChild(container);
43510
43511      provider.addEventListener("waiting", () => container.classList.add("loading"));
43512      provider.addEventListener("playing", () => container.classList.remove("loading"));
43513      provider.addEventListener("play", () => container.classList.remove("loading"));
43514
43515      provider._textTracks.addEventListener("change", () => {
43516        const enabled = provider._captionsEnabled;
43517        captionsBtn.setAttribute("aria-checked", enabled);
43518        if (enabled) {
43519          controller.setAttribute("mediasubtitlesshowing", "on");
43520        } else {
43521          controller.removeAttribute("mediasubtitlesshowing");
43522        }
43523      });
43524
43525      provider.addEventListener("qualitychange", (e) => {
43526        qualityBtn.textContent = e.detail.quality;
43527      });
43528    }
43529
43530    function initLazyPlayers() {
43531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
43532        if (details.dataset.lazyInit) return;
43533        details.dataset.lazyInit = "true";
43534        details.addEventListener("toggle", () => {
43535          if (details.open && !details.querySelector(".player-container")) {
43536            createPlayer(details);
43537          } else if (!details.open) {
43538            const provider = details.querySelector("youtube-media-provider");
43539            if (provider && typeof provider.pause === "function") {
43540              provider.pause();
43541            }
43542          }
43543        });
43544      });
43545    }
43546
43547    if (document.readyState === "loading") {
43548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
43549    } else {
43550      initLazyPlayers();
43551    }
43552  })();
43553</script>
43553<script>
43554  (() => {
43555    if (window.__courseCompletionsInitialized) return;
43556    window.__courseCompletionsInitialized = true;
43557
43558    const STORAGE_KEY = "course-completions";
43559
43560    function slugify(text) {
43561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
43562    }
43563
43564    function getCompletions() {
43565      try {
43566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
43567      } catch {
43568        return [];
43569      }
43570    }
43571
43572    function saveCompletions(completions) {
43573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
43574    }
43575
43576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
43577    function canonicalFromStored(storedId) {
43578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43579      if (!m) return slugify(storedId);
43580      const [, slug, p, mv, title] = m;
43581      return `${slug}-${p}-${mv}-${slugify(title)}`;
43582    }
43583
43584    function canonicalPlusOne(storedId) {
43585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43586      if (!m) return null;
43587      const [, slug, p, mv, title] = m;
43588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
43589    }
43590
43591    function isCompleted(canonicalId, completions) {
43592      if (completions.includes(canonicalId)) return true;
43593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
43594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
43595    }
43596
43597    function migrateCompletions() {
43598      const completions = getCompletions();
43599      let changed = false;
43600      const migrated = completions.map((c) => {
43601        const canon = canonicalFromStored(c);
43602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
43603        const plusOne = canonicalPlusOne(c);
43604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
43605          if (plusOne !== c) changed = true;
43606          return plusOne;
43607        }
43608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
43609          changed = true;
43610          return canon;
43611        }
43612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
43613        if (canon !== c) {
43614          changed = true;
43615          return canon;
43616        }
43617        return c;
43618      });
43619      // Elimina duplicados tras migración
43620      const deduped = [...new Set(migrated)];
43621      if (changed || deduped.length !== completions.length) {
43622        saveCompletions(deduped);
43623        return deduped;
43624      }
43625      return completions;
43626    }
43627
43628    function updateButtonState(btn, completions) {
43629      const id = btn.dataset.completionId;
43630      const list = completions ?? getCompletions();
43631      btn.classList.toggle("completed", isCompleted(id, list));
43632    }
43633
43634    function checkModuleCompletion(completions) {
43635      const list = completions ?? getCompletions();
43636      document.querySelectorAll(".course-video").forEach((module) => {
43637        const btns = module.querySelectorAll(".complete-btn");
43638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
43639        const check = module.querySelector(".module-check");
43640        if (check) check.classList.toggle("visible", allCompleted);
43641      });
43642    }
43643
43644    function initCompletionButtons() {
43645      const completions = migrateCompletions();
43646
43647      document.querySelectorAll(".complete-btn").forEach((btn) => {
43648        if (btn.dataset.completionInit) return;
43649        btn.dataset.completionInit = "true";
43650        updateButtonState(btn, completions);
43651
43652        btn.addEventListener("click", (e) => {
43653          e.preventDefault();
43654          e.stopPropagation();
43655
43656          const id = btn.dataset.completionId;
43657          let list = getCompletions();
43658          const completed = isCompleted(id, list);
43659
43660          if (completed) {
43661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
43662            list = list.filter((c) =>
43662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
43663          } else {
43664            list.push(id);
43665          }
43666
43667          saveCompletions(list);
43668          updateButtonState(btn, list);
43669          checkModuleCompletion(list);
43670        });
43671      });
43672
43673      checkModuleCompletion(completions);
43674    }
43675
43676    if (document.readyState === "loading") {
43677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
43678    } else {
43679      initCompletionButtons();
43680    }
43681  })();
43682</script>
43682</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 16 📘 Rendimiento web<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="1146" data-end="1623" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. ¿Qué es el rendimiento web?</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-que-es-el-rendimiento-web" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
43682<script>
43683  (() => {
43684    if (window.__courseLazyPlayersInitialized) return;
43685    window.__courseLazyPlayersInitialized = true;
43686
43687    function createPlayer(details) {
43688      const videoId = details.dataset.videoId;
43689      const start = details.dataset.start;
43690      const end = details.dataset.end;
43691
43692      const container = document.createElement("div");
43693      container.className = "player-container";
43694
43695      const spinner = document.createElement("div");
43696      spinner.className = "spinner";
43697      container.appendChild(spinner);
43698
43699      const controller = document.createElement("media-controller");
43700      controller.setAttribute("defaultsubtitles", "");
43701
43702      const provider = document.createElement("youtube-media-provider");
43703      provider.setAttribute("slot", "media");
43704      provider.setAttribute("video-id", videoId);
43705      provider.setAttribute("start", start);
43706      provider.setAttribute("end", end);
43707
43708      const controlBar = document.createElement("media-control-bar");
43709
43710      const playBtn = document.createElement("media-play-button");
43711      const timeDisplay = document.createElement("media-time-display");
43712      timeDisplay.setAttribute("showduration", "");
43713      const timeRange = document.createElement("media-time-range");
43714      const playbackRate = document.createElement("media-playback-rate-button");
43715      const muteBtn = document.createElement("media-mute-button");
43716      const volumeRange = document.createElement("media-volume-range");
43717
43718      const captionsBtn = document.createElement("media-captions-button");
43719      captionsBtn.addEventListener("click", (e) => {
43720        e.stopPropagation();
43721        const provider = container.querySelector("youtube-media-provider");
43722        if (provider) provider.toggleCaptions();
43723      });
43724
43725      const qualityBtn = document.createElement("media-chrome-button");
43726      qualityBtn.textContent = "--";
43727      qualityBtn.addEventListener("click", () => {
43728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
43729      });
43730
43731      const fullscreenBtn = document.createElement("media-fullscreen-button");
43732
43733      controlBar.append(
43734        playBtn, timeDisplay, timeRange,
43735        playbackRate, muteBtn, volumeRange,
43736        captionsBtn, qualityBtn, fullscreenBtn
43737      );
43738
43739      const poster = document.createElement("media-poster-image");
43740      poster.setAttribute("slot", "poster");
43741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
43742
43743      controller.append(provider, poster, controlBar);
43744
43745      captionsBtn.setAttribute("aria-checked", "true");
43746      controller.setAttribute("mediasubtitlesshowing", "on");
43747
43748      container.appendChild(controller);
43749      details.appendChild(container);
43750
43751      provider.addEventListener("waiting", () => container.classList.add("loading"));
43752      provider.addEventListener("playing", () => container.classList.remove("loading"));
43753      provider.addEventListener("play", () => container.classList.remove("loading"));
43754
43755      provider._textTracks.addEventListener("change", () => {
43756        const enabled = provider._captionsEnabled;
43757        captionsBtn.setAttribute("aria-checked", enabled);
43758        if (enabled) {
43759          controller.setAttribute("mediasubtitlesshowing", "on");
43760        } else {
43761          controller.removeAttribute("mediasubtitlesshowing");
43762        }
43763      });
43764
43765      provider.addEventListener("qualitychange", (e) => {
43766        qualityBtn.textContent = e.detail.quality;
43767      });
43768    }
43769
43770    function initLazyPlayers() {
43771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
43772        if (details.dataset.lazyInit) return;
43773        details.dataset.lazyInit = "true";
43774        details.addEventListener("toggle", () => {
43775          if (details.open && !details.querySelector(".player-container")) {
43776            createPlayer(details);
43777          } else if (!details.open) {
43778            const provider = details.querySelector("youtube-media-provider");
43779            if (provider && typeof provider.pause === "function") {
43780              provider.pause();
43781            }
43782          }
43783        });
43784      });
43785    }
43786
43787    if (document.readyState === "loading") {
43788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
43789    } else {
43790      initLazyPlayers();
43791    }
43792  })();
43793</script>
43793<script>
43794  (() => {
43795    if (window.__courseCompletionsInitialized) return;
43796    window.__courseCompletionsInitialized = true;
43797
43798    const STORAGE_KEY = "course-completions";
43799
43800    function slugify(text) {
43801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
43802    }
43803
43804    function getCompletions() {
43805      try {
43806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
43807      } catch {
43808        return [];
43809      }
43810    }
43811
43812    function saveCompletions(completions) {
43813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
43814    }
43815
43816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
43817    function canonicalFromStored(storedId) {
43818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43819      if (!m) return slugify(storedId);
43820      const [, slug, p, mv, title] = m;
43821      return `${slug}-${p}-${mv}-${slugify(title)}`;
43822    }
43823
43824    function canonicalPlusOne(storedId) {
43825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
43826      if (!m) return null;
43827      const [, slug, p, mv, title] = m;
43828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
43829    }
43830
43831    function isCompleted(canonicalId, completions) {
43832      if (completions.includes(canonicalId)) return true;
43833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
43834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
43835    }
43836
43837    function migrateCompletions() {
43838      const completions = getCompletions();
43839      let changed = false;
43840      const migrated = completions.map((c) => {
43841        const canon = canonicalFromStored(c);
43842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
43843        const plusOne = canonicalPlusOne(c);
43844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
43845          if (plusOne !== c) changed = true;
43846          return plusOne;
43847        }
43848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
43849          changed = true;
43850          return canon;
43851        }
43852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
43853        if (canon !== c) {
43854          changed = true;
43855          return canon;
43856        }
43857        return c;
43858      });
43859      // Elimina duplicados tras migración
43860      const deduped = [...new Set(migrated)];
43861      if (changed || deduped.length !== completions.length) {
43862        saveCompletions(deduped);
43863        return deduped;
43864      }
43865      return completions;
43866    }
43867
43868    function updateButtonState(btn, completions) {
43869      const id = btn.dataset.completionId;
43870      const list = completions ?? getCompletions();
43871      btn.classList.toggle("completed", isCompleted(id, list));
43872    }
43873
43874    function checkModuleCompletion(completions) {
43875      const list = completions ?? getCompletions();
43876      document.querySelectorAll(".course-video").forEach((module) => {
43877        const btns = module.querySelectorAll(".complete-btn");
43878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
43879        const check = module.querySelector(".module-check");
43880        if (check) check.classList.toggle("visible", allCompleted);
43881      });
43882    }
43883
43884    function initCompletionButtons() {
43885      const completions = migrateCompletions();
43886
43887      document.querySelectorAll(".complete-btn").forEach((btn) => {
43888        if (btn.dataset.completionInit) return;
43889        btn.dataset.completionInit = "true";
43890        updateButtonState(btn, completions);
43891
43892        btn.addEventListener("click", (e) => {
43893          e.preventDefault();
43894          e.stopPropagation();
43895
43896          const id = btn.dataset.completionId;
43897          let list = getCompletions();
43898          const completed = isCompleted(id, list);
43899
43900          if (completed) {
43901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
43902            list = list.filter((c) =>
43902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
43903          } else {
43904            list.push(id);
43905          }
43906
43907          saveCompletions(list);
43908          updateButtonState(btn, list);
43909          checkModuleCompletion(list);
43910        });
43911      });
43912
43913      checkModuleCompletion(completions);
43914    }
43915
43916    if (document.readyState === "loading") {
43917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
43918    } else {
43919      initCompletionButtons();
43920    }
43921  })();
43922</script>
43922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="1623" data-end="2457" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Chrome Dev Tools</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-chrome-dev-tools" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
43922<script>
43923  (() => {
43924    if (window.__courseLazyPlayersInitialized) return;
43925    window.__courseLazyPlayersInitialized = true;
43926
43927    function createPlayer(details) {
43928      const videoId = details.dataset.videoId;
43929      const start = details.dataset.start;
43930      const end = details.dataset.end;
43931
43932      const container = document.createElement("div");
43933      container.className = "player-container";
43934
43935      const spinner = document.createElement("div");
43936      spinner.className = "spinner";
43937      container.appendChild(spinner);
43938
43939      const controller = document.createElement("media-controller");
43940      controller.setAttribute("defaultsubtitles", "");
43941
43942      const provider = document.createElement("youtube-media-provider");
43943      provider.setAttribute("slot", "media");
43944      provider.setAttribute("video-id", videoId);
43945      provider.setAttribute("start", start);
43946      provider.setAttribute("end", end);
43947
43948      const controlBar = document.createElement("media-control-bar");
43949
43950      const playBtn = document.createElement("media-play-button");
43951      const timeDisplay = document.createElement("media-time-display");
43952      timeDisplay.setAttribute("showduration", "");
43953      const timeRange = document.createElement("media-time-range");
43954      const playbackRate = document.createElement("media-playback-rate-button");
43955      const muteBtn = document.createElement("media-mute-button");
43956      const volumeRange = document.createElement("media-volume-range");
43957
43958      const captionsBtn = document.createElement("media-captions-button");
43959      captionsBtn.addEventListener("click", (e) => {
43960        e.stopPropagation();
43961        const provider = container.querySelector("youtube-media-provider");
43962        if (provider) provider.toggleCaptions();
43963      });
43964
43965      const qualityBtn = document.createElement("media-chrome-button");
43966      qualityBtn.textContent = "--";
43967      qualityBtn.addEventListener("click", () => {
43968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
43969      });
43970
43971      const fullscreenBtn = document.createElement("media-fullscreen-button");
43972
43973      controlBar.append(
43974        playBtn, timeDisplay, timeRange,
43975        playbackRate, muteBtn, volumeRange,
43976        captionsBtn, qualityBtn, fullscreenBtn
43977      );
43978
43979      const poster = document.createElement("media-poster-image");
43980      poster.setAttribute("slot", "poster");
43981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
43982
43983      controller.append(provider, poster, controlBar);
43984
43985      captionsBtn.setAttribute("aria-checked", "true");
43986      controller.setAttribute("mediasubtitlesshowing", "on");
43987
43988      container.appendChild(controller);
43989      details.appendChild(container);
43990
43991      provider.addEventListener("waiting", () => container.classList.add("loading"));
43992      provider.addEventListener("playing", () => container.classList.remove("loading"));
43993      provider.addEventListener("play", () => container.classList.remove("loading"));
43994
43995      provider._textTracks.addEventListener("change", () => {
43996        const enabled = provider._captionsEnabled;
43997        captionsBtn.setAttribute("aria-checked", enabled);
43998        if (enabled) {
43999          controller.setAttribute("mediasubtitlesshowing", "on");
44000        } else {
44001          controller.removeAttribute("mediasubtitlesshowing");
44002        }
44003      });
44004
44005      provider.addEventListener("qualitychange", (e) => {
44006        qualityBtn.textContent = e.detail.quality;
44007      });
44008    }
44009
44010    function initLazyPlayers() {
44011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
44012        if (details.dataset.lazyInit) return;
44013        details.dataset.lazyInit = "true";
44014        details.addEventListener("toggle", () => {
44015          if (details.open && !details.querySelector(".player-container")) {
44016            createPlayer(details);
44017          } else if (!details.open) {
44018            const provider = details.querySelector("youtube-media-provider");
44019            if (provider && typeof provider.pause === "function") {
44020              provider.pause();
44021            }
44022          }
44023        });
44024      });
44025    }
44026
44027    if (document.readyState === "loading") {
44028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
44029    } else {
44030      initLazyPlayers();
44031    }
44032  })();
44033</script>
44033<script>
44034  (() => {
44035    if (window.__courseCompletionsInitialized) return;
44036    window.__courseCompletionsInitialized = true;
44037
44038    const STORAGE_KEY = "course-completions";
44039
44040    function slugify(text) {
44041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
44042    }
44043
44044    function getCompletions() {
44045      try {
44046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
44047      } catch {
44048        return [];
44049      }
44050    }
44051
44052    function saveCompletions(completions) {
44053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
44054    }
44055
44056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
44057    function canonicalFromStored(storedId) {
44058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44059      if (!m) return slugify(storedId);
44060      const [, slug, p, mv, title] = m;
44061      return `${slug}-${p}-${mv}-${slugify(title)}`;
44062    }
44063
44064    function canonicalPlusOne(storedId) {
44065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44066      if (!m) return null;
44067      const [, slug, p, mv, title] = m;
44068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
44069    }
44070
44071    function isCompleted(canonicalId, completions) {
44072      if (completions.includes(canonicalId)) return true;
44073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
44074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
44075    }
44076
44077    function migrateCompletions() {
44078      const completions = getCompletions();
44079      let changed = false;
44080      const migrated = completions.map((c) => {
44081        const canon = canonicalFromStored(c);
44082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
44083        const plusOne = canonicalPlusOne(c);
44084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
44085          if (plusOne !== c) changed = true;
44086          return plusOne;
44087        }
44088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
44089          changed = true;
44090          return canon;
44091        }
44092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
44093        if (canon !== c) {
44094          changed = true;
44095          return canon;
44096        }
44097        return c;
44098      });
44099      // Elimina duplicados tras migración
44100      const deduped = [...new Set(migrated)];
44101      if (changed || deduped.length !== completions.length) {
44102        saveCompletions(deduped);
44103        return deduped;
44104      }
44105      return completions;
44106    }
44107
44108    function updateButtonState(btn, completions) {
44109      const id = btn.dataset.completionId;
44110      const list = completions ?? getCompletions();
44111      btn.classList.toggle("completed", isCompleted(id, list));
44112    }
44113
44114    function checkModuleCompletion(completions) {
44115      const list = completions ?? getCompletions();
44116      document.querySelectorAll(".course-video").forEach((module) => {
44117        const btns = module.querySelectorAll(".complete-btn");
44118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
44119        const check = module.querySelector(".module-check");
44120        if (check) check.classList.toggle("visible", allCompleted);
44121      });
44122    }
44123
44124    function initCompletionButtons() {
44125      const completions = migrateCompletions();
44126
44127      document.querySelectorAll(".complete-btn").forEach((btn) => {
44128        if (btn.dataset.completionInit) return;
44129        btn.dataset.completionInit = "true";
44130        updateButtonState(btn, completions);
44131
44132        btn.addEventListener("click", (e) => {
44133          e.preventDefault();
44134          e.stopPropagation();
44135
44136          const id = btn.dataset.completionId;
44137          let list = getCompletions();
44138          const completed = isCompleted(id, list);
44139
44140          if (completed) {
44141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
44142            list = list.filter((c) =>
44142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
44143          } else {
44144            list.push(id);
44145          }
44146
44147          saveCompletions(list);
44148          updateButtonState(btn, list);
44149          checkModuleCompletion(list);
44150        });
44151      });
44152
44153      checkModuleCompletion(completions);
44154    }
44155
44156    if (document.readyState === "loading") {
44157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
44158    } else {
44159      initCompletionButtons();
44160    }
44161  })();
44162</script>
44162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="2457" data-end="5351" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Reducir uso de memoria</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>48 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-reducir-uso-de-memoria" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
44162<script>
44163  (() => {
44164    if (window.__courseLazyPlayersInitialized) return;
44165    window.__courseLazyPlayersInitialized = true;
44166
44167    function createPlayer(details) {
44168      const videoId = details.dataset.videoId;
44169      const start = details.dataset.start;
44170      const end = details.dataset.end;
44171
44172      const container = document.createElement("div");
44173      container.className = "player-container";
44174
44175      const spinner = document.createElement("div");
44176      spinner.className = "spinner";
44177      container.appendChild(spinner);
44178
44179      const controller = document.createElement("media-controller");
44180      controller.setAttribute("defaultsubtitles", "");
44181
44182      const provider = document.createElement("youtube-media-provider");
44183      provider.setAttribute("slot", "media");
44184      provider.setAttribute("video-id", videoId);
44185      provider.setAttribute("start", start);
44186      provider.setAttribute("end", end);
44187
44188      const controlBar = document.createElement("media-control-bar");
44189
44190      const playBtn = document.createElement("media-play-button");
44191      const timeDisplay = document.createElement("media-time-display");
44192      timeDisplay.setAttribute("showduration", "");
44193      const timeRange = document.createElement("media-time-range");
44194      const playbackRate = document.createElement("media-playback-rate-button");
44195      const muteBtn = document.createElement("media-mute-button");
44196      const volumeRange = document.createElement("media-volume-range");
44197
44198      const captionsBtn = document.createElement("media-captions-button");
44199      captionsBtn.addEventListener("click", (e) => {
44200        e.stopPropagation();
44201        const provider = container.querySelector("youtube-media-provider");
44202        if (provider) provider.toggleCaptions();
44203      });
44204
44205      const qualityBtn = document.createElement("media-chrome-button");
44206      qualityBtn.textContent = "--";
44207      qualityBtn.addEventListener("click", () => {
44208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
44209      });
44210
44211      const fullscreenBtn = document.createElement("media-fullscreen-button");
44212
44213      controlBar.append(
44214        playBtn, timeDisplay, timeRange,
44215        playbackRate, muteBtn, volumeRange,
44216        captionsBtn, qualityBtn, fullscreenBtn
44217      );
44218
44219      const poster = document.createElement("media-poster-image");
44220      poster.setAttribute("slot", "poster");
44221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
44222
44223      controller.append(provider, poster, controlBar);
44224
44225      captionsBtn.setAttribute("aria-checked", "true");
44226      controller.setAttribute("mediasubtitlesshowing", "on");
44227
44228      container.appendChild(controller);
44229      details.appendChild(container);
44230
44231      provider.addEventListener("waiting", () => container.classList.add("loading"));
44232      provider.addEventListener("playing", () => container.classList.remove("loading"));
44233      provider.addEventListener("play", () => container.classList.remove("loading"));
44234
44235      provider._textTracks.addEventListener("change", () => {
44236        const enabled = provider._captionsEnabled;
44237        captionsBtn.setAttribute("aria-checked", enabled);
44238        if (enabled) {
44239          controller.setAttribute("mediasubtitlesshowing", "on");
44240        } else {
44241          controller.removeAttribute("mediasubtitlesshowing");
44242        }
44243      });
44244
44245      provider.addEventListener("qualitychange", (e) => {
44246        qualityBtn.textContent = e.detail.quality;
44247      });
44248    }
44249
44250    function initLazyPlayers() {
44251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
44252        if (details.dataset.lazyInit) return;
44253        details.dataset.lazyInit = "true";
44254        details.addEventListener("toggle", () => {
44255          if (details.open && !details.querySelector(".player-container")) {
44256            createPlayer(details);
44257          } else if (!details.open) {
44258            const provider = details.querySelector("youtube-media-provider");
44259            if (provider && typeof provider.pause === "function") {
44260              provider.pause();
44261            }
44262          }
44263        });
44264      });
44265    }
44266
44267    if (document.readyState === "loading") {
44268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
44269    } else {
44270      initLazyPlayers();
44271    }
44272  })();
44273</script>
44273<script>
44274  (() => {
44275    if (window.__courseCompletionsInitialized) return;
44276    window.__courseCompletionsInitialized = true;
44277
44278    const STORAGE_KEY = "course-completions";
44279
44280    function slugify(text) {
44281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
44282    }
44283
44284    function getCompletions() {
44285      try {
44286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
44287      } catch {
44288        return [];
44289      }
44290    }
44291
44292    function saveCompletions(completions) {
44293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
44294    }
44295
44296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
44297    function canonicalFromStored(storedId) {
44298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44299      if (!m) return slugify(storedId);
44300      const [, slug, p, mv, title] = m;
44301      return `${slug}-${p}-${mv}-${slugify(title)}`;
44302    }
44303
44304    function canonicalPlusOne(storedId) {
44305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44306      if (!m) return null;
44307      const [, slug, p, mv, title] = m;
44308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
44309    }
44310
44311    function isCompleted(canonicalId, completions) {
44312      if (completions.includes(canonicalId)) return true;
44313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
44314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
44315    }
44316
44317    function migrateCompletions() {
44318      const completions = getCompletions();
44319      let changed = false;
44320      const migrated = completions.map((c) => {
44321        const canon = canonicalFromStored(c);
44322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
44323        const plusOne = canonicalPlusOne(c);
44324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
44325          if (plusOne !== c) changed = true;
44326          return plusOne;
44327        }
44328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
44329          changed = true;
44330          return canon;
44331        }
44332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
44333        if (canon !== c) {
44334          changed = true;
44335          return canon;
44336        }
44337        return c;
44338      });
44339      // Elimina duplicados tras migración
44340      const deduped = [...new Set(migrated)];
44341      if (changed || deduped.length !== completions.length) {
44342        saveCompletions(deduped);
44343        return deduped;
44344      }
44345      return completions;
44346    }
44347
44348    function updateButtonState(btn, completions) {
44349      const id = btn.dataset.completionId;
44350      const list = completions ?? getCompletions();
44351      btn.classList.toggle("completed", isCompleted(id, list));
44352    }
44353
44354    function checkModuleCompletion(completions) {
44355      const list = completions ?? getCompletions();
44356      document.querySelectorAll(".course-video").forEach((module) => {
44357        const btns = module.querySelectorAll(".complete-btn");
44358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
44359        const check = module.querySelector(".module-check");
44360        if (check) check.classList.toggle("visible", allCompleted);
44361      });
44362    }
44363
44364    function initCompletionButtons() {
44365      const completions = migrateCompletions();
44366
44367      document.querySelectorAll(".complete-btn").forEach((btn) => {
44368        if (btn.dataset.completionInit) return;
44369        btn.dataset.completionInit = "true";
44370        updateButtonState(btn, completions);
44371
44372        btn.addEventListener("click", (e) => {
44373          e.preventDefault();
44374          e.stopPropagation();
44375
44376          const id = btn.dataset.completionId;
44377          let list = getCompletions();
44378          const completed = isCompleted(id, list);
44379
44380          if (completed) {
44381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
44382            list = list.filter((c) =>
44382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
44383          } else {
44384            list.push(id);
44385          }
44386
44387          saveCompletions(list);
44388          updateButtonState(btn, list);
44389          checkModuleCompletion(list);
44390        });
44391      });
44392
44393      checkModuleCompletion(completions);
44394    }
44395
44396    if (document.readyState === "loading") {
44397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
44398    } else {
44399      initCompletionButtons();
44400    }
44401  })();
44402</script>
44402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="5700" data-end="8626" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Reducir uso de tiempo</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>49 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-reducir-uso-de-tiempo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
44402<script>
44403  (() => {
44404    if (window.__courseLazyPlayersInitialized) return;
44405    window.__courseLazyPlayersInitialized = true;
44406
44407    function createPlayer(details) {
44408      const videoId = details.dataset.videoId;
44409      const start = details.dataset.start;
44410      const end = details.dataset.end;
44411
44412      const container = document.createElement("div");
44413      container.className = "player-container";
44414
44415      const spinner = document.createElement("div");
44416      spinner.className = "spinner";
44417      container.appendChild(spinner);
44418
44419      const controller = document.createElement("media-controller");
44420      controller.setAttribute("defaultsubtitles", "");
44421
44422      const provider = document.createElement("youtube-media-provider");
44423      provider.setAttribute("slot", "media");
44424      provider.setAttribute("video-id", videoId);
44425      provider.setAttribute("start", start);
44426      provider.setAttribute("end", end);
44427
44428      const controlBar = document.createElement("media-control-bar");
44429
44430      const playBtn = document.createElement("media-play-button");
44431      const timeDisplay = document.createElement("media-time-display");
44432      timeDisplay.setAttribute("showduration", "");
44433      const timeRange = document.createElement("media-time-range");
44434      const playbackRate = document.createElement("media-playback-rate-button");
44435      const muteBtn = document.createElement("media-mute-button");
44436      const volumeRange = document.createElement("media-volume-range");
44437
44438      const captionsBtn = document.createElement("media-captions-button");
44439      captionsBtn.addEventListener("click", (e) => {
44440        e.stopPropagation();
44441        const provider = container.querySelector("youtube-media-provider");
44442        if (provider) provider.toggleCaptions();
44443      });
44444
44445      const qualityBtn = document.createElement("media-chrome-button");
44446      qualityBtn.textContent = "--";
44447      qualityBtn.addEventListener("click", () => {
44448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
44449      });
44450
44451      const fullscreenBtn = document.createElement("media-fullscreen-button");
44452
44453      controlBar.append(
44454        playBtn, timeDisplay, timeRange,
44455        playbackRate, muteBtn, volumeRange,
44456        captionsBtn, qualityBtn, fullscreenBtn
44457      );
44458
44459      const poster = document.createElement("media-poster-image");
44460      poster.setAttribute("slot", "poster");
44461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
44462
44463      controller.append(provider, poster, controlBar);
44464
44465      captionsBtn.setAttribute("aria-checked", "true");
44466      controller.setAttribute("mediasubtitlesshowing", "on");
44467
44468      container.appendChild(controller);
44469      details.appendChild(container);
44470
44471      provider.addEventListener("waiting", () => container.classList.add("loading"));
44472      provider.addEventListener("playing", () => container.classList.remove("loading"));
44473      provider.addEventListener("play", () => container.classList.remove("loading"));
44474
44475      provider._textTracks.addEventListener("change", () => {
44476        const enabled = provider._captionsEnabled;
44477        captionsBtn.setAttribute("aria-checked", enabled);
44478        if (enabled) {
44479          controller.setAttribute("mediasubtitlesshowing", "on");
44480        } else {
44481          controller.removeAttribute("mediasubtitlesshowing");
44482        }
44483      });
44484
44485      provider.addEventListener("qualitychange", (e) => {
44486        qualityBtn.textContent = e.detail.quality;
44487      });
44488    }
44489
44490    function initLazyPlayers() {
44491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
44492        if (details.dataset.lazyInit) return;
44493        details.dataset.lazyInit = "true";
44494        details.addEventListener("toggle", () => {
44495          if (details.open && !details.querySelector(".player-container")) {
44496            createPlayer(details);
44497          } else if (!details.open) {
44498            const provider = details.querySelector("youtube-media-provider");
44499            if (provider && typeof provider.pause === "function") {
44500              provider.pause();
44501            }
44502          }
44503        });
44504      });
44505    }
44506
44507    if (document.readyState === "loading") {
44508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
44509    } else {
44510      initLazyPlayers();
44511    }
44512  })();
44513</script>
44513<script>
44514  (() => {
44515    if (window.__courseCompletionsInitialized) return;
44516    window.__courseCompletionsInitialized = true;
44517
44518    const STORAGE_KEY = "course-completions";
44519
44520    function slugify(text) {
44521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
44522    }
44523
44524    function getCompletions() {
44525      try {
44526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
44527      } catch {
44528        return [];
44529      }
44530    }
44531
44532    function saveCompletions(completions) {
44533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
44534    }
44535
44536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
44537    function canonicalFromStored(storedId) {
44538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44539      if (!m) return slugify(storedId);
44540      const [, slug, p, mv, title] = m;
44541      return `${slug}-${p}-${mv}-${slugify(title)}`;
44542    }
44543
44544    function canonicalPlusOne(storedId) {
44545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44546      if (!m) return null;
44547      const [, slug, p, mv, title] = m;
44548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
44549    }
44550
44551    function isCompleted(canonicalId, completions) {
44552      if (completions.includes(canonicalId)) return true;
44553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
44554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
44555    }
44556
44557    function migrateCompletions() {
44558      const completions = getCompletions();
44559      let changed = false;
44560      const migrated = completions.map((c) => {
44561        const canon = canonicalFromStored(c);
44562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
44563        const plusOne = canonicalPlusOne(c);
44564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
44565          if (plusOne !== c) changed = true;
44566          return plusOne;
44567        }
44568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
44569          changed = true;
44570          return canon;
44571        }
44572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
44573        if (canon !== c) {
44574          changed = true;
44575          return canon;
44576        }
44577        return c;
44578      });
44579      // Elimina duplicados tras migración
44580      const deduped = [...new Set(migrated)];
44581      if (changed || deduped.length !== completions.length) {
44582        saveCompletions(deduped);
44583        return deduped;
44584      }
44585      return completions;
44586    }
44587
44588    function updateButtonState(btn, completions) {
44589      const id = btn.dataset.completionId;
44590      const list = completions ?? getCompletions();
44591      btn.classList.toggle("completed", isCompleted(id, list));
44592    }
44593
44594    function checkModuleCompletion(completions) {
44595      const list = completions ?? getCompletions();
44596      document.querySelectorAll(".course-video").forEach((module) => {
44597        const btns = module.querySelectorAll(".complete-btn");
44598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
44599        const check = module.querySelector(".module-check");
44600        if (check) check.classList.toggle("visible", allCompleted);
44601      });
44602    }
44603
44604    function initCompletionButtons() {
44605      const completions = migrateCompletions();
44606
44607      document.querySelectorAll(".complete-btn").forEach((btn) => {
44608        if (btn.dataset.completionInit) return;
44609        btn.dataset.completionInit = "true";
44610        updateButtonState(btn, completions);
44611
44612        btn.addEventListener("click", (e) => {
44613          e.preventDefault();
44614          e.stopPropagation();
44615
44616          const id = btn.dataset.completionId;
44617          let list = getCompletions();
44618          const completed = isCompleted(id, list);
44619
44620          if (completed) {
44621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
44622            list = list.filter((c) =>
44622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
44623          } else {
44624            list.push(id);
44625          }
44626
44627          saveCompletions(list);
44628          updateButtonState(btn, list);
44629          checkModuleCompletion(list);
44630        });
44631      });
44632
44633      checkModuleCompletion(completions);
44634    }
44635
44636    if (document.readyState === "loading") {
44637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
44638    } else {
44639      initCompletionButtons();
44640    }
44641  })();
44642</script>
44642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="8965" data-end="12974" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Reducir uso de transferencia</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>1 hr 7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-reducir-uso-de-transferencia" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
44642<script>
44643  (() => {
44644    if (window.__courseLazyPlayersInitialized) return;
44645    window.__courseLazyPlayersInitialized = true;
44646
44647    function createPlayer(details) {
44648      const videoId = details.dataset.videoId;
44649      const start = details.dataset.start;
44650      const end = details.dataset.end;
44651
44652      const container = document.createElement("div");
44653      container.className = "player-container";
44654
44655      const spinner = document.createElement("div");
44656      spinner.className = "spinner";
44657      container.appendChild(spinner);
44658
44659      const controller = document.createElement("media-controller");
44660      controller.setAttribute("defaultsubtitles", "");
44661
44662      const provider = document.createElement("youtube-media-provider");
44663      provider.setAttribute("slot", "media");
44664      provider.setAttribute("video-id", videoId);
44665      provider.setAttribute("start", start);
44666      provider.setAttribute("end", end);
44667
44668      const controlBar = document.createElement("media-control-bar");
44669
44670      const playBtn = document.createElement("media-play-button");
44671      const timeDisplay = document.createElement("media-time-display");
44672      timeDisplay.setAttribute("showduration", "");
44673      const timeRange = document.createElement("media-time-range");
44674      const playbackRate = document.createElement("media-playback-rate-button");
44675      const muteBtn = document.createElement("media-mute-button");
44676      const volumeRange = document.createElement("media-volume-range");
44677
44678      const captionsBtn = document.createElement("media-captions-button");
44679      captionsBtn.addEventListener("click", (e) => {
44680        e.stopPropagation();
44681        const provider = container.querySelector("youtube-media-provider");
44682        if (provider) provider.toggleCaptions();
44683      });
44684
44685      const qualityBtn = document.createElement("media-chrome-button");
44686      qualityBtn.textContent = "--";
44687      qualityBtn.addEventListener("click", () => {
44688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
44689      });
44690
44691      const fullscreenBtn = document.createElement("media-fullscreen-button");
44692
44693      controlBar.append(
44694        playBtn, timeDisplay, timeRange,
44695        playbackRate, muteBtn, volumeRange,
44696        captionsBtn, qualityBtn, fullscreenBtn
44697      );
44698
44699      const poster = document.createElement("media-poster-image");
44700      poster.setAttribute("slot", "poster");
44701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
44702
44703      controller.append(provider, poster, controlBar);
44704
44705      captionsBtn.setAttribute("aria-checked", "true");
44706      controller.setAttribute("mediasubtitlesshowing", "on");
44707
44708      container.appendChild(controller);
44709      details.appendChild(container);
44710
44711      provider.addEventListener("waiting", () => container.classList.add("loading"));
44712      provider.addEventListener("playing", () => container.classList.remove("loading"));
44713      provider.addEventListener("play", () => container.classList.remove("loading"));
44714
44715      provider._textTracks.addEventListener("change", () => {
44716        const enabled = provider._captionsEnabled;
44717        captionsBtn.setAttribute("aria-checked", enabled);
44718        if (enabled) {
44719          controller.setAttribute("mediasubtitlesshowing", "on");
44720        } else {
44721          controller.removeAttribute("mediasubtitlesshowing");
44722        }
44723      });
44724
44725      provider.addEventListener("qualitychange", (e) => {
44726        qualityBtn.textContent = e.detail.quality;
44727      });
44728    }
44729
44730    function initLazyPlayers() {
44731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
44732        if (details.dataset.lazyInit) return;
44733        details.dataset.lazyInit = "true";
44734        details.addEventListener("toggle", () => {
44735          if (details.open && !details.querySelector(".player-container")) {
44736            createPlayer(details);
44737          } else if (!details.open) {
44738            const provider = details.querySelector("youtube-media-provider");
44739            if (provider && typeof provider.pause === "function") {
44740              provider.pause();
44741            }
44742          }
44743        });
44744      });
44745    }
44746
44747    if (document.readyState === "loading") {
44748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
44749    } else {
44750      initLazyPlayers();
44751    }
44752  })();
44753</script>
44753<script>
44754  (() => {
44755    if (window.__courseCompletionsInitialized) return;
44756    window.__courseCompletionsInitialized = true;
44757
44758    const STORAGE_KEY = "course-completions";
44759
44760    function slugify(text) {
44761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
44762    }
44763
44764    function getCompletions() {
44765      try {
44766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
44767      } catch {
44768        return [];
44769      }
44770    }
44771
44772    function saveCompletions(completions) {
44773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
44774    }
44775
44776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
44777    function canonicalFromStored(storedId) {
44778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44779      if (!m) return slugify(storedId);
44780      const [, slug, p, mv, title] = m;
44781      return `${slug}-${p}-${mv}-${slugify(title)}`;
44782    }
44783
44784    function canonicalPlusOne(storedId) {
44785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
44786      if (!m) return null;
44787      const [, slug, p, mv, title] = m;
44788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
44789    }
44790
44791    function isCompleted(canonicalId, completions) {
44792      if (completions.includes(canonicalId)) return true;
44793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
44794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
44795    }
44796
44797    function migrateCompletions() {
44798      const completions = getCompletions();
44799      let changed = false;
44800      const migrated = completions.map((c) => {
44801        const canon = canonicalFromStored(c);
44802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
44803        const plusOne = canonicalPlusOne(c);
44804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
44805          if (plusOne !== c) changed = true;
44806          return plusOne;
44807        }
44808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
44809          changed = true;
44810          return canon;
44811        }
44812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
44813        if (canon !== c) {
44814          changed = true;
44815          return canon;
44816        }
44817        return c;
44818      });
44819      // Elimina duplicados tras migración
44820      const deduped = [...new Set(migrated)];
44821      if (changed || deduped.length !== completions.length) {
44822        saveCompletions(deduped);
44823        return deduped;
44824      }
44825      return completions;
44826    }
44827
44828    function updateButtonState(btn, completions) {
44829      const id = btn.dataset.completionId;
44830      const list = completions ?? getCompletions();
44831      btn.classList.toggle("completed", isCompleted(id, list));
44832    }
44833
44834    function checkModuleCompletion(completions) {
44835      const list = completions ?? getCompletions();
44836      document.querySelectorAll(".course-video").forEach((module) => {
44837        const btns = module.querySelectorAll(".complete-btn");
44838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
44839        const check = module.querySelector(".module-check");
44840        if (check) check.classList.toggle("visible", allCompleted);
44841      });
44842    }
44843
44844    function initCompletionButtons() {
44845      const completions = migrateCompletions();
44846
44847      document.querySelectorAll(".complete-btn").forEach((btn) => {
44848        if (btn.dataset.completionInit) return;
44849        btn.dataset.completionInit = "true";
44850        updateButtonState(btn, completions);
44851
44852        btn.addEventListener("click", (e) => {
44853          e.preventDefault();
44854          e.stopPropagation();
44855
44856          const id = btn.dataset.completionId;
44857          let list = getCompletions();
44858          const completed = isCompleted(id, list);
44859
44860          if (completed) {
44861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
44862            list = list.filter((c) =>
44862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
44863          } else {
44864            list.push(id);
44865          }
44866
44867          saveCompletions(list);
44868          updateButtonState(btn, list);
44869          checkModuleCompletion(list);
44870        });
44871      });
44872
44873      checkModuleCompletion(completions);
44874    }
44875
44876    if (document.readyState === "loading") {
44877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
44878    } else {
44879      initCompletionButtons();
44880    }
44881  })();
44882</script>
44882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="13305" data-end="15145" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Reducir uso de dependencias</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>31 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-reducir-uso-de-dependencias" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
44882<script>
44883  (() => {
44884    if (window.__courseLazyPlayersInitialized) return;
44885    window.__courseLazyPlayersInitialized = true;
44886
44887    function createPlayer(details) {
44888      const videoId = details.dataset.videoId;
44889      const start = details.dataset.start;
44890      const end = details.dataset.end;
44891
44892      const container = document.createElement("div");
44893      container.className = "player-container";
44894
44895      const spinner = document.createElement("div");
44896      spinner.className = "spinner";
44897      container.appendChild(spinner);
44898
44899      const controller = document.createElement("media-controller");
44900      controller.setAttribute("defaultsubtitles", "");
44901
44902      const provider = document.createElement("youtube-media-provider");
44903      provider.setAttribute("slot", "media");
44904      provider.setAttribute("video-id", videoId);
44905      provider.setAttribute("start", start);
44906      provider.setAttribute("end", end);
44907
44908      const controlBar = document.createElement("media-control-bar");
44909
44910      const playBtn = document.createElement("media-play-button");
44911      const timeDisplay = document.createElement("media-time-display");
44912      timeDisplay.setAttribute("showduration", "");
44913      const timeRange = document.createElement("media-time-range");
44914      const playbackRate = document.createElement("media-playback-rate-button");
44915      const muteBtn = document.createElement("media-mute-button");
44916      const volumeRange = document.createElement("media-volume-range");
44917
44918      const captionsBtn = document.createElement("media-captions-button");
44919      captionsBtn.addEventListener("click", (e) => {
44920        e.stopPropagation();
44921        const provider = container.querySelector("youtube-media-provider");
44922        if (provider) provider.toggleCaptions();
44923      });
44924
44925      const qualityBtn = document.createElement("media-chrome-button");
44926      qualityBtn.textContent = "--";
44927      qualityBtn.addEventListener("click", () => {
44928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
44929      });
44930
44931      const fullscreenBtn = document.createElement("media-fullscreen-button");
44932
44933      controlBar.append(
44934        playBtn, timeDisplay, timeRange,
44935        playbackRate, muteBtn, volumeRange,
44936        captionsBtn, qualityBtn, fullscreenBtn
44937      );
44938
44939      const poster = document.createElement("media-poster-image");
44940      poster.setAttribute("slot", "poster");
44941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
44942
44943      controller.append(provider, poster, controlBar);
44944
44945      captionsBtn.setAttribute("aria-checked", "true");
44946      controller.setAttribute("mediasubtitlesshowing", "on");
44947
44948      container.appendChild(controller);
44949      details.appendChild(container);
44950
44951      provider.addEventListener("waiting", () => container.classList.add("loading"));
44952      provider.addEventListener("playing", () => container.classList.remove("loading"));
44953      provider.addEventListener("play", () => container.classList.remove("loading"));
44954
44955      provider._textTracks.addEventListener("change", () => {
44956        const enabled = provider._captionsEnabled;
44957        captionsBtn.setAttribute("aria-checked", enabled);
44958        if (enabled) {
44959          controller.setAttribute("mediasubtitlesshowing", "on");
44960        } else {
44961          controller.removeAttribute("mediasubtitlesshowing");
44962        }
44963      });
44964
44965      provider.addEventListener("qualitychange", (e) => {
44966        qualityBtn.textContent = e.detail.quality;
44967      });
44968    }
44969
44970    function initLazyPlayers() {
44971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
44972        if (details.dataset.lazyInit) return;
44973        details.dataset.lazyInit = "true";
44974        details.addEventListener("toggle", () => {
44975          if (details.open && !details.querySelector(".player-container")) {
44976            createPlayer(details);
44977          } else if (!details.open) {
44978            const provider = details.querySelector("youtube-media-provider");
44979            if (provider && typeof provider.pause === "function") {
44980              provider.pause();
44981            }
44982          }
44983        });
44984      });
44985    }
44986
44987    if (document.readyState === "loading") {
44988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
44989    } else {
44990      initLazyPlayers();
44991    }
44992  })();
44993</script>
44993<script>
44994  (() => {
44995    if (window.__courseCompletionsInitialized) return;
44996    window.__courseCompletionsInitialized = true;
44997
44998    const STORAGE_KEY = "course-completions";
44999
45000    function slugify(text) {
45001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45002    }
45003
45004    function getCompletions() {
45005      try {
45006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
45007      } catch {
45008        return [];
45009      }
45010    }
45011
45012    function saveCompletions(completions) {
45013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
45014    }
45015
45016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
45017    function canonicalFromStored(storedId) {
45018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45019      if (!m) return slugify(storedId);
45020      const [, slug, p, mv, title] = m;
45021      return `${slug}-${p}-${mv}-${slugify(title)}`;
45022    }
45023
45024    function canonicalPlusOne(storedId) {
45025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45026      if (!m) return null;
45027      const [, slug, p, mv, title] = m;
45028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
45029    }
45030
45031    function isCompleted(canonicalId, completions) {
45032      if (completions.includes(canonicalId)) return true;
45033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
45034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
45035    }
45036
45037    function migrateCompletions() {
45038      const completions = getCompletions();
45039      let changed = false;
45040      const migrated = completions.map((c) => {
45041        const canon = canonicalFromStored(c);
45042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
45043        const plusOne = canonicalPlusOne(c);
45044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
45045          if (plusOne !== c) changed = true;
45046          return plusOne;
45047        }
45048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
45049          changed = true;
45050          return canon;
45051        }
45052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
45053        if (canon !== c) {
45054          changed = true;
45055          return canon;
45056        }
45057        return c;
45058      });
45059      // Elimina duplicados tras migración
45060      const deduped = [...new Set(migrated)];
45061      if (changed || deduped.length !== completions.length) {
45062        saveCompletions(deduped);
45063        return deduped;
45064      }
45065      return completions;
45066    }
45067
45068    function updateButtonState(btn, completions) {
45069      const id = btn.dataset.completionId;
45070      const list = completions ?? getCompletions();
45071      btn.classList.toggle("completed", isCompleted(id, list));
45072    }
45073
45074    function checkModuleCompletion(completions) {
45075      const list = completions ?? getCompletions();
45076      document.querySelectorAll(".course-video").forEach((module) => {
45077        const btns = module.querySelectorAll(".complete-btn");
45078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
45079        const check = module.querySelector(".module-check");
45080        if (check) check.classList.toggle("visible", allCompleted);
45081      });
45082    }
45083
45084    function initCompletionButtons() {
45085      const completions = migrateCompletions();
45086
45087      document.querySelectorAll(".complete-btn").forEach((btn) => {
45088        if (btn.dataset.completionInit) return;
45089        btn.dataset.completionInit = "true";
45090        updateButtonState(btn, completions);
45091
45092        btn.addEventListener("click", (e) => {
45093          e.preventDefault();
45094          e.stopPropagation();
45095
45096          const id = btn.dataset.completionId;
45097          let list = getCompletions();
45098          const completed = isCompleted(id, list);
45099
45100          if (completed) {
45101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
45102            list = list.filter((c) =>
45102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
45103          } else {
45104            list.push(id);
45105          }
45106
45107          saveCompletions(list);
45108          updateButtonState(btn, list);
45109          checkModuleCompletion(list);
45110        });
45111      });
45112
45113      checkModuleCompletion(completions);
45114    }
45115
45116    if (document.readyState === "loading") {
45117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
45118    } else {
45119      initCompletionButtons();
45120    }
45121  })();
45122</script>
45122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="15145" data-end="16595" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Reducir tamaño de recursos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>24 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-reducir-tamano-de-recursos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
45122<script>
45123  (() => {
45124    if (window.__courseLazyPlayersInitialized) return;
45125    window.__courseLazyPlayersInitialized = true;
45126
45127    function createPlayer(details) {
45128      const videoId = details.dataset.videoId;
45129      const start = details.dataset.start;
45130      const end = details.dataset.end;
45131
45132      const container = document.createElement("div");
45133      container.className = "player-container";
45134
45135      const spinner = document.createElement("div");
45136      spinner.className = "spinner";
45137      container.appendChild(spinner);
45138
45139      const controller = document.createElement("media-controller");
45140      controller.setAttribute("defaultsubtitles", "");
45141
45142      const provider = document.createElement("youtube-media-provider");
45143      provider.setAttribute("slot", "media");
45144      provider.setAttribute("video-id", videoId);
45145      provider.setAttribute("start", start);
45146      provider.setAttribute("end", end);
45147
45148      const controlBar = document.createElement("media-control-bar");
45149
45150      const playBtn = document.createElement("media-play-button");
45151      const timeDisplay = document.createElement("media-time-display");
45152      timeDisplay.setAttribute("showduration", "");
45153      const timeRange = document.createElement("media-time-range");
45154      const playbackRate = document.createElement("media-playback-rate-button");
45155      const muteBtn = document.createElement("media-mute-button");
45156      const volumeRange = document.createElement("media-volume-range");
45157
45158      const captionsBtn = document.createElement("media-captions-button");
45159      captionsBtn.addEventListener("click", (e) => {
45160        e.stopPropagation();
45161        const provider = container.querySelector("youtube-media-provider");
45162        if (provider) provider.toggleCaptions();
45163      });
45164
45165      const qualityBtn = document.createElement("media-chrome-button");
45166      qualityBtn.textContent = "--";
45167      qualityBtn.addEventListener("click", () => {
45168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
45169      });
45170
45171      const fullscreenBtn = document.createElement("media-fullscreen-button");
45172
45173      controlBar.append(
45174        playBtn, timeDisplay, timeRange,
45175        playbackRate, muteBtn, volumeRange,
45176        captionsBtn, qualityBtn, fullscreenBtn
45177      );
45178
45179      const poster = document.createElement("media-poster-image");
45180      poster.setAttribute("slot", "poster");
45181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
45182
45183      controller.append(provider, poster, controlBar);
45184
45185      captionsBtn.setAttribute("aria-checked", "true");
45186      controller.setAttribute("mediasubtitlesshowing", "on");
45187
45188      container.appendChild(controller);
45189      details.appendChild(container);
45190
45191      provider.addEventListener("waiting", () => container.classList.add("loading"));
45192      provider.addEventListener("playing", () => container.classList.remove("loading"));
45193      provider.addEventListener("play", () => container.classList.remove("loading"));
45194
45195      provider._textTracks.addEventListener("change", () => {
45196        const enabled = provider._captionsEnabled;
45197        captionsBtn.setAttribute("aria-checked", enabled);
45198        if (enabled) {
45199          controller.setAttribute("mediasubtitlesshowing", "on");
45200        } else {
45201          controller.removeAttribute("mediasubtitlesshowing");
45202        }
45203      });
45204
45205      provider.addEventListener("qualitychange", (e) => {
45206        qualityBtn.textContent = e.detail.quality;
45207      });
45208    }
45209
45210    function initLazyPlayers() {
45211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
45212        if (details.dataset.lazyInit) return;
45213        details.dataset.lazyInit = "true";
45214        details.addEventListener("toggle", () => {
45215          if (details.open && !details.querySelector(".player-container")) {
45216            createPlayer(details);
45217          } else if (!details.open) {
45218            const provider = details.querySelector("youtube-media-provider");
45219            if (provider && typeof provider.pause === "function") {
45220              provider.pause();
45221            }
45222          }
45223        });
45224      });
45225    }
45226
45227    if (document.readyState === "loading") {
45228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
45229    } else {
45230      initLazyPlayers();
45231    }
45232  })();
45233</script>
45233<script>
45234  (() => {
45235    if (window.__courseCompletionsInitialized) return;
45236    window.__courseCompletionsInitialized = true;
45237
45238    const STORAGE_KEY = "course-completions";
45239
45240    function slugify(text) {
45241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45242    }
45243
45244    function getCompletions() {
45245      try {
45246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
45247      } catch {
45248        return [];
45249      }
45250    }
45251
45252    function saveCompletions(completions) {
45253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
45254    }
45255
45256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
45257    function canonicalFromStored(storedId) {
45258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45259      if (!m) return slugify(storedId);
45260      const [, slug, p, mv, title] = m;
45261      return `${slug}-${p}-${mv}-${slugify(title)}`;
45262    }
45263
45264    function canonicalPlusOne(storedId) {
45265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45266      if (!m) return null;
45267      const [, slug, p, mv, title] = m;
45268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
45269    }
45270
45271    function isCompleted(canonicalId, completions) {
45272      if (completions.includes(canonicalId)) return true;
45273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
45274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
45275    }
45276
45277    function migrateCompletions() {
45278      const completions = getCompletions();
45279      let changed = false;
45280      const migrated = completions.map((c) => {
45281        const canon = canonicalFromStored(c);
45282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
45283        const plusOne = canonicalPlusOne(c);
45284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
45285          if (plusOne !== c) changed = true;
45286          return plusOne;
45287        }
45288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
45289          changed = true;
45290          return canon;
45291        }
45292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
45293        if (canon !== c) {
45294          changed = true;
45295          return canon;
45296        }
45297        return c;
45298      });
45299      // Elimina duplicados tras migración
45300      const deduped = [...new Set(migrated)];
45301      if (changed || deduped.length !== completions.length) {
45302        saveCompletions(deduped);
45303        return deduped;
45304      }
45305      return completions;
45306    }
45307
45308    function updateButtonState(btn, completions) {
45309      const id = btn.dataset.completionId;
45310      const list = completions ?? getCompletions();
45311      btn.classList.toggle("completed", isCompleted(id, list));
45312    }
45313
45314    function checkModuleCompletion(completions) {
45315      const list = completions ?? getCompletions();
45316      document.querySelectorAll(".course-video").forEach((module) => {
45317        const btns = module.querySelectorAll(".complete-btn");
45318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
45319        const check = module.querySelector(".module-check");
45320        if (check) check.classList.toggle("visible", allCompleted);
45321      });
45322    }
45323
45324    function initCompletionButtons() {
45325      const completions = migrateCompletions();
45326
45327      document.querySelectorAll(".complete-btn").forEach((btn) => {
45328        if (btn.dataset.completionInit) return;
45329        btn.dataset.completionInit = "true";
45330        updateButtonState(btn, completions);
45331
45332        btn.addEventListener("click", (e) => {
45333          e.preventDefault();
45334          e.stopPropagation();
45335
45336          const id = btn.dataset.completionId;
45337          let list = getCompletions();
45338          const completed = isCompleted(id, list);
45339
45340          if (completed) {
45341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
45342            list = list.filter((c) =>
45342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
45343          } else {
45344            list.push(id);
45345          }
45346
45347          saveCompletions(list);
45348          updateButtonState(btn, list);
45349          checkModuleCompletion(list);
45350        });
45351      });
45352
45353      checkModuleCompletion(completions);
45354    }
45355
45356    if (document.readyState === "loading") {
45357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
45358    } else {
45359      initCompletionButtons();
45360    }
45361  })();
45362</script>
45362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="A8mTvjSW9vw" data-start="16595" data-end="18754" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Testing y análisis</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>36 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-4-4-testing-y-analisis" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
45362<script>
45363  (() => {
45364    if (window.__courseLazyPlayersInitialized) return;
45365    window.__courseLazyPlayersInitialized = true;
45366
45367    function createPlayer(details) {
45368      const videoId = details.dataset.videoId;
45369      const start = details.dataset.start;
45370      const end = details.dataset.end;
45371
45372      const container = document.createElement("div");
45373      container.className = "player-container";
45374
45375      const spinner = document.createElement("div");
45376      spinner.className = "spinner";
45377      container.appendChild(spinner);
45378
45379      const controller = document.createElement("media-controller");
45380      controller.setAttribute("defaultsubtitles", "");
45381
45382      const provider = document.createElement("youtube-media-provider");
45383      provider.setAttribute("slot", "media");
45384      provider.setAttribute("video-id", videoId);
45385      provider.setAttribute("start", start);
45386      provider.setAttribute("end", end);
45387
45388      const controlBar = document.createElement("media-control-bar");
45389
45390      const playBtn = document.createElement("media-play-button");
45391      const timeDisplay = document.createElement("media-time-display");
45392      timeDisplay.setAttribute("showduration", "");
45393      const timeRange = document.createElement("media-time-range");
45394      const playbackRate = document.createElement("media-playback-rate-button");
45395      const muteBtn = document.createElement("media-mute-button");
45396      const volumeRange = document.createElement("media-volume-range");
45397
45398      const captionsBtn = document.createElement("media-captions-button");
45399      captionsBtn.addEventListener("click", (e) => {
45400        e.stopPropagation();
45401        const provider = container.querySelector("youtube-media-provider");
45402        if (provider) provider.toggleCaptions();
45403      });
45404
45405      const qualityBtn = document.createElement("media-chrome-button");
45406      qualityBtn.textContent = "--";
45407      qualityBtn.addEventListener("click", () => {
45408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
45409      });
45410
45411      const fullscreenBtn = document.createElement("media-fullscreen-button");
45412
45413      controlBar.append(
45414        playBtn, timeDisplay, timeRange,
45415        playbackRate, muteBtn, volumeRange,
45416        captionsBtn, qualityBtn, fullscreenBtn
45417      );
45418
45419      const poster = document.createElement("media-poster-image");
45420      poster.setAttribute("slot", "poster");
45421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
45422
45423      controller.append(provider, poster, controlBar);
45424
45425      captionsBtn.setAttribute("aria-checked", "true");
45426      controller.setAttribute("mediasubtitlesshowing", "on");
45427
45428      container.appendChild(controller);
45429      details.appendChild(container);
45430
45431      provider.addEventListener("waiting", () => container.classList.add("loading"));
45432      provider.addEventListener("playing", () => container.classList.remove("loading"));
45433      provider.addEventListener("play", () => container.classList.remove("loading"));
45434
45435      provider._textTracks.addEventListener("change", () => {
45436        const enabled = provider._captionsEnabled;
45437        captionsBtn.setAttribute("aria-checked", enabled);
45438        if (enabled) {
45439          controller.setAttribute("mediasubtitlesshowing", "on");
45440        } else {
45441          controller.removeAttribute("mediasubtitlesshowing");
45442        }
45443      });
45444
45445      provider.addEventListener("qualitychange", (e) => {
45446        qualityBtn.textContent = e.detail.quality;
45447      });
45448    }
45449
45450    function initLazyPlayers() {
45451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
45452        if (details.dataset.lazyInit) return;
45453        details.dataset.lazyInit = "true";
45454        details.addEventListener("toggle", () => {
45455          if (details.open && !details.querySelector(".player-container")) {
45456            createPlayer(details);
45457          } else if (!details.open) {
45458            const provider = details.querySelector("youtube-media-provider");
45459            if (provider && typeof provider.pause === "function") {
45460              provider.pause();
45461            }
45462          }
45463        });
45464      });
45465    }
45466
45467    if (document.readyState === "loading") {
45468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
45469    } else {
45470      initLazyPlayers();
45471    }
45472  })();
45473</script>
45473<script>
45474  (() => {
45475    if (window.__courseCompletionsInitialized) return;
45476    window.__courseCompletionsInitialized = true;
45477
45478    const STORAGE_KEY = "course-completions";
45479
45480    function slugify(text) {
45481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45482    }
45483
45484    function getCompletions() {
45485      try {
45486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
45487      } catch {
45488        return [];
45489      }
45490    }
45491
45492    function saveCompletions(completions) {
45493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
45494    }
45495
45496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
45497    function canonicalFromStored(storedId) {
45498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45499      if (!m) return slugify(storedId);
45500      const [, slug, p, mv, title] = m;
45501      return `${slug}-${p}-${mv}-${slugify(title)}`;
45502    }
45503
45504    function canonicalPlusOne(storedId) {
45505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45506      if (!m) return null;
45507      const [, slug, p, mv, title] = m;
45508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
45509    }
45510
45511    function isCompleted(canonicalId, completions) {
45512      if (completions.includes(canonicalId)) return true;
45513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
45514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
45515    }
45516
45517    function migrateCompletions() {
45518      const completions = getCompletions();
45519      let changed = false;
45520      const migrated = completions.map((c) => {
45521        const canon = canonicalFromStored(c);
45522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
45523        const plusOne = canonicalPlusOne(c);
45524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
45525          if (plusOne !== c) changed = true;
45526          return plusOne;
45527        }
45528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
45529          changed = true;
45530          return canon;
45531        }
45532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
45533        if (canon !== c) {
45534          changed = true;
45535          return canon;
45536        }
45537        return c;
45538      });
45539      // Elimina duplicados tras migración
45540      const deduped = [...new Set(migrated)];
45541      if (changed || deduped.length !== completions.length) {
45542        saveCompletions(deduped);
45543        return deduped;
45544      }
45545      return completions;
45546    }
45547
45548    function updateButtonState(btn, completions) {
45549      const id = btn.dataset.completionId;
45550      const list = completions ?? getCompletions();
45551      btn.classList.toggle("completed", isCompleted(id, list));
45552    }
45553
45554    function checkModuleCompletion(completions) {
45555      const list = completions ?? getCompletions();
45556      document.querySelectorAll(".course-video").forEach((module) => {
45557        const btns = module.querySelectorAll(".complete-btn");
45558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
45559        const check = module.querySelector(".module-check");
45560        if (check) check.classList.toggle("visible", allCompleted);
45561      });
45562    }
45563
45564    function initCompletionButtons() {
45565      const completions = migrateCompletions();
45566
45567      document.querySelectorAll(".complete-btn").forEach((btn) => {
45568        if (btn.dataset.completionInit) return;
45569        btn.dataset.completionInit = "true";
45570        updateButtonState(btn, completions);
45571
45572        btn.addEventListener("click", (e) => {
45573          e.preventDefault();
45574          e.stopPropagation();
45575
45576          const id = btn.dataset.completionId;
45577          let list = getCompletions();
45578          const completed = isCompleted(id, list);
45579
45580          if (completed) {
45581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
45582            list = list.filter((c) =>
45582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
45583          } else {
45584            list.push(id);
45585          }
45586
45587          saveCompletions(list);
45588          updateButtonState(btn, list);
45589          checkModuleCompletion(list);
45590        });
45591      });
45592
45593      checkModuleCompletion(completions);
45594    }
45595
45596    if (document.readyState === "loading") {
45597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
45598    } else {
45599      initCompletionButtons();
45600    }
45601  })();
45602</script>
45602</div></details></div></details><details class="phase" open data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>FASE 5: Introducción back-end</summary><div class="phase-content" data-astro-cid-3yfbwi5j><div class="description" data-astro-cid-3yfbwi5j><p data-astro-cid-3yfbwi5j>Por último, para finalizar el Bootcamp, vamos a explorar algunos aspectos más relacionados con servidores y back-end. Desde el uso de NodeJS y Express, hasta un metaframework como Astro o utilizar un servidor VPS. Con esto cerramos este Bootcamp.</p></div><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 17 📗 NodeJS<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="1723" data-end="2400" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción a NodeJS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-introduccion-a-nodejs" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
45602<script>
45603  (() => {
45604    if (window.__courseLazyPlayersInitialized) return;
45605    window.__courseLazyPlayersInitialized = true;
45606
45607    function createPlayer(details) {
45608      const videoId = details.dataset.videoId;
45609      const start = details.dataset.start;
45610      const end = details.dataset.end;
45611
45612      const container = document.createElement("div");
45613      container.className = "player-container";
45614
45615      const spinner = document.createElement("div");
45616      spinner.className = "spinner";
45617      container.appendChild(spinner);
45618
45619      const controller = document.createElement("media-controller");
45620      controller.setAttribute("defaultsubtitles", "");
45621
45622      const provider = document.createElement("youtube-media-provider");
45623      provider.setAttribute("slot", "media");
45624      provider.setAttribute("video-id", videoId);
45625      provider.setAttribute("start", start);
45626      provider.setAttribute("end", end);
45627
45628      const controlBar = document.createElement("media-control-bar");
45629
45630      const playBtn = document.createElement("media-play-button");
45631      const timeDisplay = document.createElement("media-time-display");
45632      timeDisplay.setAttribute("showduration", "");
45633      const timeRange = document.createElement("media-time-range");
45634      const playbackRate = document.createElement("media-playback-rate-button");
45635      const muteBtn = document.createElement("media-mute-button");
45636      const volumeRange = document.createElement("media-volume-range");
45637
45638      const captionsBtn = document.createElement("media-captions-button");
45639      captionsBtn.addEventListener("click", (e) => {
45640        e.stopPropagation();
45641        const provider = container.querySelector("youtube-media-provider");
45642        if (provider) provider.toggleCaptions();
45643      });
45644
45645      const qualityBtn = document.createElement("media-chrome-button");
45646      qualityBtn.textContent = "--";
45647      qualityBtn.addEventListener("click", () => {
45648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
45649      });
45650
45651      const fullscreenBtn = document.createElement("media-fullscreen-button");
45652
45653      controlBar.append(
45654        playBtn, timeDisplay, timeRange,
45655        playbackRate, muteBtn, volumeRange,
45656        captionsBtn, qualityBtn, fullscreenBtn
45657      );
45658
45659      const poster = document.createElement("media-poster-image");
45660      poster.setAttribute("slot", "poster");
45661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
45662
45663      controller.append(provider, poster, controlBar);
45664
45665      captionsBtn.setAttribute("aria-checked", "true");
45666      controller.setAttribute("mediasubtitlesshowing", "on");
45667
45668      container.appendChild(controller);
45669      details.appendChild(container);
45670
45671      provider.addEventListener("waiting", () => container.classList.add("loading"));
45672      provider.addEventListener("playing", () => container.classList.remove("loading"));
45673      provider.addEventListener("play", () => container.classList.remove("loading"));
45674
45675      provider._textTracks.addEventListener("change", () => {
45676        const enabled = provider._captionsEnabled;
45677        captionsBtn.setAttribute("aria-checked", enabled);
45678        if (enabled) {
45679          controller.setAttribute("mediasubtitlesshowing", "on");
45680        } else {
45681          controller.removeAttribute("mediasubtitlesshowing");
45682        }
45683      });
45684
45685      provider.addEventListener("qualitychange", (e) => {
45686        qualityBtn.textContent = e.detail.quality;
45687      });
45688    }
45689
45690    function initLazyPlayers() {
45691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
45692        if (details.dataset.lazyInit) return;
45693        details.dataset.lazyInit = "true";
45694        details.addEventListener("toggle", () => {
45695          if (details.open && !details.querySelector(".player-container")) {
45696            createPlayer(details);
45697          } else if (!details.open) {
45698            const provider = details.querySelector("youtube-media-provider");
45699            if (provider && typeof provider.pause === "function") {
45700              provider.pause();
45701            }
45702          }
45703        });
45704      });
45705    }
45706
45707    if (document.readyState === "loading") {
45708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
45709    } else {
45710      initLazyPlayers();
45711    }
45712  })();
45713</script>
45713<script>
45714  (() => {
45715    if (window.__courseCompletionsInitialized) return;
45716    window.__courseCompletionsInitialized = true;
45717
45718    const STORAGE_KEY = "course-completions";
45719
45720    function slugify(text) {
45721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45722    }
45723
45724    function getCompletions() {
45725      try {
45726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
45727      } catch {
45728        return [];
45729      }
45730    }
45731
45732    function saveCompletions(completions) {
45733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
45734    }
45735
45736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
45737    function canonicalFromStored(storedId) {
45738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45739      if (!m) return slugify(storedId);
45740      const [, slug, p, mv, title] = m;
45741      return `${slug}-${p}-${mv}-${slugify(title)}`;
45742    }
45743
45744    function canonicalPlusOne(storedId) {
45745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45746      if (!m) return null;
45747      const [, slug, p, mv, title] = m;
45748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
45749    }
45750
45751    function isCompleted(canonicalId, completions) {
45752      if (completions.includes(canonicalId)) return true;
45753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
45754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
45755    }
45756
45757    function migrateCompletions() {
45758      const completions = getCompletions();
45759      let changed = false;
45760      const migrated = completions.map((c) => {
45761        const canon = canonicalFromStored(c);
45762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
45763        const plusOne = canonicalPlusOne(c);
45764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
45765          if (plusOne !== c) changed = true;
45766          return plusOne;
45767        }
45768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
45769          changed = true;
45770          return canon;
45771        }
45772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
45773        if (canon !== c) {
45774          changed = true;
45775          return canon;
45776        }
45777        return c;
45778      });
45779      // Elimina duplicados tras migración
45780      const deduped = [...new Set(migrated)];
45781      if (changed || deduped.length !== completions.length) {
45782        saveCompletions(deduped);
45783        return deduped;
45784      }
45785      return completions;
45786    }
45787
45788    function updateButtonState(btn, completions) {
45789      const id = btn.dataset.completionId;
45790      const list = completions ?? getCompletions();
45791      btn.classList.toggle("completed", isCompleted(id, list));
45792    }
45793
45794    function checkModuleCompletion(completions) {
45795      const list = completions ?? getCompletions();
45796      document.querySelectorAll(".course-video").forEach((module) => {
45797        const btns = module.querySelectorAll(".complete-btn");
45798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
45799        const check = module.querySelector(".module-check");
45800        if (check) check.classList.toggle("visible", allCompleted);
45801      });
45802    }
45803
45804    function initCompletionButtons() {
45805      const completions = migrateCompletions();
45806
45807      document.querySelectorAll(".complete-btn").forEach((btn) => {
45808        if (btn.dataset.completionInit) return;
45809        btn.dataset.completionInit = "true";
45810        updateButtonState(btn, completions);
45811
45812        btn.addEventListener("click", (e) => {
45813          e.preventDefault();
45814          e.stopPropagation();
45815
45816          const id = btn.dataset.completionId;
45817          let list = getCompletions();
45818          const completed = isCompleted(id, list);
45819
45820          if (completed) {
45821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
45822            list = list.filter((c) =>
45822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
45823          } else {
45824            list.push(id);
45825          }
45826
45827          saveCompletions(list);
45828          updateButtonState(btn, list);
45829          checkModuleCompletion(list);
45830        });
45831      });
45832
45833      checkModuleCompletion(completions);
45834    }
45835
45836    if (document.readyState === "loading") {
45837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
45838    } else {
45839      initCompletionButtons();
45840    }
45841  })();
45842</script>
45842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="2400" data-end="3320" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Primeros pasos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-primeros-pasos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
45842<script>
45843  (() => {
45844    if (window.__courseLazyPlayersInitialized) return;
45845    window.__courseLazyPlayersInitialized = true;
45846
45847    function createPlayer(details) {
45848      const videoId = details.dataset.videoId;
45849      const start = details.dataset.start;
45850      const end = details.dataset.end;
45851
45852      const container = document.createElement("div");
45853      container.className = "player-container";
45854
45855      const spinner = document.createElement("div");
45856      spinner.className = "spinner";
45857      container.appendChild(spinner);
45858
45859      const controller = document.createElement("media-controller");
45860      controller.setAttribute("defaultsubtitles", "");
45861
45862      const provider = document.createElement("youtube-media-provider");
45863      provider.setAttribute("slot", "media");
45864      provider.setAttribute("video-id", videoId);
45865      provider.setAttribute("start", start);
45866      provider.setAttribute("end", end);
45867
45868      const controlBar = document.createElement("media-control-bar");
45869
45870      const playBtn = document.createElement("media-play-button");
45871      const timeDisplay = document.createElement("media-time-display");
45872      timeDisplay.setAttribute("showduration", "");
45873      const timeRange = document.createElement("media-time-range");
45874      const playbackRate = document.createElement("media-playback-rate-button");
45875      const muteBtn = document.createElement("media-mute-button");
45876      const volumeRange = document.createElement("media-volume-range");
45877
45878      const captionsBtn = document.createElement("media-captions-button");
45879      captionsBtn.addEventListener("click", (e) => {
45880        e.stopPropagation();
45881        const provider = container.querySelector("youtube-media-provider");
45882        if (provider) provider.toggleCaptions();
45883      });
45884
45885      const qualityBtn = document.createElement("media-chrome-button");
45886      qualityBtn.textContent = "--";
45887      qualityBtn.addEventListener("click", () => {
45888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
45889      });
45890
45891      const fullscreenBtn = document.createElement("media-fullscreen-button");
45892
45893      controlBar.append(
45894        playBtn, timeDisplay, timeRange,
45895        playbackRate, muteBtn, volumeRange,
45896        captionsBtn, qualityBtn, fullscreenBtn
45897      );
45898
45899      const poster = document.createElement("media-poster-image");
45900      poster.setAttribute("slot", "poster");
45901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
45902
45903      controller.append(provider, poster, controlBar);
45904
45905      captionsBtn.setAttribute("aria-checked", "true");
45906      controller.setAttribute("mediasubtitlesshowing", "on");
45907
45908      container.appendChild(controller);
45909      details.appendChild(container);
45910
45911      provider.addEventListener("waiting", () => container.classList.add("loading"));
45912      provider.addEventListener("playing", () => container.classList.remove("loading"));
45913      provider.addEventListener("play", () => container.classList.remove("loading"));
45914
45915      provider._textTracks.addEventListener("change", () => {
45916        const enabled = provider._captionsEnabled;
45917        captionsBtn.setAttribute("aria-checked", enabled);
45918        if (enabled) {
45919          controller.setAttribute("mediasubtitlesshowing", "on");
45920        } else {
45921          controller.removeAttribute("mediasubtitlesshowing");
45922        }
45923      });
45924
45925      provider.addEventListener("qualitychange", (e) => {
45926        qualityBtn.textContent = e.detail.quality;
45927      });
45928    }
45929
45930    function initLazyPlayers() {
45931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
45932        if (details.dataset.lazyInit) return;
45933        details.dataset.lazyInit = "true";
45934        details.addEventListener("toggle", () => {
45935          if (details.open && !details.querySelector(".player-container")) {
45936            createPlayer(details);
45937          } else if (!details.open) {
45938            const provider = details.querySelector("youtube-media-provider");
45939            if (provider && typeof provider.pause === "function") {
45940              provider.pause();
45941            }
45942          }
45943        });
45944      });
45945    }
45946
45947    if (document.readyState === "loading") {
45948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
45949    } else {
45950      initLazyPlayers();
45951    }
45952  })();
45953</script>
45953<script>
45954  (() => {
45955    if (window.__courseCompletionsInitialized) return;
45956    window.__courseCompletionsInitialized = true;
45957
45958    const STORAGE_KEY = "course-completions";
45959
45960    function slugify(text) {
45961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45962    }
45963
45964    function getCompletions() {
45965      try {
45966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
45967      } catch {
45968        return [];
45969      }
45970    }
45971
45972    function saveCompletions(completions) {
45973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
45974    }
45975
45976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
45977    function canonicalFromStored(storedId) {
45978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45979      if (!m) return slugify(storedId);
45980      const [, slug, p, mv, title] = m;
45981      return `${slug}-${p}-${mv}-${slugify(title)}`;
45982    }
45983
45984    function canonicalPlusOne(storedId) {
45985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
45986      if (!m) return null;
45987      const [, slug, p, mv, title] = m;
45988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
45989    }
45990
45991    function isCompleted(canonicalId, completions) {
45992      if (completions.includes(canonicalId)) return true;
45993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
45994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
45995    }
45996
45997    function migrateCompletions() {
45998      const completions = getCompletions();
45999      let changed = false;
46000      const migrated = completions.map((c) => {
46001        const canon = canonicalFromStored(c);
46002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
46003        const plusOne = canonicalPlusOne(c);
46004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
46005          if (plusOne !== c) changed = true;
46006          return plusOne;
46007        }
46008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
46009          changed = true;
46010          return canon;
46011        }
46012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
46013        if (canon !== c) {
46014          changed = true;
46015          return canon;
46016        }
46017        return c;
46018      });
46019      // Elimina duplicados tras migración
46020      const deduped = [...new Set(migrated)];
46021      if (changed || deduped.length !== completions.length) {
46022        saveCompletions(deduped);
46023        return deduped;
46024      }
46025      return completions;
46026    }
46027
46028    function updateButtonState(btn, completions) {
46029      const id = btn.dataset.completionId;
46030      const list = completions ?? getCompletions();
46031      btn.classList.toggle("completed", isCompleted(id, list));
46032    }
46033
46034    function checkModuleCompletion(completions) {
46035      const list = completions ?? getCompletions();
46036      document.querySelectorAll(".course-video").forEach((module) => {
46037        const btns = module.querySelectorAll(".complete-btn");
46038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
46039        const check = module.querySelector(".module-check");
46040        if (check) check.classList.toggle("visible", allCompleted);
46041      });
46042    }
46043
46044    function initCompletionButtons() {
46045      const completions = migrateCompletions();
46046
46047      document.querySelectorAll(".complete-btn").forEach((btn) => {
46048        if (btn.dataset.completionInit) return;
46049        btn.dataset.completionInit = "true";
46050        updateButtonState(btn, completions);
46051
46052        btn.addEventListener("click", (e) => {
46053          e.preventDefault();
46054          e.stopPropagation();
46055
46056          const id = btn.dataset.completionId;
46057          let list = getCompletions();
46058          const completed = isCompleted(id, list);
46059
46060          if (completed) {
46061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
46062            list = list.filter((c) =>
46062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
46063          } else {
46064            list.push(id);
46065          }
46066
46067          saveCompletions(list);
46068          updateButtonState(btn, list);
46069          checkModuleCompletion(list);
46070        });
46071      });
46072
46073      checkModuleCompletion(completions);
46074    }
46075
46076    if (document.readyState === "loading") {
46077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
46078    } else {
46079      initCompletionButtons();
46080    }
46081  })();
46082</script>
46082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="3320" data-end="4485" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Módulos de sistema operativo (os)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-modulos-de-sistema-operativo-os" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
46082<script>
46083  (() => {
46084    if (window.__courseLazyPlayersInitialized) return;
46085    window.__courseLazyPlayersInitialized = true;
46086
46087    function createPlayer(details) {
46088      const videoId = details.dataset.videoId;
46089      const start = details.dataset.start;
46090      const end = details.dataset.end;
46091
46092      const container = document.createElement("div");
46093      container.className = "player-container";
46094
46095      const spinner = document.createElement("div");
46096      spinner.className = "spinner";
46097      container.appendChild(spinner);
46098
46099      const controller = document.createElement("media-controller");
46100      controller.setAttribute("defaultsubtitles", "");
46101
46102      const provider = document.createElement("youtube-media-provider");
46103      provider.setAttribute("slot", "media");
46104      provider.setAttribute("video-id", videoId);
46105      provider.setAttribute("start", start);
46106      provider.setAttribute("end", end);
46107
46108      const controlBar = document.createElement("media-control-bar");
46109
46110      const playBtn = document.createElement("media-play-button");
46111      const timeDisplay = document.createElement("media-time-display");
46112      timeDisplay.setAttribute("showduration", "");
46113      const timeRange = document.createElement("media-time-range");
46114      const playbackRate = document.createElement("media-playback-rate-button");
46115      const muteBtn = document.createElement("media-mute-button");
46116      const volumeRange = document.createElement("media-volume-range");
46117
46118      const captionsBtn = document.createElement("media-captions-button");
46119      captionsBtn.addEventListener("click", (e) => {
46120        e.stopPropagation();
46121        const provider = container.querySelector("youtube-media-provider");
46122        if (provider) provider.toggleCaptions();
46123      });
46124
46125      const qualityBtn = document.createElement("media-chrome-button");
46126      qualityBtn.textContent = "--";
46127      qualityBtn.addEventListener("click", () => {
46128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
46129      });
46130
46131      const fullscreenBtn = document.createElement("media-fullscreen-button");
46132
46133      controlBar.append(
46134        playBtn, timeDisplay, timeRange,
46135        playbackRate, muteBtn, volumeRange,
46136        captionsBtn, qualityBtn, fullscreenBtn
46137      );
46138
46139      const poster = document.createElement("media-poster-image");
46140      poster.setAttribute("slot", "poster");
46141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
46142
46143      controller.append(provider, poster, controlBar);
46144
46145      captionsBtn.setAttribute("aria-checked", "true");
46146      controller.setAttribute("mediasubtitlesshowing", "on");
46147
46148      container.appendChild(controller);
46149      details.appendChild(container);
46150
46151      provider.addEventListener("waiting", () => container.classList.add("loading"));
46152      provider.addEventListener("playing", () => container.classList.remove("loading"));
46153      provider.addEventListener("play", () => container.classList.remove("loading"));
46154
46155      provider._textTracks.addEventListener("change", () => {
46156        const enabled = provider._captionsEnabled;
46157        captionsBtn.setAttribute("aria-checked", enabled);
46158        if (enabled) {
46159          controller.setAttribute("mediasubtitlesshowing", "on");
46160        } else {
46161          controller.removeAttribute("mediasubtitlesshowing");
46162        }
46163      });
46164
46165      provider.addEventListener("qualitychange", (e) => {
46166        qualityBtn.textContent = e.detail.quality;
46167      });
46168    }
46169
46170    function initLazyPlayers() {
46171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
46172        if (details.dataset.lazyInit) return;
46173        details.dataset.lazyInit = "true";
46174        details.addEventListener("toggle", () => {
46175          if (details.open && !details.querySelector(".player-container")) {
46176            createPlayer(details);
46177          } else if (!details.open) {
46178            const provider = details.querySelector("youtube-media-provider");
46179            if (provider && typeof provider.pause === "function") {
46180              provider.pause();
46181            }
46182          }
46183        });
46184      });
46185    }
46186
46187    if (document.readyState === "loading") {
46188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
46189    } else {
46190      initLazyPlayers();
46191    }
46192  })();
46193</script>
46193<script>
46194  (() => {
46195    if (window.__courseCompletionsInitialized) return;
46196    window.__courseCompletionsInitialized = true;
46197
46198    const STORAGE_KEY = "course-completions";
46199
46200    function slugify(text) {
46201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46202    }
46203
46204    function getCompletions() {
46205      try {
46206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
46207      } catch {
46208        return [];
46209      }
46210    }
46211
46212    function saveCompletions(completions) {
46213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
46214    }
46215
46216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
46217    function canonicalFromStored(storedId) {
46218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46219      if (!m) return slugify(storedId);
46220      const [, slug, p, mv, title] = m;
46221      return `${slug}-${p}-${mv}-${slugify(title)}`;
46222    }
46223
46224    function canonicalPlusOne(storedId) {
46225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46226      if (!m) return null;
46227      const [, slug, p, mv, title] = m;
46228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
46229    }
46230
46231    function isCompleted(canonicalId, completions) {
46232      if (completions.includes(canonicalId)) return true;
46233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
46234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
46235    }
46236
46237    function migrateCompletions() {
46238      const completions = getCompletions();
46239      let changed = false;
46240      const migrated = completions.map((c) => {
46241        const canon = canonicalFromStored(c);
46242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
46243        const plusOne = canonicalPlusOne(c);
46244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
46245          if (plusOne !== c) changed = true;
46246          return plusOne;
46247        }
46248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
46249          changed = true;
46250          return canon;
46251        }
46252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
46253        if (canon !== c) {
46254          changed = true;
46255          return canon;
46256        }
46257        return c;
46258      });
46259      // Elimina duplicados tras migración
46260      const deduped = [...new Set(migrated)];
46261      if (changed || deduped.length !== completions.length) {
46262        saveCompletions(deduped);
46263        return deduped;
46264      }
46265      return completions;
46266    }
46267
46268    function updateButtonState(btn, completions) {
46269      const id = btn.dataset.completionId;
46270      const list = completions ?? getCompletions();
46271      btn.classList.toggle("completed", isCompleted(id, list));
46272    }
46273
46274    function checkModuleCompletion(completions) {
46275      const list = completions ?? getCompletions();
46276      document.querySelectorAll(".course-video").forEach((module) => {
46277        const btns = module.querySelectorAll(".complete-btn");
46278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
46279        const check = module.querySelector(".module-check");
46280        if (check) check.classList.toggle("visible", allCompleted);
46281      });
46282    }
46283
46284    function initCompletionButtons() {
46285      const completions = migrateCompletions();
46286
46287      document.querySelectorAll(".complete-btn").forEach((btn) => {
46288        if (btn.dataset.completionInit) return;
46289        btn.dataset.completionInit = "true";
46290        updateButtonState(btn, completions);
46291
46292        btn.addEventListener("click", (e) => {
46293          e.preventDefault();
46294          e.stopPropagation();
46295
46296          const id = btn.dataset.completionId;
46297          let list = getCompletions();
46298          const completed = isCompleted(id, list);
46299
46300          if (completed) {
46301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
46302            list = list.filter((c) =>
46302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
46303          } else {
46304            list.push(id);
46305          }
46306
46307          saveCompletions(list);
46308          updateButtonState(btn, list);
46309          checkModuleCompletion(list);
46310        });
46311      });
46312
46313      checkModuleCompletion(completions);
46314    }
46315
46316    if (document.readyState === "loading") {
46317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
46318    } else {
46319      initCompletionButtons();
46320    }
46321  })();
46322</script>
46322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="4485" data-end="5280" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Módulos para colorear terminal (util)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-modulos-para-colorear-terminal-util" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
46322<script>
46323  (() => {
46324    if (window.__courseLazyPlayersInitialized) return;
46325    window.__courseLazyPlayersInitialized = true;
46326
46327    function createPlayer(details) {
46328      const videoId = details.dataset.videoId;
46329      const start = details.dataset.start;
46330      const end = details.dataset.end;
46331
46332      const container = document.createElement("div");
46333      container.className = "player-container";
46334
46335      const spinner = document.createElement("div");
46336      spinner.className = "spinner";
46337      container.appendChild(spinner);
46338
46339      const controller = document.createElement("media-controller");
46340      controller.setAttribute("defaultsubtitles", "");
46341
46342      const provider = document.createElement("youtube-media-provider");
46343      provider.setAttribute("slot", "media");
46344      provider.setAttribute("video-id", videoId);
46345      provider.setAttribute("start", start);
46346      provider.setAttribute("end", end);
46347
46348      const controlBar = document.createElement("media-control-bar");
46349
46350      const playBtn = document.createElement("media-play-button");
46351      const timeDisplay = document.createElement("media-time-display");
46352      timeDisplay.setAttribute("showduration", "");
46353      const timeRange = document.createElement("media-time-range");
46354      const playbackRate = document.createElement("media-playback-rate-button");
46355      const muteBtn = document.createElement("media-mute-button");
46356      const volumeRange = document.createElement("media-volume-range");
46357
46358      const captionsBtn = document.createElement("media-captions-button");
46359      captionsBtn.addEventListener("click", (e) => {
46360        e.stopPropagation();
46361        const provider = container.querySelector("youtube-media-provider");
46362        if (provider) provider.toggleCaptions();
46363      });
46364
46365      const qualityBtn = document.createElement("media-chrome-button");
46366      qualityBtn.textContent = "--";
46367      qualityBtn.addEventListener("click", () => {
46368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
46369      });
46370
46371      const fullscreenBtn = document.createElement("media-fullscreen-button");
46372
46373      controlBar.append(
46374        playBtn, timeDisplay, timeRange,
46375        playbackRate, muteBtn, volumeRange,
46376        captionsBtn, qualityBtn, fullscreenBtn
46377      );
46378
46379      const poster = document.createElement("media-poster-image");
46380      poster.setAttribute("slot", "poster");
46381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
46382
46383      controller.append(provider, poster, controlBar);
46384
46385      captionsBtn.setAttribute("aria-checked", "true");
46386      controller.setAttribute("mediasubtitlesshowing", "on");
46387
46388      container.appendChild(controller);
46389      details.appendChild(container);
46390
46391      provider.addEventListener("waiting", () => container.classList.add("loading"));
46392      provider.addEventListener("playing", () => container.classList.remove("loading"));
46393      provider.addEventListener("play", () => container.classList.remove("loading"));
46394
46395      provider._textTracks.addEventListener("change", () => {
46396        const enabled = provider._captionsEnabled;
46397        captionsBtn.setAttribute("aria-checked", enabled);
46398        if (enabled) {
46399          controller.setAttribute("mediasubtitlesshowing", "on");
46400        } else {
46401          controller.removeAttribute("mediasubtitlesshowing");
46402        }
46403      });
46404
46405      provider.addEventListener("qualitychange", (e) => {
46406        qualityBtn.textContent = e.detail.quality;
46407      });
46408    }
46409
46410    function initLazyPlayers() {
46411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
46412        if (details.dataset.lazyInit) return;
46413        details.dataset.lazyInit = "true";
46414        details.addEventListener("toggle", () => {
46415          if (details.open && !details.querySelector(".player-container")) {
46416            createPlayer(details);
46417          } else if (!details.open) {
46418            const provider = details.querySelector("youtube-media-provider");
46419            if (provider && typeof provider.pause === "function") {
46420              provider.pause();
46421            }
46422          }
46423        });
46424      });
46425    }
46426
46427    if (document.readyState === "loading") {
46428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
46429    } else {
46430      initLazyPlayers();
46431    }
46432  })();
46433</script>
46433<script>
46434  (() => {
46435    if (window.__courseCompletionsInitialized) return;
46436    window.__courseCompletionsInitialized = true;
46437
46438    const STORAGE_KEY = "course-completions";
46439
46440    function slugify(text) {
46441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46442    }
46443
46444    function getCompletions() {
46445      try {
46446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
46447      } catch {
46448        return [];
46449      }
46450    }
46451
46452    function saveCompletions(completions) {
46453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
46454    }
46455
46456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
46457    function canonicalFromStored(storedId) {
46458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46459      if (!m) return slugify(storedId);
46460      const [, slug, p, mv, title] = m;
46461      return `${slug}-${p}-${mv}-${slugify(title)}`;
46462    }
46463
46464    function canonicalPlusOne(storedId) {
46465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46466      if (!m) return null;
46467      const [, slug, p, mv, title] = m;
46468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
46469    }
46470
46471    function isCompleted(canonicalId, completions) {
46472      if (completions.includes(canonicalId)) return true;
46473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
46474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
46475    }
46476
46477    function migrateCompletions() {
46478      const completions = getCompletions();
46479      let changed = false;
46480      const migrated = completions.map((c) => {
46481        const canon = canonicalFromStored(c);
46482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
46483        const plusOne = canonicalPlusOne(c);
46484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
46485          if (plusOne !== c) changed = true;
46486          return plusOne;
46487        }
46488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
46489          changed = true;
46490          return canon;
46491        }
46492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
46493        if (canon !== c) {
46494          changed = true;
46495          return canon;
46496        }
46497        return c;
46498      });
46499      // Elimina duplicados tras migración
46500      const deduped = [...new Set(migrated)];
46501      if (changed || deduped.length !== completions.length) {
46502        saveCompletions(deduped);
46503        return deduped;
46504      }
46505      return completions;
46506    }
46507
46508    function updateButtonState(btn, completions) {
46509      const id = btn.dataset.completionId;
46510      const list = completions ?? getCompletions();
46511      btn.classList.toggle("completed", isCompleted(id, list));
46512    }
46513
46514    function checkModuleCompletion(completions) {
46515      const list = completions ?? getCompletions();
46516      document.querySelectorAll(".course-video").forEach((module) => {
46517        const btns = module.querySelectorAll(".complete-btn");
46518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
46519        const check = module.querySelector(".module-check");
46520        if (check) check.classList.toggle("visible", allCompleted);
46521      });
46522    }
46523
46524    function initCompletionButtons() {
46525      const completions = migrateCompletions();
46526
46527      document.querySelectorAll(".complete-btn").forEach((btn) => {
46528        if (btn.dataset.completionInit) return;
46529        btn.dataset.completionInit = "true";
46530        updateButtonState(btn, completions);
46531
46532        btn.addEventListener("click", (e) => {
46533          e.preventDefault();
46534          e.stopPropagation();
46535
46536          const id = btn.dataset.completionId;
46537          let list = getCompletions();
46538          const completed = isCompleted(id, list);
46539
46540          if (completed) {
46541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
46542            list = list.filter((c) =>
46542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
46543          } else {
46544            list.push(id);
46545          }
46546
46547          saveCompletions(list);
46548          updateButtonState(btn, list);
46549          checkModuleCompletion(list);
46550        });
46551      });
46552
46553      checkModuleCompletion(completions);
46554    }
46555
46556    if (document.readyState === "loading") {
46557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
46558    } else {
46559      initCompletionButtons();
46560    }
46561  })();
46562</script>
46562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="5280" data-end="6068" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Módulos de argumentos en scripts (process)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-modulos-de-argumentos-en-scripts-process" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
46562<script>
46563  (() => {
46564    if (window.__courseLazyPlayersInitialized) return;
46565    window.__courseLazyPlayersInitialized = true;
46566
46567    function createPlayer(details) {
46568      const videoId = details.dataset.videoId;
46569      const start = details.dataset.start;
46570      const end = details.dataset.end;
46571
46572      const container = document.createElement("div");
46573      container.className = "player-container";
46574
46575      const spinner = document.createElement("div");
46576      spinner.className = "spinner";
46577      container.appendChild(spinner);
46578
46579      const controller = document.createElement("media-controller");
46580      controller.setAttribute("defaultsubtitles", "");
46581
46582      const provider = document.createElement("youtube-media-provider");
46583      provider.setAttribute("slot", "media");
46584      provider.setAttribute("video-id", videoId);
46585      provider.setAttribute("start", start);
46586      provider.setAttribute("end", end);
46587
46588      const controlBar = document.createElement("media-control-bar");
46589
46590      const playBtn = document.createElement("media-play-button");
46591      const timeDisplay = document.createElement("media-time-display");
46592      timeDisplay.setAttribute("showduration", "");
46593      const timeRange = document.createElement("media-time-range");
46594      const playbackRate = document.createElement("media-playback-rate-button");
46595      const muteBtn = document.createElement("media-mute-button");
46596      const volumeRange = document.createElement("media-volume-range");
46597
46598      const captionsBtn = document.createElement("media-captions-button");
46599      captionsBtn.addEventListener("click", (e) => {
46600        e.stopPropagation();
46601        const provider = container.querySelector("youtube-media-provider");
46602        if (provider) provider.toggleCaptions();
46603      });
46604
46605      const qualityBtn = document.createElement("media-chrome-button");
46606      qualityBtn.textContent = "--";
46607      qualityBtn.addEventListener("click", () => {
46608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
46609      });
46610
46611      const fullscreenBtn = document.createElement("media-fullscreen-button");
46612
46613      controlBar.append(
46614        playBtn, timeDisplay, timeRange,
46615        playbackRate, muteBtn, volumeRange,
46616        captionsBtn, qualityBtn, fullscreenBtn
46617      );
46618
46619      const poster = document.createElement("media-poster-image");
46620      poster.setAttribute("slot", "poster");
46621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
46622
46623      controller.append(provider, poster, controlBar);
46624
46625      captionsBtn.setAttribute("aria-checked", "true");
46626      controller.setAttribute("mediasubtitlesshowing", "on");
46627
46628      container.appendChild(controller);
46629      details.appendChild(container);
46630
46631      provider.addEventListener("waiting", () => container.classList.add("loading"));
46632      provider.addEventListener("playing", () => container.classList.remove("loading"));
46633      provider.addEventListener("play", () => container.classList.remove("loading"));
46634
46635      provider._textTracks.addEventListener("change", () => {
46636        const enabled = provider._captionsEnabled;
46637        captionsBtn.setAttribute("aria-checked", enabled);
46638        if (enabled) {
46639          controller.setAttribute("mediasubtitlesshowing", "on");
46640        } else {
46641          controller.removeAttribute("mediasubtitlesshowing");
46642        }
46643      });
46644
46645      provider.addEventListener("qualitychange", (e) => {
46646        qualityBtn.textContent = e.detail.quality;
46647      });
46648    }
46649
46650    function initLazyPlayers() {
46651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
46652        if (details.dataset.lazyInit) return;
46653        details.dataset.lazyInit = "true";
46654        details.addEventListener("toggle", () => {
46655          if (details.open && !details.querySelector(".player-container")) {
46656            createPlayer(details);
46657          } else if (!details.open) {
46658            const provider = details.querySelector("youtube-media-provider");
46659            if (provider && typeof provider.pause === "function") {
46660              provider.pause();
46661            }
46662          }
46663        });
46664      });
46665    }
46666
46667    if (document.readyState === "loading") {
46668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
46669    } else {
46670      initLazyPlayers();
46671    }
46672  })();
46673</script>
46673<script>
46674  (() => {
46675    if (window.__courseCompletionsInitialized) return;
46676    window.__courseCompletionsInitialized = true;
46677
46678    const STORAGE_KEY = "course-completions";
46679
46680    function slugify(text) {
46681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46682    }
46683
46684    function getCompletions() {
46685      try {
46686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
46687      } catch {
46688        return [];
46689      }
46690    }
46691
46692    function saveCompletions(completions) {
46693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
46694    }
46695
46696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
46697    function canonicalFromStored(storedId) {
46698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46699      if (!m) return slugify(storedId);
46700      const [, slug, p, mv, title] = m;
46701      return `${slug}-${p}-${mv}-${slugify(title)}`;
46702    }
46703
46704    function canonicalPlusOne(storedId) {
46705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46706      if (!m) return null;
46707      const [, slug, p, mv, title] = m;
46708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
46709    }
46710
46711    function isCompleted(canonicalId, completions) {
46712      if (completions.includes(canonicalId)) return true;
46713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
46714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
46715    }
46716
46717    function migrateCompletions() {
46718      const completions = getCompletions();
46719      let changed = false;
46720      const migrated = completions.map((c) => {
46721        const canon = canonicalFromStored(c);
46722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
46723        const plusOne = canonicalPlusOne(c);
46724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
46725          if (plusOne !== c) changed = true;
46726          return plusOne;
46727        }
46728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
46729          changed = true;
46730          return canon;
46731        }
46732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
46733        if (canon !== c) {
46734          changed = true;
46735          return canon;
46736        }
46737        return c;
46738      });
46739      // Elimina duplicados tras migración
46740      const deduped = [...new Set(migrated)];
46741      if (changed || deduped.length !== completions.length) {
46742        saveCompletions(deduped);
46743        return deduped;
46744      }
46745      return completions;
46746    }
46747
46748    function updateButtonState(btn, completions) {
46749      const id = btn.dataset.completionId;
46750      const list = completions ?? getCompletions();
46751      btn.classList.toggle("completed", isCompleted(id, list));
46752    }
46753
46754    function checkModuleCompletion(completions) {
46755      const list = completions ?? getCompletions();
46756      document.querySelectorAll(".course-video").forEach((module) => {
46757        const btns = module.querySelectorAll(".complete-btn");
46758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
46759        const check = module.querySelector(".module-check");
46760        if (check) check.classList.toggle("visible", allCompleted);
46761      });
46762    }
46763
46764    function initCompletionButtons() {
46765      const completions = migrateCompletions();
46766
46767      document.querySelectorAll(".complete-btn").forEach((btn) => {
46768        if (btn.dataset.completionInit) return;
46769        btn.dataset.completionInit = "true";
46770        updateButtonState(btn, completions);
46771
46772        btn.addEventListener("click", (e) => {
46773          e.preventDefault();
46774          e.stopPropagation();
46775
46776          const id = btn.dataset.completionId;
46777          let list = getCompletions();
46778          const completed = isCompleted(id, list);
46779
46780          if (completed) {
46781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
46782            list = list.filter((c) =>
46782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
46783          } else {
46784            list.push(id);
46785          }
46786
46787          saveCompletions(list);
46788          updateButtonState(btn, list);
46789          checkModuleCompletion(list);
46790        });
46791      });
46792
46793      checkModuleCompletion(completions);
46794    }
46795
46796    if (document.readyState === "loading") {
46797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
46798    } else {
46799      initCompletionButtons();
46800    }
46801  })();
46802</script>
46802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="6528" data-end="8110" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Módulos de leer y escribir ficheros (fs)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>26 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-modulos-de-leer-y-escribir-ficheros-fs" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
46802<script>
46803  (() => {
46804    if (window.__courseLazyPlayersInitialized) return;
46805    window.__courseLazyPlayersInitialized = true;
46806
46807    function createPlayer(details) {
46808      const videoId = details.dataset.videoId;
46809      const start = details.dataset.start;
46810      const end = details.dataset.end;
46811
46812      const container = document.createElement("div");
46813      container.className = "player-container";
46814
46815      const spinner = document.createElement("div");
46816      spinner.className = "spinner";
46817      container.appendChild(spinner);
46818
46819      const controller = document.createElement("media-controller");
46820      controller.setAttribute("defaultsubtitles", "");
46821
46822      const provider = document.createElement("youtube-media-provider");
46823      provider.setAttribute("slot", "media");
46824      provider.setAttribute("video-id", videoId);
46825      provider.setAttribute("start", start);
46826      provider.setAttribute("end", end);
46827
46828      const controlBar = document.createElement("media-control-bar");
46829
46830      const playBtn = document.createElement("media-play-button");
46831      const timeDisplay = document.createElement("media-time-display");
46832      timeDisplay.setAttribute("showduration", "");
46833      const timeRange = document.createElement("media-time-range");
46834      const playbackRate = document.createElement("media-playback-rate-button");
46835      const muteBtn = document.createElement("media-mute-button");
46836      const volumeRange = document.createElement("media-volume-range");
46837
46838      const captionsBtn = document.createElement("media-captions-button");
46839      captionsBtn.addEventListener("click", (e) => {
46840        e.stopPropagation();
46841        const provider = container.querySelector("youtube-media-provider");
46842        if (provider) provider.toggleCaptions();
46843      });
46844
46845      const qualityBtn = document.createElement("media-chrome-button");
46846      qualityBtn.textContent = "--";
46847      qualityBtn.addEventListener("click", () => {
46848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
46849      });
46850
46851      const fullscreenBtn = document.createElement("media-fullscreen-button");
46852
46853      controlBar.append(
46854        playBtn, timeDisplay, timeRange,
46855        playbackRate, muteBtn, volumeRange,
46856        captionsBtn, qualityBtn, fullscreenBtn
46857      );
46858
46859      const poster = document.createElement("media-poster-image");
46860      poster.setAttribute("slot", "poster");
46861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
46862
46863      controller.append(provider, poster, controlBar);
46864
46865      captionsBtn.setAttribute("aria-checked", "true");
46866      controller.setAttribute("mediasubtitlesshowing", "on");
46867
46868      container.appendChild(controller);
46869      details.appendChild(container);
46870
46871      provider.addEventListener("waiting", () => container.classList.add("loading"));
46872      provider.addEventListener("playing", () => container.classList.remove("loading"));
46873      provider.addEventListener("play", () => container.classList.remove("loading"));
46874
46875      provider._textTracks.addEventListener("change", () => {
46876        const enabled = provider._captionsEnabled;
46877        captionsBtn.setAttribute("aria-checked", enabled);
46878        if (enabled) {
46879          controller.setAttribute("mediasubtitlesshowing", "on");
46880        } else {
46881          controller.removeAttribute("mediasubtitlesshowing");
46882        }
46883      });
46884
46885      provider.addEventListener("qualitychange", (e) => {
46886        qualityBtn.textContent = e.detail.quality;
46887      });
46888    }
46889
46890    function initLazyPlayers() {
46891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
46892        if (details.dataset.lazyInit) return;
46893        details.dataset.lazyInit = "true";
46894        details.addEventListener("toggle", () => {
46895          if (details.open && !details.querySelector(".player-container")) {
46896            createPlayer(details);
46897          } else if (!details.open) {
46898            const provider = details.querySelector("youtube-media-provider");
46899            if (provider && typeof provider.pause === "function") {
46900              provider.pause();
46901            }
46902          }
46903        });
46904      });
46905    }
46906
46907    if (document.readyState === "loading") {
46908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
46909    } else {
46910      initLazyPlayers();
46911    }
46912  })();
46913</script>
46913<script>
46914  (() => {
46915    if (window.__courseCompletionsInitialized) return;
46916    window.__courseCompletionsInitialized = true;
46917
46918    const STORAGE_KEY = "course-completions";
46919
46920    function slugify(text) {
46921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46922    }
46923
46924    function getCompletions() {
46925      try {
46926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
46927      } catch {
46928        return [];
46929      }
46930    }
46931
46932    function saveCompletions(completions) {
46933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
46934    }
46935
46936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
46937    function canonicalFromStored(storedId) {
46938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46939      if (!m) return slugify(storedId);
46940      const [, slug, p, mv, title] = m;
46941      return `${slug}-${p}-${mv}-${slugify(title)}`;
46942    }
46943
46944    function canonicalPlusOne(storedId) {
46945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
46946      if (!m) return null;
46947      const [, slug, p, mv, title] = m;
46948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
46949    }
46950
46951    function isCompleted(canonicalId, completions) {
46952      if (completions.includes(canonicalId)) return true;
46953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
46954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
46955    }
46956
46957    function migrateCompletions() {
46958      const completions = getCompletions();
46959      let changed = false;
46960      const migrated = completions.map((c) => {
46961        const canon = canonicalFromStored(c);
46962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
46963        const plusOne = canonicalPlusOne(c);
46964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
46965          if (plusOne !== c) changed = true;
46966          return plusOne;
46967        }
46968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
46969          changed = true;
46970          return canon;
46971        }
46972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
46973        if (canon !== c) {
46974          changed = true;
46975          return canon;
46976        }
46977        return c;
46978      });
46979      // Elimina duplicados tras migración
46980      const deduped = [...new Set(migrated)];
46981      if (changed || deduped.length !== completions.length) {
46982        saveCompletions(deduped);
46983        return deduped;
46984      }
46985      return completions;
46986    }
46987
46988    function updateButtonState(btn, completions) {
46989      const id = btn.dataset.completionId;
46990      const list = completions ?? getCompletions();
46991      btn.classList.toggle("completed", isCompleted(id, list));
46992    }
46993
46994    function checkModuleCompletion(completions) {
46995      const list = completions ?? getCompletions();
46996      document.querySelectorAll(".course-video").forEach((module) => {
46997        const btns = module.querySelectorAll(".complete-btn");
46998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
46999        const check = module.querySelector(".module-check");
47000        if (check) check.classList.toggle("visible", allCompleted);
47001      });
47002    }
47003
47004    function initCompletionButtons() {
47005      const completions = migrateCompletions();
47006
47007      document.querySelectorAll(".complete-btn").forEach((btn) => {
47008        if (btn.dataset.completionInit) return;
47009        btn.dataset.completionInit = "true";
47010        updateButtonState(btn, completions);
47011
47012        btn.addEventListener("click", (e) => {
47013          e.preventDefault();
47014          e.stopPropagation();
47015
47016          const id = btn.dataset.completionId;
47017          let list = getCompletions();
47018          const completed = isCompleted(id, list);
47019
47020          if (completed) {
47021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
47022            list = list.filter((c) =>
47022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
47023          } else {
47024            list.push(id);
47025          }
47026
47027          saveCompletions(list);
47028          updateButtonState(btn, list);
47029          checkModuleCompletion(list);
47030        });
47031      });
47032
47033      checkModuleCompletion(completions);
47034    }
47035
47036    if (document.readyState === "loading") {
47037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
47038    } else {
47039      initCompletionButtons();
47040    }
47041  })();
47042</script>
47042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="8110" data-end="8760" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Ejecutar en otros runtimes (deno, bun)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-ejecutar-en-otros-runtimes-deno-bun" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
47042<script>
47043  (() => {
47044    if (window.__courseLazyPlayersInitialized) return;
47045    window.__courseLazyPlayersInitialized = true;
47046
47047    function createPlayer(details) {
47048      const videoId = details.dataset.videoId;
47049      const start = details.dataset.start;
47050      const end = details.dataset.end;
47051
47052      const container = document.createElement("div");
47053      container.className = "player-container";
47054
47055      const spinner = document.createElement("div");
47056      spinner.className = "spinner";
47057      container.appendChild(spinner);
47058
47059      const controller = document.createElement("media-controller");
47060      controller.setAttribute("defaultsubtitles", "");
47061
47062      const provider = document.createElement("youtube-media-provider");
47063      provider.setAttribute("slot", "media");
47064      provider.setAttribute("video-id", videoId);
47065      provider.setAttribute("start", start);
47066      provider.setAttribute("end", end);
47067
47068      const controlBar = document.createElement("media-control-bar");
47069
47070      const playBtn = document.createElement("media-play-button");
47071      const timeDisplay = document.createElement("media-time-display");
47072      timeDisplay.setAttribute("showduration", "");
47073      const timeRange = document.createElement("media-time-range");
47074      const playbackRate = document.createElement("media-playback-rate-button");
47075      const muteBtn = document.createElement("media-mute-button");
47076      const volumeRange = document.createElement("media-volume-range");
47077
47078      const captionsBtn = document.createElement("media-captions-button");
47079      captionsBtn.addEventListener("click", (e) => {
47080        e.stopPropagation();
47081        const provider = container.querySelector("youtube-media-provider");
47082        if (provider) provider.toggleCaptions();
47083      });
47084
47085      const qualityBtn = document.createElement("media-chrome-button");
47086      qualityBtn.textContent = "--";
47087      qualityBtn.addEventListener("click", () => {
47088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
47089      });
47090
47091      const fullscreenBtn = document.createElement("media-fullscreen-button");
47092
47093      controlBar.append(
47094        playBtn, timeDisplay, timeRange,
47095        playbackRate, muteBtn, volumeRange,
47096        captionsBtn, qualityBtn, fullscreenBtn
47097      );
47098
47099      const poster = document.createElement("media-poster-image");
47100      poster.setAttribute("slot", "poster");
47101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
47102
47103      controller.append(provider, poster, controlBar);
47104
47105      captionsBtn.setAttribute("aria-checked", "true");
47106      controller.setAttribute("mediasubtitlesshowing", "on");
47107
47108      container.appendChild(controller);
47109      details.appendChild(container);
47110
47111      provider.addEventListener("waiting", () => container.classList.add("loading"));
47112      provider.addEventListener("playing", () => container.classList.remove("loading"));
47113      provider.addEventListener("play", () => container.classList.remove("loading"));
47114
47115      provider._textTracks.addEventListener("change", () => {
47116        const enabled = provider._captionsEnabled;
47117        captionsBtn.setAttribute("aria-checked", enabled);
47118        if (enabled) {
47119          controller.setAttribute("mediasubtitlesshowing", "on");
47120        } else {
47121          controller.removeAttribute("mediasubtitlesshowing");
47122        }
47123      });
47124
47125      provider.addEventListener("qualitychange", (e) => {
47126        qualityBtn.textContent = e.detail.quality;
47127      });
47128    }
47129
47130    function initLazyPlayers() {
47131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
47132        if (details.dataset.lazyInit) return;
47133        details.dataset.lazyInit = "true";
47134        details.addEventListener("toggle", () => {
47135          if (details.open && !details.querySelector(".player-container")) {
47136            createPlayer(details);
47137          } else if (!details.open) {
47138            const provider = details.querySelector("youtube-media-provider");
47139            if (provider && typeof provider.pause === "function") {
47140              provider.pause();
47141            }
47142          }
47143        });
47144      });
47145    }
47146
47147    if (document.readyState === "loading") {
47148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
47149    } else {
47150      initLazyPlayers();
47151    }
47152  })();
47153</script>
47153<script>
47154  (() => {
47155    if (window.__courseCompletionsInitialized) return;
47156    window.__courseCompletionsInitialized = true;
47157
47158    const STORAGE_KEY = "course-completions";
47159
47160    function slugify(text) {
47161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
47162    }
47163
47164    function getCompletions() {
47165      try {
47166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
47167      } catch {
47168        return [];
47169      }
47170    }
47171
47172    function saveCompletions(completions) {
47173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
47174    }
47175
47176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
47177    function canonicalFromStored(storedId) {
47178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47179      if (!m) return slugify(storedId);
47180      const [, slug, p, mv, title] = m;
47181      return `${slug}-${p}-${mv}-${slugify(title)}`;
47182    }
47183
47184    function canonicalPlusOne(storedId) {
47185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47186      if (!m) return null;
47187      const [, slug, p, mv, title] = m;
47188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
47189    }
47190
47191    function isCompleted(canonicalId, completions) {
47192      if (completions.includes(canonicalId)) return true;
47193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
47194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
47195    }
47196
47197    function migrateCompletions() {
47198      const completions = getCompletions();
47199      let changed = false;
47200      const migrated = completions.map((c) => {
47201        const canon = canonicalFromStored(c);
47202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
47203        const plusOne = canonicalPlusOne(c);
47204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
47205          if (plusOne !== c) changed = true;
47206          return plusOne;
47207        }
47208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
47209          changed = true;
47210          return canon;
47211        }
47212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
47213        if (canon !== c) {
47214          changed = true;
47215          return canon;
47216        }
47217        return c;
47218      });
47219      // Elimina duplicados tras migración
47220      const deduped = [...new Set(migrated)];
47221      if (changed || deduped.length !== completions.length) {
47222        saveCompletions(deduped);
47223        return deduped;
47224      }
47225      return completions;
47226    }
47227
47228    function updateButtonState(btn, completions) {
47229      const id = btn.dataset.completionId;
47230      const list = completions ?? getCompletions();
47231      btn.classList.toggle("completed", isCompleted(id, list));
47232    }
47233
47234    function checkModuleCompletion(completions) {
47235      const list = completions ?? getCompletions();
47236      document.querySelectorAll(".course-video").forEach((module) => {
47237        const btns = module.querySelectorAll(".complete-btn");
47238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
47239        const check = module.querySelector(".module-check");
47240        if (check) check.classList.toggle("visible", allCompleted);
47241      });
47242    }
47243
47244    function initCompletionButtons() {
47245      const completions = migrateCompletions();
47246
47247      document.querySelectorAll(".complete-btn").forEach((btn) => {
47248        if (btn.dataset.completionInit) return;
47249        btn.dataset.completionInit = "true";
47250        updateButtonState(btn, completions);
47251
47252        btn.addEventListener("click", (e) => {
47253          e.preventDefault();
47254          e.stopPropagation();
47255
47256          const id = btn.dataset.completionId;
47257          let list = getCompletions();
47258          const completed = isCompleted(id, list);
47259
47260          if (completed) {
47261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
47262            list = list.filter((c) =>
47262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
47263          } else {
47264            list.push(id);
47265          }
47266
47267          saveCompletions(list);
47268          updateButtonState(btn, list);
47269          checkModuleCompletion(list);
47270        });
47271      });
47272
47273      checkModuleCompletion(completions);
47274    }
47275
47276    if (document.readyState === "loading") {
47277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
47278    } else {
47279      initCompletionButtons();
47280    }
47281  })();
47282</script>
47282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="8760" data-end="10459" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Ejercicio: Clon del ls</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-ejercicio-clon-del-ls" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
47282<script>
47283  (() => {
47284    if (window.__courseLazyPlayersInitialized) return;
47285    window.__courseLazyPlayersInitialized = true;
47286
47287    function createPlayer(details) {
47288      const videoId = details.dataset.videoId;
47289      const start = details.dataset.start;
47290      const end = details.dataset.end;
47291
47292      const container = document.createElement("div");
47293      container.className = "player-container";
47294
47295      const spinner = document.createElement("div");
47296      spinner.className = "spinner";
47297      container.appendChild(spinner);
47298
47299      const controller = document.createElement("media-controller");
47300      controller.setAttribute("defaultsubtitles", "");
47301
47302      const provider = document.createElement("youtube-media-provider");
47303      provider.setAttribute("slot", "media");
47304      provider.setAttribute("video-id", videoId);
47305      provider.setAttribute("start", start);
47306      provider.setAttribute("end", end);
47307
47308      const controlBar = document.createElement("media-control-bar");
47309
47310      const playBtn = document.createElement("media-play-button");
47311      const timeDisplay = document.createElement("media-time-display");
47312      timeDisplay.setAttribute("showduration", "");
47313      const timeRange = document.createElement("media-time-range");
47314      const playbackRate = document.createElement("media-playback-rate-button");
47315      const muteBtn = document.createElement("media-mute-button");
47316      const volumeRange = document.createElement("media-volume-range");
47317
47318      const captionsBtn = document.createElement("media-captions-button");
47319      captionsBtn.addEventListener("click", (e) => {
47320        e.stopPropagation();
47321        const provider = container.querySelector("youtube-media-provider");
47322        if (provider) provider.toggleCaptions();
47323      });
47324
47325      const qualityBtn = document.createElement("media-chrome-button");
47326      qualityBtn.textContent = "--";
47327      qualityBtn.addEventListener("click", () => {
47328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
47329      });
47330
47331      const fullscreenBtn = document.createElement("media-fullscreen-button");
47332
47333      controlBar.append(
47334        playBtn, timeDisplay, timeRange,
47335        playbackRate, muteBtn, volumeRange,
47336        captionsBtn, qualityBtn, fullscreenBtn
47337      );
47338
47339      const poster = document.createElement("media-poster-image");
47340      poster.setAttribute("slot", "poster");
47341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
47342
47343      controller.append(provider, poster, controlBar);
47344
47345      captionsBtn.setAttribute("aria-checked", "true");
47346      controller.setAttribute("mediasubtitlesshowing", "on");
47347
47348      container.appendChild(controller);
47349      details.appendChild(container);
47350
47351      provider.addEventListener("waiting", () => container.classList.add("loading"));
47352      provider.addEventListener("playing", () => container.classList.remove("loading"));
47353      provider.addEventListener("play", () => container.classList.remove("loading"));
47354
47355      provider._textTracks.addEventListener("change", () => {
47356        const enabled = provider._captionsEnabled;
47357        captionsBtn.setAttribute("aria-checked", enabled);
47358        if (enabled) {
47359          controller.setAttribute("mediasubtitlesshowing", "on");
47360        } else {
47361          controller.removeAttribute("mediasubtitlesshowing");
47362        }
47363      });
47364
47365      provider.addEventListener("qualitychange", (e) => {
47366        qualityBtn.textContent = e.detail.quality;
47367      });
47368    }
47369
47370    function initLazyPlayers() {
47371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
47372        if (details.dataset.lazyInit) return;
47373        details.dataset.lazyInit = "true";
47374        details.addEventListener("toggle", () => {
47375          if (details.open && !details.querySelector(".player-container")) {
47376            createPlayer(details);
47377          } else if (!details.open) {
47378            const provider = details.querySelector("youtube-media-provider");
47379            if (provider && typeof provider.pause === "function") {
47380              provider.pause();
47381            }
47382          }
47383        });
47384      });
47385    }
47386
47387    if (document.readyState === "loading") {
47388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
47389    } else {
47390      initLazyPlayers();
47391    }
47392  })();
47393</script>
47393<script>
47394  (() => {
47395    if (window.__courseCompletionsInitialized) return;
47396    window.__courseCompletionsInitialized = true;
47397
47398    const STORAGE_KEY = "course-completions";
47399
47400    function slugify(text) {
47401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
47402    }
47403
47404    function getCompletions() {
47405      try {
47406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
47407      } catch {
47408        return [];
47409      }
47410    }
47411
47412    function saveCompletions(completions) {
47413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
47414    }
47415
47416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
47417    function canonicalFromStored(storedId) {
47418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47419      if (!m) return slugify(storedId);
47420      const [, slug, p, mv, title] = m;
47421      return `${slug}-${p}-${mv}-${slugify(title)}`;
47422    }
47423
47424    function canonicalPlusOne(storedId) {
47425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47426      if (!m) return null;
47427      const [, slug, p, mv, title] = m;
47428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
47429    }
47430
47431    function isCompleted(canonicalId, completions) {
47432      if (completions.includes(canonicalId)) return true;
47433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
47434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
47435    }
47436
47437    function migrateCompletions() {
47438      const completions = getCompletions();
47439      let changed = false;
47440      const migrated = completions.map((c) => {
47441        const canon = canonicalFromStored(c);
47442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
47443        const plusOne = canonicalPlusOne(c);
47444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
47445          if (plusOne !== c) changed = true;
47446          return plusOne;
47447        }
47448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
47449          changed = true;
47450          return canon;
47451        }
47452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
47453        if (canon !== c) {
47454          changed = true;
47455          return canon;
47456        }
47457        return c;
47458      });
47459      // Elimina duplicados tras migración
47460      const deduped = [...new Set(migrated)];
47461      if (changed || deduped.length !== completions.length) {
47462        saveCompletions(deduped);
47463        return deduped;
47464      }
47465      return completions;
47466    }
47467
47468    function updateButtonState(btn, completions) {
47469      const id = btn.dataset.completionId;
47470      const list = completions ?? getCompletions();
47471      btn.classList.toggle("completed", isCompleted(id, list));
47472    }
47473
47474    function checkModuleCompletion(completions) {
47475      const list = completions ?? getCompletions();
47476      document.querySelectorAll(".course-video").forEach((module) => {
47477        const btns = module.querySelectorAll(".complete-btn");
47478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
47479        const check = module.querySelector(".module-check");
47480        if (check) check.classList.toggle("visible", allCompleted);
47481      });
47482    }
47483
47484    function initCompletionButtons() {
47485      const completions = migrateCompletions();
47486
47487      document.querySelectorAll(".complete-btn").forEach((btn) => {
47488        if (btn.dataset.completionInit) return;
47489        btn.dataset.completionInit = "true";
47490        updateButtonState(btn, completions);
47491
47492        btn.addEventListener("click", (e) => {
47493          e.preventDefault();
47494          e.stopPropagation();
47495
47496          const id = btn.dataset.completionId;
47497          let list = getCompletions();
47498          const completed = isCompleted(id, list);
47499
47500          if (completed) {
47501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
47502            list = list.filter((c) =>
47502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
47503          } else {
47504            list.push(id);
47505          }
47506
47507          saveCompletions(list);
47508          updateButtonState(btn, list);
47509          checkModuleCompletion(list);
47510        });
47511      });
47512
47513      checkModuleCompletion(completions);
47514    }
47515
47516    if (document.readyState === "loading") {
47517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
47518    } else {
47519      initCompletionButtons();
47520    }
47521  })();
47522</script>
47522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="10980" data-end="13020" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Ejercicio: Compresor</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>34 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-ejercicio-compresor" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
47522<script>
47523  (() => {
47524    if (window.__courseLazyPlayersInitialized) return;
47525    window.__courseLazyPlayersInitialized = true;
47526
47527    function createPlayer(details) {
47528      const videoId = details.dataset.videoId;
47529      const start = details.dataset.start;
47530      const end = details.dataset.end;
47531
47532      const container = document.createElement("div");
47533      container.className = "player-container";
47534
47535      const spinner = document.createElement("div");
47536      spinner.className = "spinner";
47537      container.appendChild(spinner);
47538
47539      const controller = document.createElement("media-controller");
47540      controller.setAttribute("defaultsubtitles", "");
47541
47542      const provider = document.createElement("youtube-media-provider");
47543      provider.setAttribute("slot", "media");
47544      provider.setAttribute("video-id", videoId);
47545      provider.setAttribute("start", start);
47546      provider.setAttribute("end", end);
47547
47548      const controlBar = document.createElement("media-control-bar");
47549
47550      const playBtn = document.createElement("media-play-button");
47551      const timeDisplay = document.createElement("media-time-display");
47552      timeDisplay.setAttribute("showduration", "");
47553      const timeRange = document.createElement("media-time-range");
47554      const playbackRate = document.createElement("media-playback-rate-button");
47555      const muteBtn = document.createElement("media-mute-button");
47556      const volumeRange = document.createElement("media-volume-range");
47557
47558      const captionsBtn = document.createElement("media-captions-button");
47559      captionsBtn.addEventListener("click", (e) => {
47560        e.stopPropagation();
47561        const provider = container.querySelector("youtube-media-provider");
47562        if (provider) provider.toggleCaptions();
47563      });
47564
47565      const qualityBtn = document.createElement("media-chrome-button");
47566      qualityBtn.textContent = "--";
47567      qualityBtn.addEventListener("click", () => {
47568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
47569      });
47570
47571      const fullscreenBtn = document.createElement("media-fullscreen-button");
47572
47573      controlBar.append(
47574        playBtn, timeDisplay, timeRange,
47575        playbackRate, muteBtn, volumeRange,
47576        captionsBtn, qualityBtn, fullscreenBtn
47577      );
47578
47579      const poster = document.createElement("media-poster-image");
47580      poster.setAttribute("slot", "poster");
47581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
47582
47583      controller.append(provider, poster, controlBar);
47584
47585      captionsBtn.setAttribute("aria-checked", "true");
47586      controller.setAttribute("mediasubtitlesshowing", "on");
47587
47588      container.appendChild(controller);
47589      details.appendChild(container);
47590
47591      provider.addEventListener("waiting", () => container.classList.add("loading"));
47592      provider.addEventListener("playing", () => container.classList.remove("loading"));
47593      provider.addEventListener("play", () => container.classList.remove("loading"));
47594
47595      provider._textTracks.addEventListener("change", () => {
47596        const enabled = provider._captionsEnabled;
47597        captionsBtn.setAttribute("aria-checked", enabled);
47598        if (enabled) {
47599          controller.setAttribute("mediasubtitlesshowing", "on");
47600        } else {
47601          controller.removeAttribute("mediasubtitlesshowing");
47602        }
47603      });
47604
47605      provider.addEventListener("qualitychange", (e) => {
47606        qualityBtn.textContent = e.detail.quality;
47607      });
47608    }
47609
47610    function initLazyPlayers() {
47611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
47612        if (details.dataset.lazyInit) return;
47613        details.dataset.lazyInit = "true";
47614        details.addEventListener("toggle", () => {
47615          if (details.open && !details.querySelector(".player-container")) {
47616            createPlayer(details);
47617          } else if (!details.open) {
47618            const provider = details.querySelector("youtube-media-provider");
47619            if (provider && typeof provider.pause === "function") {
47620              provider.pause();
47621            }
47622          }
47623        });
47624      });
47625    }
47626
47627    if (document.readyState === "loading") {
47628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
47629    } else {
47630      initLazyPlayers();
47631    }
47632  })();
47633</script>
47633<script>
47634  (() => {
47635    if (window.__courseCompletionsInitialized) return;
47636    window.__courseCompletionsInitialized = true;
47637
47638    const STORAGE_KEY = "course-completions";
47639
47640    function slugify(text) {
47641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
47642    }
47643
47644    function getCompletions() {
47645      try {
47646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
47647      } catch {
47648        return [];
47649      }
47650    }
47651
47652    function saveCompletions(completions) {
47653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
47654    }
47655
47656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
47657    function canonicalFromStored(storedId) {
47658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47659      if (!m) return slugify(storedId);
47660      const [, slug, p, mv, title] = m;
47661      return `${slug}-${p}-${mv}-${slugify(title)}`;
47662    }
47663
47664    function canonicalPlusOne(storedId) {
47665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47666      if (!m) return null;
47667      const [, slug, p, mv, title] = m;
47668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
47669    }
47670
47671    function isCompleted(canonicalId, completions) {
47672      if (completions.includes(canonicalId)) return true;
47673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
47674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
47675    }
47676
47677    function migrateCompletions() {
47678      const completions = getCompletions();
47679      let changed = false;
47680      const migrated = completions.map((c) => {
47681        const canon = canonicalFromStored(c);
47682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
47683        const plusOne = canonicalPlusOne(c);
47684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
47685          if (plusOne !== c) changed = true;
47686          return plusOne;
47687        }
47688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
47689          changed = true;
47690          return canon;
47691        }
47692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
47693        if (canon !== c) {
47694          changed = true;
47695          return canon;
47696        }
47697        return c;
47698      });
47699      // Elimina duplicados tras migración
47700      const deduped = [...new Set(migrated)];
47701      if (changed || deduped.length !== completions.length) {
47702        saveCompletions(deduped);
47703        return deduped;
47704      }
47705      return completions;
47706    }
47707
47708    function updateButtonState(btn, completions) {
47709      const id = btn.dataset.completionId;
47710      const list = completions ?? getCompletions();
47711      btn.classList.toggle("completed", isCompleted(id, list));
47712    }
47713
47714    function checkModuleCompletion(completions) {
47715      const list = completions ?? getCompletions();
47716      document.querySelectorAll(".course-video").forEach((module) => {
47717        const btns = module.querySelectorAll(".complete-btn");
47718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
47719        const check = module.querySelector(".module-check");
47720        if (check) check.classList.toggle("visible", allCompleted);
47721      });
47722    }
47723
47724    function initCompletionButtons() {
47725      const completions = migrateCompletions();
47726
47727      document.querySelectorAll(".complete-btn").forEach((btn) => {
47728        if (btn.dataset.completionInit) return;
47729        btn.dataset.completionInit = "true";
47730        updateButtonState(btn, completions);
47731
47732        btn.addEventListener("click", (e) => {
47733          e.preventDefault();
47734          e.stopPropagation();
47735
47736          const id = btn.dataset.completionId;
47737          let list = getCompletions();
47738          const completed = isCompleted(id, list);
47739
47740          if (completed) {
47741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
47742            list = list.filter((c) =>
47742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
47743          } else {
47744            list.push(id);
47745          }
47746
47747          saveCompletions(list);
47748          updateButtonState(btn, list);
47749          checkModuleCompletion(list);
47750        });
47751      });
47752
47753      checkModuleCompletion(completions);
47754    }
47755
47756    if (document.readyState === "loading") {
47757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
47758    } else {
47759      initCompletionButtons();
47760    }
47761  })();
47762</script>
47762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="13020" data-end="15900" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Ejercicio: Servidor HTTP básico</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>48 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-ejercicio-servidor-http-basico" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
47762<script>
47763  (() => {
47764    if (window.__courseLazyPlayersInitialized) return;
47765    window.__courseLazyPlayersInitialized = true;
47766
47767    function createPlayer(details) {
47768      const videoId = details.dataset.videoId;
47769      const start = details.dataset.start;
47770      const end = details.dataset.end;
47771
47772      const container = document.createElement("div");
47773      container.className = "player-container";
47774
47775      const spinner = document.createElement("div");
47776      spinner.className = "spinner";
47777      container.appendChild(spinner);
47778
47779      const controller = document.createElement("media-controller");
47780      controller.setAttribute("defaultsubtitles", "");
47781
47782      const provider = document.createElement("youtube-media-provider");
47783      provider.setAttribute("slot", "media");
47784      provider.setAttribute("video-id", videoId);
47785      provider.setAttribute("start", start);
47786      provider.setAttribute("end", end);
47787
47788      const controlBar = document.createElement("media-control-bar");
47789
47790      const playBtn = document.createElement("media-play-button");
47791      const timeDisplay = document.createElement("media-time-display");
47792      timeDisplay.setAttribute("showduration", "");
47793      const timeRange = document.createElement("media-time-range");
47794      const playbackRate = document.createElement("media-playback-rate-button");
47795      const muteBtn = document.createElement("media-mute-button");
47796      const volumeRange = document.createElement("media-volume-range");
47797
47798      const captionsBtn = document.createElement("media-captions-button");
47799      captionsBtn.addEventListener("click", (e) => {
47800        e.stopPropagation();
47801        const provider = container.querySelector("youtube-media-provider");
47802        if (provider) provider.toggleCaptions();
47803      });
47804
47805      const qualityBtn = document.createElement("media-chrome-button");
47806      qualityBtn.textContent = "--";
47807      qualityBtn.addEventListener("click", () => {
47808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
47809      });
47810
47811      const fullscreenBtn = document.createElement("media-fullscreen-button");
47812
47813      controlBar.append(
47814        playBtn, timeDisplay, timeRange,
47815        playbackRate, muteBtn, volumeRange,
47816        captionsBtn, qualityBtn, fullscreenBtn
47817      );
47818
47819      const poster = document.createElement("media-poster-image");
47820      poster.setAttribute("slot", "poster");
47821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
47822
47823      controller.append(provider, poster, controlBar);
47824
47825      captionsBtn.setAttribute("aria-checked", "true");
47826      controller.setAttribute("mediasubtitlesshowing", "on");
47827
47828      container.appendChild(controller);
47829      details.appendChild(container);
47830
47831      provider.addEventListener("waiting", () => container.classList.add("loading"));
47832      provider.addEventListener("playing", () => container.classList.remove("loading"));
47833      provider.addEventListener("play", () => container.classList.remove("loading"));
47834
47835      provider._textTracks.addEventListener("change", () => {
47836        const enabled = provider._captionsEnabled;
47837        captionsBtn.setAttribute("aria-checked", enabled);
47838        if (enabled) {
47839          controller.setAttribute("mediasubtitlesshowing", "on");
47840        } else {
47841          controller.removeAttribute("mediasubtitlesshowing");
47842        }
47843      });
47844
47845      provider.addEventListener("qualitychange", (e) => {
47846        qualityBtn.textContent = e.detail.quality;
47847      });
47848    }
47849
47850    function initLazyPlayers() {
47851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
47852        if (details.dataset.lazyInit) return;
47853        details.dataset.lazyInit = "true";
47854        details.addEventListener("toggle", () => {
47855          if (details.open && !details.querySelector(".player-container")) {
47856            createPlayer(details);
47857          } else if (!details.open) {
47858            const provider = details.querySelector("youtube-media-provider");
47859            if (provider && typeof provider.pause === "function") {
47860              provider.pause();
47861            }
47862          }
47863        });
47864      });
47865    }
47866
47867    if (document.readyState === "loading") {
47868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
47869    } else {
47870      initLazyPlayers();
47871    }
47872  })();
47873</script>
47873<script>
47874  (() => {
47875    if (window.__courseCompletionsInitialized) return;
47876    window.__courseCompletionsInitialized = true;
47877
47878    const STORAGE_KEY = "course-completions";
47879
47880    function slugify(text) {
47881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
47882    }
47883
47884    function getCompletions() {
47885      try {
47886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
47887      } catch {
47888        return [];
47889      }
47890    }
47891
47892    function saveCompletions(completions) {
47893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
47894    }
47895
47896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
47897    function canonicalFromStored(storedId) {
47898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47899      if (!m) return slugify(storedId);
47900      const [, slug, p, mv, title] = m;
47901      return `${slug}-${p}-${mv}-${slugify(title)}`;
47902    }
47903
47904    function canonicalPlusOne(storedId) {
47905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
47906      if (!m) return null;
47907      const [, slug, p, mv, title] = m;
47908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
47909    }
47910
47911    function isCompleted(canonicalId, completions) {
47912      if (completions.includes(canonicalId)) return true;
47913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
47914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
47915    }
47916
47917    function migrateCompletions() {
47918      const completions = getCompletions();
47919      let changed = false;
47920      const migrated = completions.map((c) => {
47921        const canon = canonicalFromStored(c);
47922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
47923        const plusOne = canonicalPlusOne(c);
47924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
47925          if (plusOne !== c) changed = true;
47926          return plusOne;
47927        }
47928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
47929          changed = true;
47930          return canon;
47931        }
47932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
47933        if (canon !== c) {
47934          changed = true;
47935          return canon;
47936        }
47937        return c;
47938      });
47939      // Elimina duplicados tras migración
47940      const deduped = [...new Set(migrated)];
47941      if (changed || deduped.length !== completions.length) {
47942        saveCompletions(deduped);
47943        return deduped;
47944      }
47945      return completions;
47946    }
47947
47948    function updateButtonState(btn, completions) {
47949      const id = btn.dataset.completionId;
47950      const list = completions ?? getCompletions();
47951      btn.classList.toggle("completed", isCompleted(id, list));
47952    }
47953
47954    function checkModuleCompletion(completions) {
47955      const list = completions ?? getCompletions();
47956      document.querySelectorAll(".course-video").forEach((module) => {
47957        const btns = module.querySelectorAll(".complete-btn");
47958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
47959        const check = module.querySelector(".module-check");
47960        if (check) check.classList.toggle("visible", allCompleted);
47961      });
47962    }
47963
47964    function initCompletionButtons() {
47965      const completions = migrateCompletions();
47966
47967      document.querySelectorAll(".complete-btn").forEach((btn) => {
47968        if (btn.dataset.completionInit) return;
47969        btn.dataset.completionInit = "true";
47970        updateButtonState(btn, completions);
47971
47972        btn.addEventListener("click", (e) => {
47973          e.preventDefault();
47974          e.stopPropagation();
47975
47976          const id = btn.dataset.completionId;
47977          let list = getCompletions();
47978          const completed = isCompleted(id, list);
47979
47980          if (completed) {
47981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
47982            list = list.filter((c) =>
47982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
47983          } else {
47984            list.push(id);
47985          }
47986
47987          saveCompletions(list);
47988          updateButtonState(btn, list);
47989          checkModuleCompletion(list);
47990        });
47991      });
47992
47993      checkModuleCompletion(completions);
47994    }
47995
47996    if (document.readyState === "loading") {
47997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
47998    } else {
47999      initCompletionButtons();
48000    }
48001  })();
48002</script>
48002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="5CizcdH_Mwk" data-start="15900" data-end="17197" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Primicia</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>22 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-1-primicia" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
48002<script>
48003  (() => {
48004    if (window.__courseLazyPlayersInitialized) return;
48005    window.__courseLazyPlayersInitialized = true;
48006
48007    function createPlayer(details) {
48008      const videoId = details.dataset.videoId;
48009      const start = details.dataset.start;
48010      const end = details.dataset.end;
48011
48012      const container = document.createElement("div");
48013      container.className = "player-container";
48014
48015      const spinner = document.createElement("div");
48016      spinner.className = "spinner";
48017      container.appendChild(spinner);
48018
48019      const controller = document.createElement("media-controller");
48020      controller.setAttribute("defaultsubtitles", "");
48021
48022      const provider = document.createElement("youtube-media-provider");
48023      provider.setAttribute("slot", "media");
48024      provider.setAttribute("video-id", videoId);
48025      provider.setAttribute("start", start);
48026      provider.setAttribute("end", end);
48027
48028      const controlBar = document.createElement("media-control-bar");
48029
48030      const playBtn = document.createElement("media-play-button");
48031      const timeDisplay = document.createElement("media-time-display");
48032      timeDisplay.setAttribute("showduration", "");
48033      const timeRange = document.createElement("media-time-range");
48034      const playbackRate = document.createElement("media-playback-rate-button");
48035      const muteBtn = document.createElement("media-mute-button");
48036      const volumeRange = document.createElement("media-volume-range");
48037
48038      const captionsBtn = document.createElement("media-captions-button");
48039      captionsBtn.addEventListener("click", (e) => {
48040        e.stopPropagation();
48041        const provider = container.querySelector("youtube-media-provider");
48042        if (provider) provider.toggleCaptions();
48043      });
48044
48045      const qualityBtn = document.createElement("media-chrome-button");
48046      qualityBtn.textContent = "--";
48047      qualityBtn.addEventListener("click", () => {
48048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
48049      });
48050
48051      const fullscreenBtn = document.createElement("media-fullscreen-button");
48052
48053      controlBar.append(
48054        playBtn, timeDisplay, timeRange,
48055        playbackRate, muteBtn, volumeRange,
48056        captionsBtn, qualityBtn, fullscreenBtn
48057      );
48058
48059      const poster = document.createElement("media-poster-image");
48060      poster.setAttribute("slot", "poster");
48061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
48062
48063      controller.append(provider, poster, controlBar);
48064
48065      captionsBtn.setAttribute("aria-checked", "true");
48066      controller.setAttribute("mediasubtitlesshowing", "on");
48067
48068      container.appendChild(controller);
48069      details.appendChild(container);
48070
48071      provider.addEventListener("waiting", () => container.classList.add("loading"));
48072      provider.addEventListener("playing", () => container.classList.remove("loading"));
48073      provider.addEventListener("play", () => container.classList.remove("loading"));
48074
48075      provider._textTracks.addEventListener("change", () => {
48076        const enabled = provider._captionsEnabled;
48077        captionsBtn.setAttribute("aria-checked", enabled);
48078        if (enabled) {
48079          controller.setAttribute("mediasubtitlesshowing", "on");
48080        } else {
48081          controller.removeAttribute("mediasubtitlesshowing");
48082        }
48083      });
48084
48085      provider.addEventListener("qualitychange", (e) => {
48086        qualityBtn.textContent = e.detail.quality;
48087      });
48088    }
48089
48090    function initLazyPlayers() {
48091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
48092        if (details.dataset.lazyInit) return;
48093        details.dataset.lazyInit = "true";
48094        details.addEventListener("toggle", () => {
48095          if (details.open && !details.querySelector(".player-container")) {
48096            createPlayer(details);
48097          } else if (!details.open) {
48098            const provider = details.querySelector("youtube-media-provider");
48099            if (provider && typeof provider.pause === "function") {
48100              provider.pause();
48101            }
48102          }
48103        });
48104      });
48105    }
48106
48107    if (document.readyState === "loading") {
48108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
48109    } else {
48110      initLazyPlayers();
48111    }
48112  })();
48113</script>
48113<script>
48114  (() => {
48115    if (window.__courseCompletionsInitialized) return;
48116    window.__courseCompletionsInitialized = true;
48117
48118    const STORAGE_KEY = "course-completions";
48119
48120    function slugify(text) {
48121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
48122    }
48123
48124    function getCompletions() {
48125      try {
48126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
48127      } catch {
48128        return [];
48129      }
48130    }
48131
48132    function saveCompletions(completions) {
48133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
48134    }
48135
48136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
48137    function canonicalFromStored(storedId) {
48138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48139      if (!m) return slugify(storedId);
48140      const [, slug, p, mv, title] = m;
48141      return `${slug}-${p}-${mv}-${slugify(title)}`;
48142    }
48143
48144    function canonicalPlusOne(storedId) {
48145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48146      if (!m) return null;
48147      const [, slug, p, mv, title] = m;
48148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
48149    }
48150
48151    function isCompleted(canonicalId, completions) {
48152      if (completions.includes(canonicalId)) return true;
48153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
48154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
48155    }
48156
48157    function migrateCompletions() {
48158      const completions = getCompletions();
48159      let changed = false;
48160      const migrated = completions.map((c) => {
48161        const canon = canonicalFromStored(c);
48162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
48163        const plusOne = canonicalPlusOne(c);
48164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
48165          if (plusOne !== c) changed = true;
48166          return plusOne;
48167        }
48168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
48169          changed = true;
48170          return canon;
48171        }
48172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
48173        if (canon !== c) {
48174          changed = true;
48175          return canon;
48176        }
48177        return c;
48178      });
48179      // Elimina duplicados tras migración
48180      const deduped = [...new Set(migrated)];
48181      if (changed || deduped.length !== completions.length) {
48182        saveCompletions(deduped);
48183        return deduped;
48184      }
48185      return completions;
48186    }
48187
48188    function updateButtonState(btn, completions) {
48189      const id = btn.dataset.completionId;
48190      const list = completions ?? getCompletions();
48191      btn.classList.toggle("completed", isCompleted(id, list));
48192    }
48193
48194    function checkModuleCompletion(completions) {
48195      const list = completions ?? getCompletions();
48196      document.querySelectorAll(".course-video").forEach((module) => {
48197        const btns = module.querySelectorAll(".complete-btn");
48198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
48199        const check = module.querySelector(".module-check");
48200        if (check) check.classList.toggle("visible", allCompleted);
48201      });
48202    }
48203
48204    function initCompletionButtons() {
48205      const completions = migrateCompletions();
48206
48207      document.querySelectorAll(".complete-btn").forEach((btn) => {
48208        if (btn.dataset.completionInit) return;
48209        btn.dataset.completionInit = "true";
48210        updateButtonState(btn, completions);
48211
48212        btn.addEventListener("click", (e) => {
48213          e.preventDefault();
48214          e.stopPropagation();
48215
48216          const id = btn.dataset.completionId;
48217          let list = getCompletions();
48218          const completed = isCompleted(id, list);
48219
48220          if (completed) {
48221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
48222            list = list.filter((c) =>
48222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
48223          } else {
48224            list.push(id);
48225          }
48226
48227          saveCompletions(list);
48228          updateButtonState(btn, list);
48229          checkModuleCompletion(list);
48230        });
48231      });
48232
48233      checkModuleCompletion(completions);
48234    }
48235
48236    if (document.readyState === "loading") {
48237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
48238    } else {
48239      initCompletionButtons();
48240    }
48241  })();
48242</script>
48242</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 18 📗 NodeJS + Express<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="1440" data-end="1920" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>8 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-introduccion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
48242<script>
48243  (() => {
48244    if (window.__courseLazyPlayersInitialized) return;
48245    window.__courseLazyPlayersInitialized = true;
48246
48247    function createPlayer(details) {
48248      const videoId = details.dataset.videoId;
48249      const start = details.dataset.start;
48250      const end = details.dataset.end;
48251
48252      const container = document.createElement("div");
48253      container.className = "player-container";
48254
48255      const spinner = document.createElement("div");
48256      spinner.className = "spinner";
48257      container.appendChild(spinner);
48258
48259      const controller = document.createElement("media-controller");
48260      controller.setAttribute("defaultsubtitles", "");
48261
48262      const provider = document.createElement("youtube-media-provider");
48263      provider.setAttribute("slot", "media");
48264      provider.setAttribute("video-id", videoId);
48265      provider.setAttribute("start", start);
48266      provider.setAttribute("end", end);
48267
48268      const controlBar = document.createElement("media-control-bar");
48269
48270      const playBtn = document.createElement("media-play-button");
48271      const timeDisplay = document.createElement("media-time-display");
48272      timeDisplay.setAttribute("showduration", "");
48273      const timeRange = document.createElement("media-time-range");
48274      const playbackRate = document.createElement("media-playback-rate-button");
48275      const muteBtn = document.createElement("media-mute-button");
48276      const volumeRange = document.createElement("media-volume-range");
48277
48278      const captionsBtn = document.createElement("media-captions-button");
48279      captionsBtn.addEventListener("click", (e) => {
48280        e.stopPropagation();
48281        const provider = container.querySelector("youtube-media-provider");
48282        if (provider) provider.toggleCaptions();
48283      });
48284
48285      const qualityBtn = document.createElement("media-chrome-button");
48286      qualityBtn.textContent = "--";
48287      qualityBtn.addEventListener("click", () => {
48288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
48289      });
48290
48291      const fullscreenBtn = document.createElement("media-fullscreen-button");
48292
48293      controlBar.append(
48294        playBtn, timeDisplay, timeRange,
48295        playbackRate, muteBtn, volumeRange,
48296        captionsBtn, qualityBtn, fullscreenBtn
48297      );
48298
48299      const poster = document.createElement("media-poster-image");
48300      poster.setAttribute("slot", "poster");
48301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
48302
48303      controller.append(provider, poster, controlBar);
48304
48305      captionsBtn.setAttribute("aria-checked", "true");
48306      controller.setAttribute("mediasubtitlesshowing", "on");
48307
48308      container.appendChild(controller);
48309      details.appendChild(container);
48310
48311      provider.addEventListener("waiting", () => container.classList.add("loading"));
48312      provider.addEventListener("playing", () => container.classList.remove("loading"));
48313      provider.addEventListener("play", () => container.classList.remove("loading"));
48314
48315      provider._textTracks.addEventListener("change", () => {
48316        const enabled = provider._captionsEnabled;
48317        captionsBtn.setAttribute("aria-checked", enabled);
48318        if (enabled) {
48319          controller.setAttribute("mediasubtitlesshowing", "on");
48320        } else {
48321          controller.removeAttribute("mediasubtitlesshowing");
48322        }
48323      });
48324
48325      provider.addEventListener("qualitychange", (e) => {
48326        qualityBtn.textContent = e.detail.quality;
48327      });
48328    }
48329
48330    function initLazyPlayers() {
48331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
48332        if (details.dataset.lazyInit) return;
48333        details.dataset.lazyInit = "true";
48334        details.addEventListener("toggle", () => {
48335          if (details.open && !details.querySelector(".player-container")) {
48336            createPlayer(details);
48337          } else if (!details.open) {
48338            const provider = details.querySelector("youtube-media-provider");
48339            if (provider && typeof provider.pause === "function") {
48340              provider.pause();
48341            }
48342          }
48343        });
48344      });
48345    }
48346
48347    if (document.readyState === "loading") {
48348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
48349    } else {
48350      initLazyPlayers();
48351    }
48352  })();
48353</script>
48353<script>
48354  (() => {
48355    if (window.__courseCompletionsInitialized) return;
48356    window.__courseCompletionsInitialized = true;
48357
48358    const STORAGE_KEY = "course-completions";
48359
48360    function slugify(text) {
48361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
48362    }
48363
48364    function getCompletions() {
48365      try {
48366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
48367      } catch {
48368        return [];
48369      }
48370    }
48371
48372    function saveCompletions(completions) {
48373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
48374    }
48375
48376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
48377    function canonicalFromStored(storedId) {
48378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48379      if (!m) return slugify(storedId);
48380      const [, slug, p, mv, title] = m;
48381      return `${slug}-${p}-${mv}-${slugify(title)}`;
48382    }
48383
48384    function canonicalPlusOne(storedId) {
48385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48386      if (!m) return null;
48387      const [, slug, p, mv, title] = m;
48388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
48389    }
48390
48391    function isCompleted(canonicalId, completions) {
48392      if (completions.includes(canonicalId)) return true;
48393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
48394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
48395    }
48396
48397    function migrateCompletions() {
48398      const completions = getCompletions();
48399      let changed = false;
48400      const migrated = completions.map((c) => {
48401        const canon = canonicalFromStored(c);
48402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
48403        const plusOne = canonicalPlusOne(c);
48404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
48405          if (plusOne !== c) changed = true;
48406          return plusOne;
48407        }
48408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
48409          changed = true;
48410          return canon;
48411        }
48412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
48413        if (canon !== c) {
48414          changed = true;
48415          return canon;
48416        }
48417        return c;
48418      });
48419      // Elimina duplicados tras migración
48420      const deduped = [...new Set(migrated)];
48421      if (changed || deduped.length !== completions.length) {
48422        saveCompletions(deduped);
48423        return deduped;
48424      }
48425      return completions;
48426    }
48427
48428    function updateButtonState(btn, completions) {
48429      const id = btn.dataset.completionId;
48430      const list = completions ?? getCompletions();
48431      btn.classList.toggle("completed", isCompleted(id, list));
48432    }
48433
48434    function checkModuleCompletion(completions) {
48435      const list = completions ?? getCompletions();
48436      document.querySelectorAll(".course-video").forEach((module) => {
48437        const btns = module.querySelectorAll(".complete-btn");
48438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
48439        const check = module.querySelector(".module-check");
48440        if (check) check.classList.toggle("visible", allCompleted);
48441      });
48442    }
48443
48444    function initCompletionButtons() {
48445      const completions = migrateCompletions();
48446
48447      document.querySelectorAll(".complete-btn").forEach((btn) => {
48448        if (btn.dataset.completionInit) return;
48449        btn.dataset.completionInit = "true";
48450        updateButtonState(btn, completions);
48451
48452        btn.addEventListener("click", (e) => {
48453          e.preventDefault();
48454          e.stopPropagation();
48455
48456          const id = btn.dataset.completionId;
48457          let list = getCompletions();
48458          const completed = isCompleted(id, list);
48459
48460          if (completed) {
48461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
48462            list = list.filter((c) =>
48462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
48463          } else {
48464            list.push(id);
48465          }
48466
48467          saveCompletions(list);
48468          updateButtonState(btn, list);
48469          checkModuleCompletion(list);
48470        });
48471      });
48472
48473      checkModuleCompletion(completions);
48474    }
48475
48476    if (document.readyState === "loading") {
48477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
48478    } else {
48479      initCompletionButtons();
48480    }
48481  })();
48482</script>
48482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="1920" data-end="5260" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Cabeceras HTTP</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>56 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-cabeceras-http" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
48482<script>
48483  (() => {
48484    if (window.__courseLazyPlayersInitialized) return;
48485    window.__courseLazyPlayersInitialized = true;
48486
48487    function createPlayer(details) {
48488      const videoId = details.dataset.videoId;
48489      const start = details.dataset.start;
48490      const end = details.dataset.end;
48491
48492      const container = document.createElement("div");
48493      container.className = "player-container";
48494
48495      const spinner = document.createElement("div");
48496      spinner.className = "spinner";
48497      container.appendChild(spinner);
48498
48499      const controller = document.createElement("media-controller");
48500      controller.setAttribute("defaultsubtitles", "");
48501
48502      const provider = document.createElement("youtube-media-provider");
48503      provider.setAttribute("slot", "media");
48504      provider.setAttribute("video-id", videoId);
48505      provider.setAttribute("start", start);
48506      provider.setAttribute("end", end);
48507
48508      const controlBar = document.createElement("media-control-bar");
48509
48510      const playBtn = document.createElement("media-play-button");
48511      const timeDisplay = document.createElement("media-time-display");
48512      timeDisplay.setAttribute("showduration", "");
48513      const timeRange = document.createElement("media-time-range");
48514      const playbackRate = document.createElement("media-playback-rate-button");
48515      const muteBtn = document.createElement("media-mute-button");
48516      const volumeRange = document.createElement("media-volume-range");
48517
48518      const captionsBtn = document.createElement("media-captions-button");
48519      captionsBtn.addEventListener("click", (e) => {
48520        e.stopPropagation();
48521        const provider = container.querySelector("youtube-media-provider");
48522        if (provider) provider.toggleCaptions();
48523      });
48524
48525      const qualityBtn = document.createElement("media-chrome-button");
48526      qualityBtn.textContent = "--";
48527      qualityBtn.addEventListener("click", () => {
48528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
48529      });
48530
48531      const fullscreenBtn = document.createElement("media-fullscreen-button");
48532
48533      controlBar.append(
48534        playBtn, timeDisplay, timeRange,
48535        playbackRate, muteBtn, volumeRange,
48536        captionsBtn, qualityBtn, fullscreenBtn
48537      );
48538
48539      const poster = document.createElement("media-poster-image");
48540      poster.setAttribute("slot", "poster");
48541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
48542
48543      controller.append(provider, poster, controlBar);
48544
48545      captionsBtn.setAttribute("aria-checked", "true");
48546      controller.setAttribute("mediasubtitlesshowing", "on");
48547
48548      container.appendChild(controller);
48549      details.appendChild(container);
48550
48551      provider.addEventListener("waiting", () => container.classList.add("loading"));
48552      provider.addEventListener("playing", () => container.classList.remove("loading"));
48553      provider.addEventListener("play", () => container.classList.remove("loading"));
48554
48555      provider._textTracks.addEventListener("change", () => {
48556        const enabled = provider._captionsEnabled;
48557        captionsBtn.setAttribute("aria-checked", enabled);
48558        if (enabled) {
48559          controller.setAttribute("mediasubtitlesshowing", "on");
48560        } else {
48561          controller.removeAttribute("mediasubtitlesshowing");
48562        }
48563      });
48564
48565      provider.addEventListener("qualitychange", (e) => {
48566        qualityBtn.textContent = e.detail.quality;
48567      });
48568    }
48569
48570    function initLazyPlayers() {
48571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
48572        if (details.dataset.lazyInit) return;
48573        details.dataset.lazyInit = "true";
48574        details.addEventListener("toggle", () => {
48575          if (details.open && !details.querySelector(".player-container")) {
48576            createPlayer(details);
48577          } else if (!details.open) {
48578            const provider = details.querySelector("youtube-media-provider");
48579            if (provider && typeof provider.pause === "function") {
48580              provider.pause();
48581            }
48582          }
48583        });
48584      });
48585    }
48586
48587    if (document.readyState === "loading") {
48588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
48589    } else {
48590      initLazyPlayers();
48591    }
48592  })();
48593</script>
48593<script>
48594  (() => {
48595    if (window.__courseCompletionsInitialized) return;
48596    window.__courseCompletionsInitialized = true;
48597
48598    const STORAGE_KEY = "course-completions";
48599
48600    function slugify(text) {
48601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
48602    }
48603
48604    function getCompletions() {
48605      try {
48606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
48607      } catch {
48608        return [];
48609      }
48610    }
48611
48612    function saveCompletions(completions) {
48613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
48614    }
48615
48616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
48617    function canonicalFromStored(storedId) {
48618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48619      if (!m) return slugify(storedId);
48620      const [, slug, p, mv, title] = m;
48621      return `${slug}-${p}-${mv}-${slugify(title)}`;
48622    }
48623
48624    function canonicalPlusOne(storedId) {
48625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48626      if (!m) return null;
48627      const [, slug, p, mv, title] = m;
48628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
48629    }
48630
48631    function isCompleted(canonicalId, completions) {
48632      if (completions.includes(canonicalId)) return true;
48633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
48634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
48635    }
48636
48637    function migrateCompletions() {
48638      const completions = getCompletions();
48639      let changed = false;
48640      const migrated = completions.map((c) => {
48641        const canon = canonicalFromStored(c);
48642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
48643        const plusOne = canonicalPlusOne(c);
48644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
48645          if (plusOne !== c) changed = true;
48646          return plusOne;
48647        }
48648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
48649          changed = true;
48650          return canon;
48651        }
48652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
48653        if (canon !== c) {
48654          changed = true;
48655          return canon;
48656        }
48657        return c;
48658      });
48659      // Elimina duplicados tras migración
48660      const deduped = [...new Set(migrated)];
48661      if (changed || deduped.length !== completions.length) {
48662        saveCompletions(deduped);
48663        return deduped;
48664      }
48665      return completions;
48666    }
48667
48668    function updateButtonState(btn, completions) {
48669      const id = btn.dataset.completionId;
48670      const list = completions ?? getCompletions();
48671      btn.classList.toggle("completed", isCompleted(id, list));
48672    }
48673
48674    function checkModuleCompletion(completions) {
48675      const list = completions ?? getCompletions();
48676      document.querySelectorAll(".course-video").forEach((module) => {
48677        const btns = module.querySelectorAll(".complete-btn");
48678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
48679        const check = module.querySelector(".module-check");
48680        if (check) check.classList.toggle("visible", allCompleted);
48681      });
48682    }
48683
48684    function initCompletionButtons() {
48685      const completions = migrateCompletions();
48686
48687      document.querySelectorAll(".complete-btn").forEach((btn) => {
48688        if (btn.dataset.completionInit) return;
48689        btn.dataset.completionInit = "true";
48690        updateButtonState(btn, completions);
48691
48692        btn.addEventListener("click", (e) => {
48693          e.preventDefault();
48694          e.stopPropagation();
48695
48696          const id = btn.dataset.completionId;
48697          let list = getCompletions();
48698          const completed = isCompleted(id, list);
48699
48700          if (completed) {
48701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
48702            list = list.filter((c) =>
48702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
48703          } else {
48704            list.push(id);
48705          }
48706
48707          saveCompletions(list);
48708          updateButtonState(btn, list);
48709          checkModuleCompletion(list);
48710        });
48711      });
48712
48713      checkModuleCompletion(completions);
48714    }
48715
48716    if (document.readyState === "loading") {
48717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
48718    } else {
48719      initCompletionButtons();
48720    }
48721  })();
48722</script>
48722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="5260" data-end="7188" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Rutas en Express</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>32 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-rutas-en-express" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
48722<script>
48723  (() => {
48724    if (window.__courseLazyPlayersInitialized) return;
48725    window.__courseLazyPlayersInitialized = true;
48726
48727    function createPlayer(details) {
48728      const videoId = details.dataset.videoId;
48729      const start = details.dataset.start;
48730      const end = details.dataset.end;
48731
48732      const container = document.createElement("div");
48733      container.className = "player-container";
48734
48735      const spinner = document.createElement("div");
48736      spinner.className = "spinner";
48737      container.appendChild(spinner);
48738
48739      const controller = document.createElement("media-controller");
48740      controller.setAttribute("defaultsubtitles", "");
48741
48742      const provider = document.createElement("youtube-media-provider");
48743      provider.setAttribute("slot", "media");
48744      provider.setAttribute("video-id", videoId);
48745      provider.setAttribute("start", start);
48746      provider.setAttribute("end", end);
48747
48748      const controlBar = document.createElement("media-control-bar");
48749
48750      const playBtn = document.createElement("media-play-button");
48751      const timeDisplay = document.createElement("media-time-display");
48752      timeDisplay.setAttribute("showduration", "");
48753      const timeRange = document.createElement("media-time-range");
48754      const playbackRate = document.createElement("media-playback-rate-button");
48755      const muteBtn = document.createElement("media-mute-button");
48756      const volumeRange = document.createElement("media-volume-range");
48757
48758      const captionsBtn = document.createElement("media-captions-button");
48759      captionsBtn.addEventListener("click", (e) => {
48760        e.stopPropagation();
48761        const provider = container.querySelector("youtube-media-provider");
48762        if (provider) provider.toggleCaptions();
48763      });
48764
48765      const qualityBtn = document.createElement("media-chrome-button");
48766      qualityBtn.textContent = "--";
48767      qualityBtn.addEventListener("click", () => {
48768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
48769      });
48770
48771      const fullscreenBtn = document.createElement("media-fullscreen-button");
48772
48773      controlBar.append(
48774        playBtn, timeDisplay, timeRange,
48775        playbackRate, muteBtn, volumeRange,
48776        captionsBtn, qualityBtn, fullscreenBtn
48777      );
48778
48779      const poster = document.createElement("media-poster-image");
48780      poster.setAttribute("slot", "poster");
48781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
48782
48783      controller.append(provider, poster, controlBar);
48784
48785      captionsBtn.setAttribute("aria-checked", "true");
48786      controller.setAttribute("mediasubtitlesshowing", "on");
48787
48788      container.appendChild(controller);
48789      details.appendChild(container);
48790
48791      provider.addEventListener("waiting", () => container.classList.add("loading"));
48792      provider.addEventListener("playing", () => container.classList.remove("loading"));
48793      provider.addEventListener("play", () => container.classList.remove("loading"));
48794
48795      provider._textTracks.addEventListener("change", () => {
48796        const enabled = provider._captionsEnabled;
48797        captionsBtn.setAttribute("aria-checked", enabled);
48798        if (enabled) {
48799          controller.setAttribute("mediasubtitlesshowing", "on");
48800        } else {
48801          controller.removeAttribute("mediasubtitlesshowing");
48802        }
48803      });
48804
48805      provider.addEventListener("qualitychange", (e) => {
48806        qualityBtn.textContent = e.detail.quality;
48807      });
48808    }
48809
48810    function initLazyPlayers() {
48811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
48812        if (details.dataset.lazyInit) return;
48813        details.dataset.lazyInit = "true";
48814        details.addEventListener("toggle", () => {
48815          if (details.open && !details.querySelector(".player-container")) {
48816            createPlayer(details);
48817          } else if (!details.open) {
48818            const provider = details.querySelector("youtube-media-provider");
48819            if (provider && typeof provider.pause === "function") {
48820              provider.pause();
48821            }
48822          }
48823        });
48824      });
48825    }
48826
48827    if (document.readyState === "loading") {
48828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
48829    } else {
48830      initLazyPlayers();
48831    }
48832  })();
48833</script>
48833<script>
48834  (() => {
48835    if (window.__courseCompletionsInitialized) return;
48836    window.__courseCompletionsInitialized = true;
48837
48838    const STORAGE_KEY = "course-completions";
48839
48840    function slugify(text) {
48841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
48842    }
48843
48844    function getCompletions() {
48845      try {
48846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
48847      } catch {
48848        return [];
48849      }
48850    }
48851
48852    function saveCompletions(completions) {
48853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
48854    }
48855
48856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
48857    function canonicalFromStored(storedId) {
48858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48859      if (!m) return slugify(storedId);
48860      const [, slug, p, mv, title] = m;
48861      return `${slug}-${p}-${mv}-${slugify(title)}`;
48862    }
48863
48864    function canonicalPlusOne(storedId) {
48865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
48866      if (!m) return null;
48867      const [, slug, p, mv, title] = m;
48868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
48869    }
48870
48871    function isCompleted(canonicalId, completions) {
48872      if (completions.includes(canonicalId)) return true;
48873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
48874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
48875    }
48876
48877    function migrateCompletions() {
48878      const completions = getCompletions();
48879      let changed = false;
48880      const migrated = completions.map((c) => {
48881        const canon = canonicalFromStored(c);
48882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
48883        const plusOne = canonicalPlusOne(c);
48884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
48885          if (plusOne !== c) changed = true;
48886          return plusOne;
48887        }
48888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
48889          changed = true;
48890          return canon;
48891        }
48892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
48893        if (canon !== c) {
48894          changed = true;
48895          return canon;
48896        }
48897        return c;
48898      });
48899      // Elimina duplicados tras migración
48900      const deduped = [...new Set(migrated)];
48901      if (changed || deduped.length !== completions.length) {
48902        saveCompletions(deduped);
48903        return deduped;
48904      }
48905      return completions;
48906    }
48907
48908    function updateButtonState(btn, completions) {
48909      const id = btn.dataset.completionId;
48910      const list = completions ?? getCompletions();
48911      btn.classList.toggle("completed", isCompleted(id, list));
48912    }
48913
48914    function checkModuleCompletion(completions) {
48915      const list = completions ?? getCompletions();
48916      document.querySelectorAll(".course-video").forEach((module) => {
48917        const btns = module.querySelectorAll(".complete-btn");
48918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
48919        const check = module.querySelector(".module-check");
48920        if (check) check.classList.toggle("visible", allCompleted);
48921      });
48922    }
48923
48924    function initCompletionButtons() {
48925      const completions = migrateCompletions();
48926
48927      document.querySelectorAll(".complete-btn").forEach((btn) => {
48928        if (btn.dataset.completionInit) return;
48929        btn.dataset.completionInit = "true";
48930        updateButtonState(btn, completions);
48931
48932        btn.addEventListener("click", (e) => {
48933          e.preventDefault();
48934          e.stopPropagation();
48935
48936          const id = btn.dataset.completionId;
48937          let list = getCompletions();
48938          const completed = isCompleted(id, list);
48939
48940          if (completed) {
48941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
48942            list = list.filter((c) =>
48942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
48943          } else {
48944            list.push(id);
48945          }
48946
48947          saveCompletions(list);
48948          updateButtonState(btn, list);
48949          checkModuleCompletion(list);
48950        });
48951      });
48952
48953      checkModuleCompletion(completions);
48954    }
48955
48956    if (document.readyState === "loading") {
48957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
48958    } else {
48959      initCompletionButtons();
48960    }
48961  })();
48962</script>
48962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="7564" data-end="8160" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Rutas dinámicas en Express</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>10 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-rutas-dinamicas-en-express" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
48962<script>
48963  (() => {
48964    if (window.__courseLazyPlayersInitialized) return;
48965    window.__courseLazyPlayersInitialized = true;
48966
48967    function createPlayer(details) {
48968      const videoId = details.dataset.videoId;
48969      const start = details.dataset.start;
48970      const end = details.dataset.end;
48971
48972      const container = document.createElement("div");
48973      container.className = "player-container";
48974
48975      const spinner = document.createElement("div");
48976      spinner.className = "spinner";
48977      container.appendChild(spinner);
48978
48979      const controller = document.createElement("media-controller");
48980      controller.setAttribute("defaultsubtitles", "");
48981
48982      const provider = document.createElement("youtube-media-provider");
48983      provider.setAttribute("slot", "media");
48984      provider.setAttribute("video-id", videoId);
48985      provider.setAttribute("start", start);
48986      provider.setAttribute("end", end);
48987
48988      const controlBar = document.createElement("media-control-bar");
48989
48990      const playBtn = document.createElement("media-play-button");
48991      const timeDisplay = document.createElement("media-time-display");
48992      timeDisplay.setAttribute("showduration", "");
48993      const timeRange = document.createElement("media-time-range");
48994      const playbackRate = document.createElement("media-playback-rate-button");
48995      const muteBtn = document.createElement("media-mute-button");
48996      const volumeRange = document.createElement("media-volume-range");
48997
48998      const captionsBtn = document.createElement("media-captions-button");
48999      captionsBtn.addEventListener("click", (e) => {
49000        e.stopPropagation();
49001        const provider = container.querySelector("youtube-media-provider");
49002        if (provider) provider.toggleCaptions();
49003      });
49004
49005      const qualityBtn = document.createElement("media-chrome-button");
49006      qualityBtn.textContent = "--";
49007      qualityBtn.addEventListener("click", () => {
49008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
49009      });
49010
49011      const fullscreenBtn = document.createElement("media-fullscreen-button");
49012
49013      controlBar.append(
49014        playBtn, timeDisplay, timeRange,
49015        playbackRate, muteBtn, volumeRange,
49016        captionsBtn, qualityBtn, fullscreenBtn
49017      );
49018
49019      const poster = document.createElement("media-poster-image");
49020      poster.setAttribute("slot", "poster");
49021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
49022
49023      controller.append(provider, poster, controlBar);
49024
49025      captionsBtn.setAttribute("aria-checked", "true");
49026      controller.setAttribute("mediasubtitlesshowing", "on");
49027
49028      container.appendChild(controller);
49029      details.appendChild(container);
49030
49031      provider.addEventListener("waiting", () => container.classList.add("loading"));
49032      provider.addEventListener("playing", () => container.classList.remove("loading"));
49033      provider.addEventListener("play", () => container.classList.remove("loading"));
49034
49035      provider._textTracks.addEventListener("change", () => {
49036        const enabled = provider._captionsEnabled;
49037        captionsBtn.setAttribute("aria-checked", enabled);
49038        if (enabled) {
49039          controller.setAttribute("mediasubtitlesshowing", "on");
49040        } else {
49041          controller.removeAttribute("mediasubtitlesshowing");
49042        }
49043      });
49044
49045      provider.addEventListener("qualitychange", (e) => {
49046        qualityBtn.textContent = e.detail.quality;
49047      });
49048    }
49049
49050    function initLazyPlayers() {
49051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
49052        if (details.dataset.lazyInit) return;
49053        details.dataset.lazyInit = "true";
49054        details.addEventListener("toggle", () => {
49055          if (details.open && !details.querySelector(".player-container")) {
49056            createPlayer(details);
49057          } else if (!details.open) {
49058            const provider = details.querySelector("youtube-media-provider");
49059            if (provider && typeof provider.pause === "function") {
49060              provider.pause();
49061            }
49062          }
49063        });
49064      });
49065    }
49066
49067    if (document.readyState === "loading") {
49068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
49069    } else {
49070      initLazyPlayers();
49071    }
49072  })();
49073</script>
49073<script>
49074  (() => {
49075    if (window.__courseCompletionsInitialized) return;
49076    window.__courseCompletionsInitialized = true;
49077
49078    const STORAGE_KEY = "course-completions";
49079
49080    function slugify(text) {
49081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
49082    }
49083
49084    function getCompletions() {
49085      try {
49086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
49087      } catch {
49088        return [];
49089      }
49090    }
49091
49092    function saveCompletions(completions) {
49093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
49094    }
49095
49096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
49097    function canonicalFromStored(storedId) {
49098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49099      if (!m) return slugify(storedId);
49100      const [, slug, p, mv, title] = m;
49101      return `${slug}-${p}-${mv}-${slugify(title)}`;
49102    }
49103
49104    function canonicalPlusOne(storedId) {
49105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49106      if (!m) return null;
49107      const [, slug, p, mv, title] = m;
49108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
49109    }
49110
49111    function isCompleted(canonicalId, completions) {
49112      if (completions.includes(canonicalId)) return true;
49113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
49114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
49115    }
49116
49117    function migrateCompletions() {
49118      const completions = getCompletions();
49119      let changed = false;
49120      const migrated = completions.map((c) => {
49121        const canon = canonicalFromStored(c);
49122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
49123        const plusOne = canonicalPlusOne(c);
49124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
49125          if (plusOne !== c) changed = true;
49126          return plusOne;
49127        }
49128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
49129          changed = true;
49130          return canon;
49131        }
49132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
49133        if (canon !== c) {
49134          changed = true;
49135          return canon;
49136        }
49137        return c;
49138      });
49139      // Elimina duplicados tras migración
49140      const deduped = [...new Set(migrated)];
49141      if (changed || deduped.length !== completions.length) {
49142        saveCompletions(deduped);
49143        return deduped;
49144      }
49145      return completions;
49146    }
49147
49148    function updateButtonState(btn, completions) {
49149      const id = btn.dataset.completionId;
49150      const list = completions ?? getCompletions();
49151      btn.classList.toggle("completed", isCompleted(id, list));
49152    }
49153
49154    function checkModuleCompletion(completions) {
49155      const list = completions ?? getCompletions();
49156      document.querySelectorAll(".course-video").forEach((module) => {
49157        const btns = module.querySelectorAll(".complete-btn");
49158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
49159        const check = module.querySelector(".module-check");
49160        if (check) check.classList.toggle("visible", allCompleted);
49161      });
49162    }
49163
49164    function initCompletionButtons() {
49165      const completions = migrateCompletions();
49166
49167      document.querySelectorAll(".complete-btn").forEach((btn) => {
49168        if (btn.dataset.completionInit) return;
49169        btn.dataset.completionInit = "true";
49170        updateButtonState(btn, completions);
49171
49172        btn.addEventListener("click", (e) => {
49173          e.preventDefault();
49174          e.stopPropagation();
49175
49176          const id = btn.dataset.completionId;
49177          let list = getCompletions();
49178          const completed = isCompleted(id, list);
49179
49180          if (completed) {
49181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
49182            list = list.filter((c) =>
49182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
49183          } else {
49184            list.push(id);
49185          }
49186
49187          saveCompletions(list);
49188          updateButtonState(btn, list);
49189          checkModuleCompletion(list);
49190        });
49191      });
49192
49193      checkModuleCompletion(completions);
49194    }
49195
49196    if (document.readyState === "loading") {
49197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
49198    } else {
49199      initCompletionButtons();
49200    }
49201  })();
49202</script>
49202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="8160" data-end="9100" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Inicio ejercicio API</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-inicio-ejercicio-api" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
49202<script>
49203  (() => {
49204    if (window.__courseLazyPlayersInitialized) return;
49205    window.__courseLazyPlayersInitialized = true;
49206
49207    function createPlayer(details) {
49208      const videoId = details.dataset.videoId;
49209      const start = details.dataset.start;
49210      const end = details.dataset.end;
49211
49212      const container = document.createElement("div");
49213      container.className = "player-container";
49214
49215      const spinner = document.createElement("div");
49216      spinner.className = "spinner";
49217      container.appendChild(spinner);
49218
49219      const controller = document.createElement("media-controller");
49220      controller.setAttribute("defaultsubtitles", "");
49221
49222      const provider = document.createElement("youtube-media-provider");
49223      provider.setAttribute("slot", "media");
49224      provider.setAttribute("video-id", videoId);
49225      provider.setAttribute("start", start);
49226      provider.setAttribute("end", end);
49227
49228      const controlBar = document.createElement("media-control-bar");
49229
49230      const playBtn = document.createElement("media-play-button");
49231      const timeDisplay = document.createElement("media-time-display");
49232      timeDisplay.setAttribute("showduration", "");
49233      const timeRange = document.createElement("media-time-range");
49234      const playbackRate = document.createElement("media-playback-rate-button");
49235      const muteBtn = document.createElement("media-mute-button");
49236      const volumeRange = document.createElement("media-volume-range");
49237
49238      const captionsBtn = document.createElement("media-captions-button");
49239      captionsBtn.addEventListener("click", (e) => {
49240        e.stopPropagation();
49241        const provider = container.querySelector("youtube-media-provider");
49242        if (provider) provider.toggleCaptions();
49243      });
49244
49245      const qualityBtn = document.createElement("media-chrome-button");
49246      qualityBtn.textContent = "--";
49247      qualityBtn.addEventListener("click", () => {
49248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
49249      });
49250
49251      const fullscreenBtn = document.createElement("media-fullscreen-button");
49252
49253      controlBar.append(
49254        playBtn, timeDisplay, timeRange,
49255        playbackRate, muteBtn, volumeRange,
49256        captionsBtn, qualityBtn, fullscreenBtn
49257      );
49258
49259      const poster = document.createElement("media-poster-image");
49260      poster.setAttribute("slot", "poster");
49261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
49262
49263      controller.append(provider, poster, controlBar);
49264
49265      captionsBtn.setAttribute("aria-checked", "true");
49266      controller.setAttribute("mediasubtitlesshowing", "on");
49267
49268      container.appendChild(controller);
49269      details.appendChild(container);
49270
49271      provider.addEventListener("waiting", () => container.classList.add("loading"));
49272      provider.addEventListener("playing", () => container.classList.remove("loading"));
49273      provider.addEventListener("play", () => container.classList.remove("loading"));
49274
49275      provider._textTracks.addEventListener("change", () => {
49276        const enabled = provider._captionsEnabled;
49277        captionsBtn.setAttribute("aria-checked", enabled);
49278        if (enabled) {
49279          controller.setAttribute("mediasubtitlesshowing", "on");
49280        } else {
49281          controller.removeAttribute("mediasubtitlesshowing");
49282        }
49283      });
49284
49285      provider.addEventListener("qualitychange", (e) => {
49286        qualityBtn.textContent = e.detail.quality;
49287      });
49288    }
49289
49290    function initLazyPlayers() {
49291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
49292        if (details.dataset.lazyInit) return;
49293        details.dataset.lazyInit = "true";
49294        details.addEventListener("toggle", () => {
49295          if (details.open && !details.querySelector(".player-container")) {
49296            createPlayer(details);
49297          } else if (!details.open) {
49298            const provider = details.querySelector("youtube-media-provider");
49299            if (provider && typeof provider.pause === "function") {
49300              provider.pause();
49301            }
49302          }
49303        });
49304      });
49305    }
49306
49307    if (document.readyState === "loading") {
49308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
49309    } else {
49310      initLazyPlayers();
49311    }
49312  })();
49313</script>
49313<script>
49314  (() => {
49315    if (window.__courseCompletionsInitialized) return;
49316    window.__courseCompletionsInitialized = true;
49317
49318    const STORAGE_KEY = "course-completions";
49319
49320    function slugify(text) {
49321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
49322    }
49323
49324    function getCompletions() {
49325      try {
49326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
49327      } catch {
49328        return [];
49329      }
49330    }
49331
49332    function saveCompletions(completions) {
49333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
49334    }
49335
49336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
49337    function canonicalFromStored(storedId) {
49338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49339      if (!m) return slugify(storedId);
49340      const [, slug, p, mv, title] = m;
49341      return `${slug}-${p}-${mv}-${slugify(title)}`;
49342    }
49343
49344    function canonicalPlusOne(storedId) {
49345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49346      if (!m) return null;
49347      const [, slug, p, mv, title] = m;
49348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
49349    }
49350
49351    function isCompleted(canonicalId, completions) {
49352      if (completions.includes(canonicalId)) return true;
49353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
49354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
49355    }
49356
49357    function migrateCompletions() {
49358      const completions = getCompletions();
49359      let changed = false;
49360      const migrated = completions.map((c) => {
49361        const canon = canonicalFromStored(c);
49362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
49363        const plusOne = canonicalPlusOne(c);
49364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
49365          if (plusOne !== c) changed = true;
49366          return plusOne;
49367        }
49368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
49369          changed = true;
49370          return canon;
49371        }
49372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
49373        if (canon !== c) {
49374          changed = true;
49375          return canon;
49376        }
49377        return c;
49378      });
49379      // Elimina duplicados tras migración
49380      const deduped = [...new Set(migrated)];
49381      if (changed || deduped.length !== completions.length) {
49382        saveCompletions(deduped);
49383        return deduped;
49384      }
49385      return completions;
49386    }
49387
49388    function updateButtonState(btn, completions) {
49389      const id = btn.dataset.completionId;
49390      const list = completions ?? getCompletions();
49391      btn.classList.toggle("completed", isCompleted(id, list));
49392    }
49393
49394    function checkModuleCompletion(completions) {
49395      const list = completions ?? getCompletions();
49396      document.querySelectorAll(".course-video").forEach((module) => {
49397        const btns = module.querySelectorAll(".complete-btn");
49398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
49399        const check = module.querySelector(".module-check");
49400        if (check) check.classList.toggle("visible", allCompleted);
49401      });
49402    }
49403
49404    function initCompletionButtons() {
49405      const completions = migrateCompletions();
49406
49407      document.querySelectorAll(".complete-btn").forEach((btn) => {
49408        if (btn.dataset.completionInit) return;
49409        btn.dataset.completionInit = "true";
49410        updateButtonState(btn, completions);
49411
49412        btn.addEventListener("click", (e) => {
49413          e.preventDefault();
49414          e.stopPropagation();
49415
49416          const id = btn.dataset.completionId;
49417          let list = getCompletions();
49418          const completed = isCompleted(id, list);
49419
49420          if (completed) {
49421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
49422            list = list.filter((c) =>
49422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
49423          } else {
49424            list.push(id);
49425          }
49426
49427          saveCompletions(list);
49428          updateButtonState(btn, list);
49429          checkModuleCompletion(list);
49430        });
49431      });
49432
49433      checkModuleCompletion(completions);
49434    }
49435
49436    if (document.readyState === "loading") {
49437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
49438    } else {
49439      initCompletionButtons();
49440    }
49441  })();
49442</script>
49442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="9100" data-end="9750" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Ruta de juego específico</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-ruta-de-juego-especifico" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
49442<script>
49443  (() => {
49444    if (window.__courseLazyPlayersInitialized) return;
49445    window.__courseLazyPlayersInitialized = true;
49446
49447    function createPlayer(details) {
49448      const videoId = details.dataset.videoId;
49449      const start = details.dataset.start;
49450      const end = details.dataset.end;
49451
49452      const container = document.createElement("div");
49453      container.className = "player-container";
49454
49455      const spinner = document.createElement("div");
49456      spinner.className = "spinner";
49457      container.appendChild(spinner);
49458
49459      const controller = document.createElement("media-controller");
49460      controller.setAttribute("defaultsubtitles", "");
49461
49462      const provider = document.createElement("youtube-media-provider");
49463      provider.setAttribute("slot", "media");
49464      provider.setAttribute("video-id", videoId);
49465      provider.setAttribute("start", start);
49466      provider.setAttribute("end", end);
49467
49468      const controlBar = document.createElement("media-control-bar");
49469
49470      const playBtn = document.createElement("media-play-button");
49471      const timeDisplay = document.createElement("media-time-display");
49472      timeDisplay.setAttribute("showduration", "");
49473      const timeRange = document.createElement("media-time-range");
49474      const playbackRate = document.createElement("media-playback-rate-button");
49475      const muteBtn = document.createElement("media-mute-button");
49476      const volumeRange = document.createElement("media-volume-range");
49477
49478      const captionsBtn = document.createElement("media-captions-button");
49479      captionsBtn.addEventListener("click", (e) => {
49480        e.stopPropagation();
49481        const provider = container.querySelector("youtube-media-provider");
49482        if (provider) provider.toggleCaptions();
49483      });
49484
49485      const qualityBtn = document.createElement("media-chrome-button");
49486      qualityBtn.textContent = "--";
49487      qualityBtn.addEventListener("click", () => {
49488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
49489      });
49490
49491      const fullscreenBtn = document.createElement("media-fullscreen-button");
49492
49493      controlBar.append(
49494        playBtn, timeDisplay, timeRange,
49495        playbackRate, muteBtn, volumeRange,
49496        captionsBtn, qualityBtn, fullscreenBtn
49497      );
49498
49499      const poster = document.createElement("media-poster-image");
49500      poster.setAttribute("slot", "poster");
49501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
49502
49503      controller.append(provider, poster, controlBar);
49504
49505      captionsBtn.setAttribute("aria-checked", "true");
49506      controller.setAttribute("mediasubtitlesshowing", "on");
49507
49508      container.appendChild(controller);
49509      details.appendChild(container);
49510
49511      provider.addEventListener("waiting", () => container.classList.add("loading"));
49512      provider.addEventListener("playing", () => container.classList.remove("loading"));
49513      provider.addEventListener("play", () => container.classList.remove("loading"));
49514
49515      provider._textTracks.addEventListener("change", () => {
49516        const enabled = provider._captionsEnabled;
49517        captionsBtn.setAttribute("aria-checked", enabled);
49518        if (enabled) {
49519          controller.setAttribute("mediasubtitlesshowing", "on");
49520        } else {
49521          controller.removeAttribute("mediasubtitlesshowing");
49522        }
49523      });
49524
49525      provider.addEventListener("qualitychange", (e) => {
49526        qualityBtn.textContent = e.detail.quality;
49527      });
49528    }
49529
49530    function initLazyPlayers() {
49531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
49532        if (details.dataset.lazyInit) return;
49533        details.dataset.lazyInit = "true";
49534        details.addEventListener("toggle", () => {
49535          if (details.open && !details.querySelector(".player-container")) {
49536            createPlayer(details);
49537          } else if (!details.open) {
49538            const provider = details.querySelector("youtube-media-provider");
49539            if (provider && typeof provider.pause === "function") {
49540              provider.pause();
49541            }
49542          }
49543        });
49544      });
49545    }
49546
49547    if (document.readyState === "loading") {
49548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
49549    } else {
49550      initLazyPlayers();
49551    }
49552  })();
49553</script>
49553<script>
49554  (() => {
49555    if (window.__courseCompletionsInitialized) return;
49556    window.__courseCompletionsInitialized = true;
49557
49558    const STORAGE_KEY = "course-completions";
49559
49560    function slugify(text) {
49561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
49562    }
49563
49564    function getCompletions() {
49565      try {
49566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
49567      } catch {
49568        return [];
49569      }
49570    }
49571
49572    function saveCompletions(completions) {
49573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
49574    }
49575
49576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
49577    function canonicalFromStored(storedId) {
49578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49579      if (!m) return slugify(storedId);
49580      const [, slug, p, mv, title] = m;
49581      return `${slug}-${p}-${mv}-${slugify(title)}`;
49582    }
49583
49584    function canonicalPlusOne(storedId) {
49585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49586      if (!m) return null;
49587      const [, slug, p, mv, title] = m;
49588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
49589    }
49590
49591    function isCompleted(canonicalId, completions) {
49592      if (completions.includes(canonicalId)) return true;
49593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
49594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
49595    }
49596
49597    function migrateCompletions() {
49598      const completions = getCompletions();
49599      let changed = false;
49600      const migrated = completions.map((c) => {
49601        const canon = canonicalFromStored(c);
49602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
49603        const plusOne = canonicalPlusOne(c);
49604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
49605          if (plusOne !== c) changed = true;
49606          return plusOne;
49607        }
49608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
49609          changed = true;
49610          return canon;
49611        }
49612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
49613        if (canon !== c) {
49614          changed = true;
49615          return canon;
49616        }
49617        return c;
49618      });
49619      // Elimina duplicados tras migración
49620      const deduped = [...new Set(migrated)];
49621      if (changed || deduped.length !== completions.length) {
49622        saveCompletions(deduped);
49623        return deduped;
49624      }
49625      return completions;
49626    }
49627
49628    function updateButtonState(btn, completions) {
49629      const id = btn.dataset.completionId;
49630      const list = completions ?? getCompletions();
49631      btn.classList.toggle("completed", isCompleted(id, list));
49632    }
49633
49634    function checkModuleCompletion(completions) {
49635      const list = completions ?? getCompletions();
49636      document.querySelectorAll(".course-video").forEach((module) => {
49637        const btns = module.querySelectorAll(".complete-btn");
49638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
49639        const check = module.querySelector(".module-check");
49640        if (check) check.classList.toggle("visible", allCompleted);
49641      });
49642    }
49643
49644    function initCompletionButtons() {
49645      const completions = migrateCompletions();
49646
49647      document.querySelectorAll(".complete-btn").forEach((btn) => {
49648        if (btn.dataset.completionInit) return;
49649        btn.dataset.completionInit = "true";
49650        updateButtonState(btn, completions);
49651
49652        btn.addEventListener("click", (e) => {
49653          e.preventDefault();
49654          e.stopPropagation();
49655
49656          const id = btn.dataset.completionId;
49657          let list = getCompletions();
49658          const completed = isCompleted(id, list);
49659
49660          if (completed) {
49661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
49662            list = list.filter((c) =>
49662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
49663          } else {
49664            list.push(id);
49665          }
49666
49667          saveCompletions(list);
49668          updateButtonState(btn, list);
49669          checkModuleCompletion(list);
49670        });
49671      });
49672
49673      checkModuleCompletion(completions);
49674    }
49675
49676    if (document.readyState === "loading") {
49677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
49678    } else {
49679      initCompletionButtons();
49680    }
49681  })();
49682</script>
49682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="9750" data-end="10260" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Ruta por año y aleatorio</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-ruta-por-ano-y-aleatorio" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
49682<script>
49683  (() => {
49684    if (window.__courseLazyPlayersInitialized) return;
49685    window.__courseLazyPlayersInitialized = true;
49686
49687    function createPlayer(details) {
49688      const videoId = details.dataset.videoId;
49689      const start = details.dataset.start;
49690      const end = details.dataset.end;
49691
49692      const container = document.createElement("div");
49693      container.className = "player-container";
49694
49695      const spinner = document.createElement("div");
49696      spinner.className = "spinner";
49697      container.appendChild(spinner);
49698
49699      const controller = document.createElement("media-controller");
49700      controller.setAttribute("defaultsubtitles", "");
49701
49702      const provider = document.createElement("youtube-media-provider");
49703      provider.setAttribute("slot", "media");
49704      provider.setAttribute("video-id", videoId);
49705      provider.setAttribute("start", start);
49706      provider.setAttribute("end", end);
49707
49708      const controlBar = document.createElement("media-control-bar");
49709
49710      const playBtn = document.createElement("media-play-button");
49711      const timeDisplay = document.createElement("media-time-display");
49712      timeDisplay.setAttribute("showduration", "");
49713      const timeRange = document.createElement("media-time-range");
49714      const playbackRate = document.createElement("media-playback-rate-button");
49715      const muteBtn = document.createElement("media-mute-button");
49716      const volumeRange = document.createElement("media-volume-range");
49717
49718      const captionsBtn = document.createElement("media-captions-button");
49719      captionsBtn.addEventListener("click", (e) => {
49720        e.stopPropagation();
49721        const provider = container.querySelector("youtube-media-provider");
49722        if (provider) provider.toggleCaptions();
49723      });
49724
49725      const qualityBtn = document.createElement("media-chrome-button");
49726      qualityBtn.textContent = "--";
49727      qualityBtn.addEventListener("click", () => {
49728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
49729      });
49730
49731      const fullscreenBtn = document.createElement("media-fullscreen-button");
49732
49733      controlBar.append(
49734        playBtn, timeDisplay, timeRange,
49735        playbackRate, muteBtn, volumeRange,
49736        captionsBtn, qualityBtn, fullscreenBtn
49737      );
49738
49739      const poster = document.createElement("media-poster-image");
49740      poster.setAttribute("slot", "poster");
49741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
49742
49743      controller.append(provider, poster, controlBar);
49744
49745      captionsBtn.setAttribute("aria-checked", "true");
49746      controller.setAttribute("mediasubtitlesshowing", "on");
49747
49748      container.appendChild(controller);
49749      details.appendChild(container);
49750
49751      provider.addEventListener("waiting", () => container.classList.add("loading"));
49752      provider.addEventListener("playing", () => container.classList.remove("loading"));
49753      provider.addEventListener("play", () => container.classList.remove("loading"));
49754
49755      provider._textTracks.addEventListener("change", () => {
49756        const enabled = provider._captionsEnabled;
49757        captionsBtn.setAttribute("aria-checked", enabled);
49758        if (enabled) {
49759          controller.setAttribute("mediasubtitlesshowing", "on");
49760        } else {
49761          controller.removeAttribute("mediasubtitlesshowing");
49762        }
49763      });
49764
49765      provider.addEventListener("qualitychange", (e) => {
49766        qualityBtn.textContent = e.detail.quality;
49767      });
49768    }
49769
49770    function initLazyPlayers() {
49771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
49772        if (details.dataset.lazyInit) return;
49773        details.dataset.lazyInit = "true";
49774        details.addEventListener("toggle", () => {
49775          if (details.open && !details.querySelector(".player-container")) {
49776            createPlayer(details);
49777          } else if (!details.open) {
49778            const provider = details.querySelector("youtube-media-provider");
49779            if (provider && typeof provider.pause === "function") {
49780              provider.pause();
49781            }
49782          }
49783        });
49784      });
49785    }
49786
49787    if (document.readyState === "loading") {
49788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
49789    } else {
49790      initLazyPlayers();
49791    }
49792  })();
49793</script>
49793<script>
49794  (() => {
49795    if (window.__courseCompletionsInitialized) return;
49796    window.__courseCompletionsInitialized = true;
49797
49798    const STORAGE_KEY = "course-completions";
49799
49800    function slugify(text) {
49801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
49802    }
49803
49804    function getCompletions() {
49805      try {
49806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
49807      } catch {
49808        return [];
49809      }
49810    }
49811
49812    function saveCompletions(completions) {
49813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
49814    }
49815
49816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
49817    function canonicalFromStored(storedId) {
49818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49819      if (!m) return slugify(storedId);
49820      const [, slug, p, mv, title] = m;
49821      return `${slug}-${p}-${mv}-${slugify(title)}`;
49822    }
49823
49824    function canonicalPlusOne(storedId) {
49825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
49826      if (!m) return null;
49827      const [, slug, p, mv, title] = m;
49828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
49829    }
49830
49831    function isCompleted(canonicalId, completions) {
49832      if (completions.includes(canonicalId)) return true;
49833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
49834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
49835    }
49836
49837    function migrateCompletions() {
49838      const completions = getCompletions();
49839      let changed = false;
49840      const migrated = completions.map((c) => {
49841        const canon = canonicalFromStored(c);
49842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
49843        const plusOne = canonicalPlusOne(c);
49844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
49845          if (plusOne !== c) changed = true;
49846          return plusOne;
49847        }
49848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
49849          changed = true;
49850          return canon;
49851        }
49852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
49853        if (canon !== c) {
49854          changed = true;
49855          return canon;
49856        }
49857        return c;
49858      });
49859      // Elimina duplicados tras migración
49860      const deduped = [...new Set(migrated)];
49861      if (changed || deduped.length !== completions.length) {
49862        saveCompletions(deduped);
49863        return deduped;
49864      }
49865      return completions;
49866    }
49867
49868    function updateButtonState(btn, completions) {
49869      const id = btn.dataset.completionId;
49870      const list = completions ?? getCompletions();
49871      btn.classList.toggle("completed", isCompleted(id, list));
49872    }
49873
49874    function checkModuleCompletion(completions) {
49875      const list = completions ?? getCompletions();
49876      document.querySelectorAll(".course-video").forEach((module) => {
49877        const btns = module.querySelectorAll(".complete-btn");
49878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
49879        const check = module.querySelector(".module-check");
49880        if (check) check.classList.toggle("visible", allCompleted);
49881      });
49882    }
49883
49884    function initCompletionButtons() {
49885      const completions = migrateCompletions();
49886
49887      document.querySelectorAll(".complete-btn").forEach((btn) => {
49888        if (btn.dataset.completionInit) return;
49889        btn.dataset.completionInit = "true";
49890        updateButtonState(btn, completions);
49891
49892        btn.addEventListener("click", (e) => {
49893          e.preventDefault();
49894          e.stopPropagation();
49895
49896          const id = btn.dataset.completionId;
49897          let list = getCompletions();
49898          const completed = isCompleted(id, list);
49899
49900          if (completed) {
49901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
49902            list = list.filter((c) =>
49902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
49903          } else {
49904            list.push(id);
49905          }
49906
49907          saveCompletions(list);
49908          updateButtonState(btn, list);
49909          checkModuleCompletion(list);
49910        });
49911      });
49912
49913      checkModuleCompletion(completions);
49914    }
49915
49916    if (document.readyState === "loading") {
49917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
49918    } else {
49919      initCompletionButtons();
49920    }
49921  })();
49922</script>
49922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="10260" data-end="10942" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Parámetros, estáticos y catch-all</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-parametros-estaticos-y-catch-all" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
49922<script>
49923  (() => {
49924    if (window.__courseLazyPlayersInitialized) return;
49925    window.__courseLazyPlayersInitialized = true;
49926
49927    function createPlayer(details) {
49928      const videoId = details.dataset.videoId;
49929      const start = details.dataset.start;
49930      const end = details.dataset.end;
49931
49932      const container = document.createElement("div");
49933      container.className = "player-container";
49934
49935      const spinner = document.createElement("div");
49936      spinner.className = "spinner";
49937      container.appendChild(spinner);
49938
49939      const controller = document.createElement("media-controller");
49940      controller.setAttribute("defaultsubtitles", "");
49941
49942      const provider = document.createElement("youtube-media-provider");
49943      provider.setAttribute("slot", "media");
49944      provider.setAttribute("video-id", videoId);
49945      provider.setAttribute("start", start);
49946      provider.setAttribute("end", end);
49947
49948      const controlBar = document.createElement("media-control-bar");
49949
49950      const playBtn = document.createElement("media-play-button");
49951      const timeDisplay = document.createElement("media-time-display");
49952      timeDisplay.setAttribute("showduration", "");
49953      const timeRange = document.createElement("media-time-range");
49954      const playbackRate = document.createElement("media-playback-rate-button");
49955      const muteBtn = document.createElement("media-mute-button");
49956      const volumeRange = document.createElement("media-volume-range");
49957
49958      const captionsBtn = document.createElement("media-captions-button");
49959      captionsBtn.addEventListener("click", (e) => {
49960        e.stopPropagation();
49961        const provider = container.querySelector("youtube-media-provider");
49962        if (provider) provider.toggleCaptions();
49963      });
49964
49965      const qualityBtn = document.createElement("media-chrome-button");
49966      qualityBtn.textContent = "--";
49967      qualityBtn.addEventListener("click", () => {
49968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
49969      });
49970
49971      const fullscreenBtn = document.createElement("media-fullscreen-button");
49972
49973      controlBar.append(
49974        playBtn, timeDisplay, timeRange,
49975        playbackRate, muteBtn, volumeRange,
49976        captionsBtn, qualityBtn, fullscreenBtn
49977      );
49978
49979      const poster = document.createElement("media-poster-image");
49980      poster.setAttribute("slot", "poster");
49981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
49982
49983      controller.append(provider, poster, controlBar);
49984
49985      captionsBtn.setAttribute("aria-checked", "true");
49986      controller.setAttribute("mediasubtitlesshowing", "on");
49987
49988      container.appendChild(controller);
49989      details.appendChild(container);
49990
49991      provider.addEventListener("waiting", () => container.classList.add("loading"));
49992      provider.addEventListener("playing", () => container.classList.remove("loading"));
49993      provider.addEventListener("play", () => container.classList.remove("loading"));
49994
49995      provider._textTracks.addEventListener("change", () => {
49996        const enabled = provider._captionsEnabled;
49997        captionsBtn.setAttribute("aria-checked", enabled);
49998        if (enabled) {
49999          controller.setAttribute("mediasubtitlesshowing", "on");
50000        } else {
50001          controller.removeAttribute("mediasubtitlesshowing");
50002        }
50003      });
50004
50005      provider.addEventListener("qualitychange", (e) => {
50006        qualityBtn.textContent = e.detail.quality;
50007      });
50008    }
50009
50010    function initLazyPlayers() {
50011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
50012        if (details.dataset.lazyInit) return;
50013        details.dataset.lazyInit = "true";
50014        details.addEventListener("toggle", () => {
50015          if (details.open && !details.querySelector(".player-container")) {
50016            createPlayer(details);
50017          } else if (!details.open) {
50018            const provider = details.querySelector("youtube-media-provider");
50019            if (provider && typeof provider.pause === "function") {
50020              provider.pause();
50021            }
50022          }
50023        });
50024      });
50025    }
50026
50027    if (document.readyState === "loading") {
50028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
50029    } else {
50030      initLazyPlayers();
50031    }
50032  })();
50033</script>
50033<script>
50034  (() => {
50035    if (window.__courseCompletionsInitialized) return;
50036    window.__courseCompletionsInitialized = true;
50037
50038    const STORAGE_KEY = "course-completions";
50039
50040    function slugify(text) {
50041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
50042    }
50043
50044    function getCompletions() {
50045      try {
50046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
50047      } catch {
50048        return [];
50049      }
50050    }
50051
50052    function saveCompletions(completions) {
50053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
50054    }
50055
50056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
50057    function canonicalFromStored(storedId) {
50058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50059      if (!m) return slugify(storedId);
50060      const [, slug, p, mv, title] = m;
50061      return `${slug}-${p}-${mv}-${slugify(title)}`;
50062    }
50063
50064    function canonicalPlusOne(storedId) {
50065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50066      if (!m) return null;
50067      const [, slug, p, mv, title] = m;
50068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
50069    }
50070
50071    function isCompleted(canonicalId, completions) {
50072      if (completions.includes(canonicalId)) return true;
50073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
50074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
50075    }
50076
50077    function migrateCompletions() {
50078      const completions = getCompletions();
50079      let changed = false;
50080      const migrated = completions.map((c) => {
50081        const canon = canonicalFromStored(c);
50082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
50083        const plusOne = canonicalPlusOne(c);
50084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
50085          if (plusOne !== c) changed = true;
50086          return plusOne;
50087        }
50088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
50089          changed = true;
50090          return canon;
50091        }
50092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
50093        if (canon !== c) {
50094          changed = true;
50095          return canon;
50096        }
50097        return c;
50098      });
50099      // Elimina duplicados tras migración
50100      const deduped = [...new Set(migrated)];
50101      if (changed || deduped.length !== completions.length) {
50102        saveCompletions(deduped);
50103        return deduped;
50104      }
50105      return completions;
50106    }
50107
50108    function updateButtonState(btn, completions) {
50109      const id = btn.dataset.completionId;
50110      const list = completions ?? getCompletions();
50111      btn.classList.toggle("completed", isCompleted(id, list));
50112    }
50113
50114    function checkModuleCompletion(completions) {
50115      const list = completions ?? getCompletions();
50116      document.querySelectorAll(".course-video").forEach((module) => {
50117        const btns = module.querySelectorAll(".complete-btn");
50118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
50119        const check = module.querySelector(".module-check");
50120        if (check) check.classList.toggle("visible", allCompleted);
50121      });
50122    }
50123
50124    function initCompletionButtons() {
50125      const completions = migrateCompletions();
50126
50127      document.querySelectorAll(".complete-btn").forEach((btn) => {
50128        if (btn.dataset.completionInit) return;
50129        btn.dataset.completionInit = "true";
50130        updateButtonState(btn, completions);
50131
50132        btn.addEventListener("click", (e) => {
50133          e.preventDefault();
50134          e.stopPropagation();
50135
50136          const id = btn.dataset.completionId;
50137          let list = getCompletions();
50138          const completed = isCompleted(id, list);
50139
50140          if (completed) {
50141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
50142            list = list.filter((c) =>
50142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
50143          } else {
50144            list.push(id);
50145          }
50146
50147          saveCompletions(list);
50148          updateButtonState(btn, list);
50149          checkModuleCompletion(list);
50150        });
50151      });
50152
50153      checkModuleCompletion(completions);
50154    }
50155
50156    if (document.readyState === "loading") {
50157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
50158    } else {
50159      initCompletionButtons();
50160    }
50161  })();
50162</script>
50162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="10942" data-end="11460" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Ruta para buscador</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-ruta-para-buscador" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
50162<script>
50163  (() => {
50164    if (window.__courseLazyPlayersInitialized) return;
50165    window.__courseLazyPlayersInitialized = true;
50166
50167    function createPlayer(details) {
50168      const videoId = details.dataset.videoId;
50169      const start = details.dataset.start;
50170      const end = details.dataset.end;
50171
50172      const container = document.createElement("div");
50173      container.className = "player-container";
50174
50175      const spinner = document.createElement("div");
50176      spinner.className = "spinner";
50177      container.appendChild(spinner);
50178
50179      const controller = document.createElement("media-controller");
50180      controller.setAttribute("defaultsubtitles", "");
50181
50182      const provider = document.createElement("youtube-media-provider");
50183      provider.setAttribute("slot", "media");
50184      provider.setAttribute("video-id", videoId);
50185      provider.setAttribute("start", start);
50186      provider.setAttribute("end", end);
50187
50188      const controlBar = document.createElement("media-control-bar");
50189
50190      const playBtn = document.createElement("media-play-button");
50191      const timeDisplay = document.createElement("media-time-display");
50192      timeDisplay.setAttribute("showduration", "");
50193      const timeRange = document.createElement("media-time-range");
50194      const playbackRate = document.createElement("media-playback-rate-button");
50195      const muteBtn = document.createElement("media-mute-button");
50196      const volumeRange = document.createElement("media-volume-range");
50197
50198      const captionsBtn = document.createElement("media-captions-button");
50199      captionsBtn.addEventListener("click", (e) => {
50200        e.stopPropagation();
50201        const provider = container.querySelector("youtube-media-provider");
50202        if (provider) provider.toggleCaptions();
50203      });
50204
50205      const qualityBtn = document.createElement("media-chrome-button");
50206      qualityBtn.textContent = "--";
50207      qualityBtn.addEventListener("click", () => {
50208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
50209      });
50210
50211      const fullscreenBtn = document.createElement("media-fullscreen-button");
50212
50213      controlBar.append(
50214        playBtn, timeDisplay, timeRange,
50215        playbackRate, muteBtn, volumeRange,
50216        captionsBtn, qualityBtn, fullscreenBtn
50217      );
50218
50219      const poster = document.createElement("media-poster-image");
50220      poster.setAttribute("slot", "poster");
50221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
50222
50223      controller.append(provider, poster, controlBar);
50224
50225      captionsBtn.setAttribute("aria-checked", "true");
50226      controller.setAttribute("mediasubtitlesshowing", "on");
50227
50228      container.appendChild(controller);
50229      details.appendChild(container);
50230
50231      provider.addEventListener("waiting", () => container.classList.add("loading"));
50232      provider.addEventListener("playing", () => container.classList.remove("loading"));
50233      provider.addEventListener("play", () => container.classList.remove("loading"));
50234
50235      provider._textTracks.addEventListener("change", () => {
50236        const enabled = provider._captionsEnabled;
50237        captionsBtn.setAttribute("aria-checked", enabled);
50238        if (enabled) {
50239          controller.setAttribute("mediasubtitlesshowing", "on");
50240        } else {
50241          controller.removeAttribute("mediasubtitlesshowing");
50242        }
50243      });
50244
50245      provider.addEventListener("qualitychange", (e) => {
50246        qualityBtn.textContent = e.detail.quality;
50247      });
50248    }
50249
50250    function initLazyPlayers() {
50251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
50252        if (details.dataset.lazyInit) return;
50253        details.dataset.lazyInit = "true";
50254        details.addEventListener("toggle", () => {
50255          if (details.open && !details.querySelector(".player-container")) {
50256            createPlayer(details);
50257          } else if (!details.open) {
50258            const provider = details.querySelector("youtube-media-provider");
50259            if (provider && typeof provider.pause === "function") {
50260              provider.pause();
50261            }
50262          }
50263        });
50264      });
50265    }
50266
50267    if (document.readyState === "loading") {
50268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
50269    } else {
50270      initLazyPlayers();
50271    }
50272  })();
50273</script>
50273<script>
50274  (() => {
50275    if (window.__courseCompletionsInitialized) return;
50276    window.__courseCompletionsInitialized = true;
50277
50278    const STORAGE_KEY = "course-completions";
50279
50280    function slugify(text) {
50281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
50282    }
50283
50284    function getCompletions() {
50285      try {
50286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
50287      } catch {
50288        return [];
50289      }
50290    }
50291
50292    function saveCompletions(completions) {
50293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
50294    }
50295
50296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
50297    function canonicalFromStored(storedId) {
50298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50299      if (!m) return slugify(storedId);
50300      const [, slug, p, mv, title] = m;
50301      return `${slug}-${p}-${mv}-${slugify(title)}`;
50302    }
50303
50304    function canonicalPlusOne(storedId) {
50305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50306      if (!m) return null;
50307      const [, slug, p, mv, title] = m;
50308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
50309    }
50310
50311    function isCompleted(canonicalId, completions) {
50312      if (completions.includes(canonicalId)) return true;
50313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
50314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
50315    }
50316
50317    function migrateCompletions() {
50318      const completions = getCompletions();
50319      let changed = false;
50320      const migrated = completions.map((c) => {
50321        const canon = canonicalFromStored(c);
50322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
50323        const plusOne = canonicalPlusOne(c);
50324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
50325          if (plusOne !== c) changed = true;
50326          return plusOne;
50327        }
50328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
50329          changed = true;
50330          return canon;
50331        }
50332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
50333        if (canon !== c) {
50334          changed = true;
50335          return canon;
50336        }
50337        return c;
50338      });
50339      // Elimina duplicados tras migración
50340      const deduped = [...new Set(migrated)];
50341      if (changed || deduped.length !== completions.length) {
50342        saveCompletions(deduped);
50343        return deduped;
50344      }
50345      return completions;
50346    }
50347
50348    function updateButtonState(btn, completions) {
50349      const id = btn.dataset.completionId;
50350      const list = completions ?? getCompletions();
50351      btn.classList.toggle("completed", isCompleted(id, list));
50352    }
50353
50354    function checkModuleCompletion(completions) {
50355      const list = completions ?? getCompletions();
50356      document.querySelectorAll(".course-video").forEach((module) => {
50357        const btns = module.querySelectorAll(".complete-btn");
50358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
50359        const check = module.querySelector(".module-check");
50360        if (check) check.classList.toggle("visible", allCompleted);
50361      });
50362    }
50363
50364    function initCompletionButtons() {
50365      const completions = migrateCompletions();
50366
50367      document.querySelectorAll(".complete-btn").forEach((btn) => {
50368        if (btn.dataset.completionInit) return;
50369        btn.dataset.completionInit = "true";
50370        updateButtonState(btn, completions);
50371
50372        btn.addEventListener("click", (e) => {
50373          e.preventDefault();
50374          e.stopPropagation();
50375
50376          const id = btn.dataset.completionId;
50377          let list = getCompletions();
50378          const completed = isCompleted(id, list);
50379
50380          if (completed) {
50381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
50382            list = list.filter((c) =>
50382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
50383          } else {
50384            list.push(id);
50385          }
50386
50387          saveCompletions(list);
50388          updateButtonState(btn, list);
50389          checkModuleCompletion(list);
50390        });
50391      });
50392
50393      checkModuleCompletion(completions);
50394    }
50395
50396    if (document.readyState === "loading") {
50397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
50398    } else {
50399      initCompletionButtons();
50400    }
50401  })();
50402</script>
50402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="11460" data-end="12250" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Separar la lógica en controlador</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>13 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-separar-la-logica-en-controlador" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
50402<script>
50403  (() => {
50404    if (window.__courseLazyPlayersInitialized) return;
50405    window.__courseLazyPlayersInitialized = true;
50406
50407    function createPlayer(details) {
50408      const videoId = details.dataset.videoId;
50409      const start = details.dataset.start;
50410      const end = details.dataset.end;
50411
50412      const container = document.createElement("div");
50413      container.className = "player-container";
50414
50415      const spinner = document.createElement("div");
50416      spinner.className = "spinner";
50417      container.appendChild(spinner);
50418
50419      const controller = document.createElement("media-controller");
50420      controller.setAttribute("defaultsubtitles", "");
50421
50422      const provider = document.createElement("youtube-media-provider");
50423      provider.setAttribute("slot", "media");
50424      provider.setAttribute("video-id", videoId);
50425      provider.setAttribute("start", start);
50426      provider.setAttribute("end", end);
50427
50428      const controlBar = document.createElement("media-control-bar");
50429
50430      const playBtn = document.createElement("media-play-button");
50431      const timeDisplay = document.createElement("media-time-display");
50432      timeDisplay.setAttribute("showduration", "");
50433      const timeRange = document.createElement("media-time-range");
50434      const playbackRate = document.createElement("media-playback-rate-button");
50435      const muteBtn = document.createElement("media-mute-button");
50436      const volumeRange = document.createElement("media-volume-range");
50437
50438      const captionsBtn = document.createElement("media-captions-button");
50439      captionsBtn.addEventListener("click", (e) => {
50440        e.stopPropagation();
50441        const provider = container.querySelector("youtube-media-provider");
50442        if (provider) provider.toggleCaptions();
50443      });
50444
50445      const qualityBtn = document.createElement("media-chrome-button");
50446      qualityBtn.textContent = "--";
50447      qualityBtn.addEventListener("click", () => {
50448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
50449      });
50450
50451      const fullscreenBtn = document.createElement("media-fullscreen-button");
50452
50453      controlBar.append(
50454        playBtn, timeDisplay, timeRange,
50455        playbackRate, muteBtn, volumeRange,
50456        captionsBtn, qualityBtn, fullscreenBtn
50457      );
50458
50459      const poster = document.createElement("media-poster-image");
50460      poster.setAttribute("slot", "poster");
50461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
50462
50463      controller.append(provider, poster, controlBar);
50464
50465      captionsBtn.setAttribute("aria-checked", "true");
50466      controller.setAttribute("mediasubtitlesshowing", "on");
50467
50468      container.appendChild(controller);
50469      details.appendChild(container);
50470
50471      provider.addEventListener("waiting", () => container.classList.add("loading"));
50472      provider.addEventListener("playing", () => container.classList.remove("loading"));
50473      provider.addEventListener("play", () => container.classList.remove("loading"));
50474
50475      provider._textTracks.addEventListener("change", () => {
50476        const enabled = provider._captionsEnabled;
50477        captionsBtn.setAttribute("aria-checked", enabled);
50478        if (enabled) {
50479          controller.setAttribute("mediasubtitlesshowing", "on");
50480        } else {
50481          controller.removeAttribute("mediasubtitlesshowing");
50482        }
50483      });
50484
50485      provider.addEventListener("qualitychange", (e) => {
50486        qualityBtn.textContent = e.detail.quality;
50487      });
50488    }
50489
50490    function initLazyPlayers() {
50491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
50492        if (details.dataset.lazyInit) return;
50493        details.dataset.lazyInit = "true";
50494        details.addEventListener("toggle", () => {
50495          if (details.open && !details.querySelector(".player-container")) {
50496            createPlayer(details);
50497          } else if (!details.open) {
50498            const provider = details.querySelector("youtube-media-provider");
50499            if (provider && typeof provider.pause === "function") {
50500              provider.pause();
50501            }
50502          }
50503        });
50504      });
50505    }
50506
50507    if (document.readyState === "loading") {
50508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
50509    } else {
50510      initLazyPlayers();
50511    }
50512  })();
50513</script>
50513<script>
50514  (() => {
50515    if (window.__courseCompletionsInitialized) return;
50516    window.__courseCompletionsInitialized = true;
50517
50518    const STORAGE_KEY = "course-completions";
50519
50520    function slugify(text) {
50521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
50522    }
50523
50524    function getCompletions() {
50525      try {
50526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
50527      } catch {
50528        return [];
50529      }
50530    }
50531
50532    function saveCompletions(completions) {
50533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
50534    }
50535
50536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
50537    function canonicalFromStored(storedId) {
50538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50539      if (!m) return slugify(storedId);
50540      const [, slug, p, mv, title] = m;
50541      return `${slug}-${p}-${mv}-${slugify(title)}`;
50542    }
50543
50544    function canonicalPlusOne(storedId) {
50545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50546      if (!m) return null;
50547      const [, slug, p, mv, title] = m;
50548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
50549    }
50550
50551    function isCompleted(canonicalId, completions) {
50552      if (completions.includes(canonicalId)) return true;
50553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
50554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
50555    }
50556
50557    function migrateCompletions() {
50558      const completions = getCompletions();
50559      let changed = false;
50560      const migrated = completions.map((c) => {
50561        const canon = canonicalFromStored(c);
50562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
50563        const plusOne = canonicalPlusOne(c);
50564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
50565          if (plusOne !== c) changed = true;
50566          return plusOne;
50567        }
50568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
50569          changed = true;
50570          return canon;
50571        }
50572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
50573        if (canon !== c) {
50574          changed = true;
50575          return canon;
50576        }
50577        return c;
50578      });
50579      // Elimina duplicados tras migración
50580      const deduped = [...new Set(migrated)];
50581      if (changed || deduped.length !== completions.length) {
50582        saveCompletions(deduped);
50583        return deduped;
50584      }
50585      return completions;
50586    }
50587
50588    function updateButtonState(btn, completions) {
50589      const id = btn.dataset.completionId;
50590      const list = completions ?? getCompletions();
50591      btn.classList.toggle("completed", isCompleted(id, list));
50592    }
50593
50594    function checkModuleCompletion(completions) {
50595      const list = completions ?? getCompletions();
50596      document.querySelectorAll(".course-video").forEach((module) => {
50597        const btns = module.querySelectorAll(".complete-btn");
50598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
50599        const check = module.querySelector(".module-check");
50600        if (check) check.classList.toggle("visible", allCompleted);
50601      });
50602    }
50603
50604    function initCompletionButtons() {
50605      const completions = migrateCompletions();
50606
50607      document.querySelectorAll(".complete-btn").forEach((btn) => {
50608        if (btn.dataset.completionInit) return;
50609        btn.dataset.completionInit = "true";
50610        updateButtonState(btn, completions);
50611
50612        btn.addEventListener("click", (e) => {
50613          e.preventDefault();
50614          e.stopPropagation();
50615
50616          const id = btn.dataset.completionId;
50617          let list = getCompletions();
50618          const completed = isCompleted(id, list);
50619
50620          if (completed) {
50621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
50622            list = list.filter((c) =>
50622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
50623          } else {
50624            list.push(id);
50625          }
50626
50627          saveCompletions(list);
50628          updateButtonState(btn, list);
50629          checkModuleCompletion(list);
50630        });
50631      });
50632
50633      checkModuleCompletion(completions);
50634    }
50635
50636    if (document.readyState === "loading") {
50637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
50638    } else {
50639      initCompletionButtons();
50640    }
50641  })();
50642</script>
50642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="12250" data-end="13342" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Validaciones con Zod</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-validaciones-con-zod" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
50642<script>
50643  (() => {
50644    if (window.__courseLazyPlayersInitialized) return;
50645    window.__courseLazyPlayersInitialized = true;
50646
50647    function createPlayer(details) {
50648      const videoId = details.dataset.videoId;
50649      const start = details.dataset.start;
50650      const end = details.dataset.end;
50651
50652      const container = document.createElement("div");
50653      container.className = "player-container";
50654
50655      const spinner = document.createElement("div");
50656      spinner.className = "spinner";
50657      container.appendChild(spinner);
50658
50659      const controller = document.createElement("media-controller");
50660      controller.setAttribute("defaultsubtitles", "");
50661
50662      const provider = document.createElement("youtube-media-provider");
50663      provider.setAttribute("slot", "media");
50664      provider.setAttribute("video-id", videoId);
50665      provider.setAttribute("start", start);
50666      provider.setAttribute("end", end);
50667
50668      const controlBar = document.createElement("media-control-bar");
50669
50670      const playBtn = document.createElement("media-play-button");
50671      const timeDisplay = document.createElement("media-time-display");
50672      timeDisplay.setAttribute("showduration", "");
50673      const timeRange = document.createElement("media-time-range");
50674      const playbackRate = document.createElement("media-playback-rate-button");
50675      const muteBtn = document.createElement("media-mute-button");
50676      const volumeRange = document.createElement("media-volume-range");
50677
50678      const captionsBtn = document.createElement("media-captions-button");
50679      captionsBtn.addEventListener("click", (e) => {
50680        e.stopPropagation();
50681        const provider = container.querySelector("youtube-media-provider");
50682        if (provider) provider.toggleCaptions();
50683      });
50684
50685      const qualityBtn = document.createElement("media-chrome-button");
50686      qualityBtn.textContent = "--";
50687      qualityBtn.addEventListener("click", () => {
50688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
50689      });
50690
50691      const fullscreenBtn = document.createElement("media-fullscreen-button");
50692
50693      controlBar.append(
50694        playBtn, timeDisplay, timeRange,
50695        playbackRate, muteBtn, volumeRange,
50696        captionsBtn, qualityBtn, fullscreenBtn
50697      );
50698
50699      const poster = document.createElement("media-poster-image");
50700      poster.setAttribute("slot", "poster");
50701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
50702
50703      controller.append(provider, poster, controlBar);
50704
50705      captionsBtn.setAttribute("aria-checked", "true");
50706      controller.setAttribute("mediasubtitlesshowing", "on");
50707
50708      container.appendChild(controller);
50709      details.appendChild(container);
50710
50711      provider.addEventListener("waiting", () => container.classList.add("loading"));
50712      provider.addEventListener("playing", () => container.classList.remove("loading"));
50713      provider.addEventListener("play", () => container.classList.remove("loading"));
50714
50715      provider._textTracks.addEventListener("change", () => {
50716        const enabled = provider._captionsEnabled;
50717        captionsBtn.setAttribute("aria-checked", enabled);
50718        if (enabled) {
50719          controller.setAttribute("mediasubtitlesshowing", "on");
50720        } else {
50721          controller.removeAttribute("mediasubtitlesshowing");
50722        }
50723      });
50724
50725      provider.addEventListener("qualitychange", (e) => {
50726        qualityBtn.textContent = e.detail.quality;
50727      });
50728    }
50729
50730    function initLazyPlayers() {
50731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
50732        if (details.dataset.lazyInit) return;
50733        details.dataset.lazyInit = "true";
50734        details.addEventListener("toggle", () => {
50735          if (details.open && !details.querySelector(".player-container")) {
50736            createPlayer(details);
50737          } else if (!details.open) {
50738            const provider = details.querySelector("youtube-media-provider");
50739            if (provider && typeof provider.pause === "function") {
50740              provider.pause();
50741            }
50742          }
50743        });
50744      });
50745    }
50746
50747    if (document.readyState === "loading") {
50748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
50749    } else {
50750      initLazyPlayers();
50751    }
50752  })();
50753</script>
50753<script>
50754  (() => {
50755    if (window.__courseCompletionsInitialized) return;
50756    window.__courseCompletionsInitialized = true;
50757
50758    const STORAGE_KEY = "course-completions";
50759
50760    function slugify(text) {
50761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
50762    }
50763
50764    function getCompletions() {
50765      try {
50766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
50767      } catch {
50768        return [];
50769      }
50770    }
50771
50772    function saveCompletions(completions) {
50773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
50774    }
50775
50776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
50777    function canonicalFromStored(storedId) {
50778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50779      if (!m) return slugify(storedId);
50780      const [, slug, p, mv, title] = m;
50781      return `${slug}-${p}-${mv}-${slugify(title)}`;
50782    }
50783
50784    function canonicalPlusOne(storedId) {
50785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
50786      if (!m) return null;
50787      const [, slug, p, mv, title] = m;
50788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
50789    }
50790
50791    function isCompleted(canonicalId, completions) {
50792      if (completions.includes(canonicalId)) return true;
50793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
50794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
50795    }
50796
50797    function migrateCompletions() {
50798      const completions = getCompletions();
50799      let changed = false;
50800      const migrated = completions.map((c) => {
50801        const canon = canonicalFromStored(c);
50802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
50803        const plusOne = canonicalPlusOne(c);
50804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
50805          if (plusOne !== c) changed = true;
50806          return plusOne;
50807        }
50808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
50809          changed = true;
50810          return canon;
50811        }
50812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
50813        if (canon !== c) {
50814          changed = true;
50815          return canon;
50816        }
50817        return c;
50818      });
50819      // Elimina duplicados tras migración
50820      const deduped = [...new Set(migrated)];
50821      if (changed || deduped.length !== completions.length) {
50822        saveCompletions(deduped);
50823        return deduped;
50824      }
50825      return completions;
50826    }
50827
50828    function updateButtonState(btn, completions) {
50829      const id = btn.dataset.completionId;
50830      const list = completions ?? getCompletions();
50831      btn.classList.toggle("completed", isCompleted(id, list));
50832    }
50833
50834    function checkModuleCompletion(completions) {
50835      const list = completions ?? getCompletions();
50836      document.querySelectorAll(".course-video").forEach((module) => {
50837        const btns = module.querySelectorAll(".complete-btn");
50838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
50839        const check = module.querySelector(".module-check");
50840        if (check) check.classList.toggle("visible", allCompleted);
50841      });
50842    }
50843
50844    function initCompletionButtons() {
50845      const completions = migrateCompletions();
50846
50847      document.querySelectorAll(".complete-btn").forEach((btn) => {
50848        if (btn.dataset.completionInit) return;
50849        btn.dataset.completionInit = "true";
50850        updateButtonState(btn, completions);
50851
50852        btn.addEventListener("click", (e) => {
50853          e.preventDefault();
50854          e.stopPropagation();
50855
50856          const id = btn.dataset.completionId;
50857          let list = getCompletions();
50858          const completed = isCompleted(id, list);
50859
50860          if (completed) {
50861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
50862            list = list.filter((c) =>
50862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
50863          } else {
50864            list.push(id);
50865          }
50866
50867          saveCompletions(list);
50868          updateButtonState(btn, list);
50869          checkModuleCompletion(list);
50870        });
50871      });
50872
50873      checkModuleCompletion(completions);
50874    }
50875
50876    if (document.readyState === "loading") {
50877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
50878    } else {
50879      initCompletionButtons();
50880    }
50881  })();
50882</script>
50882<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="13608" data-end="14535" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. Patrón repositorio y separar datos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-patron-repositorio-y-separar-datos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
50882<script>
50883  (() => {
50884    if (window.__courseLazyPlayersInitialized) return;
50885    window.__courseLazyPlayersInitialized = true;
50886
50887    function createPlayer(details) {
50888      const videoId = details.dataset.videoId;
50889      const start = details.dataset.start;
50890      const end = details.dataset.end;
50891
50892      const container = document.createElement("div");
50893      container.className = "player-container";
50894
50895      const spinner = document.createElement("div");
50896      spinner.className = "spinner";
50897      container.appendChild(spinner);
50898
50899      const controller = document.createElement("media-controller");
50900      controller.setAttribute("defaultsubtitles", "");
50901
50902      const provider = document.createElement("youtube-media-provider");
50903      provider.setAttribute("slot", "media");
50904      provider.setAttribute("video-id", videoId);
50905      provider.setAttribute("start", start);
50906      provider.setAttribute("end", end);
50907
50908      const controlBar = document.createElement("media-control-bar");
50909
50910      const playBtn = document.createElement("media-play-button");
50911      const timeDisplay = document.createElement("media-time-display");
50912      timeDisplay.setAttribute("showduration", "");
50913      const timeRange = document.createElement("media-time-range");
50914      const playbackRate = document.createElement("media-playback-rate-button");
50915      const muteBtn = document.createElement("media-mute-button");
50916      const volumeRange = document.createElement("media-volume-range");
50917
50918      const captionsBtn = document.createElement("media-captions-button");
50919      captionsBtn.addEventListener("click", (e) => {
50920        e.stopPropagation();
50921        const provider = container.querySelector("youtube-media-provider");
50922        if (provider) provider.toggleCaptions();
50923      });
50924
50925      const qualityBtn = document.createElement("media-chrome-button");
50926      qualityBtn.textContent = "--";
50927      qualityBtn.addEventListener("click", () => {
50928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
50929      });
50930
50931      const fullscreenBtn = document.createElement("media-fullscreen-button");
50932
50933      controlBar.append(
50934        playBtn, timeDisplay, timeRange,
50935        playbackRate, muteBtn, volumeRange,
50936        captionsBtn, qualityBtn, fullscreenBtn
50937      );
50938
50939      const poster = document.createElement("media-poster-image");
50940      poster.setAttribute("slot", "poster");
50941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
50942
50943      controller.append(provider, poster, controlBar);
50944
50945      captionsBtn.setAttribute("aria-checked", "true");
50946      controller.setAttribute("mediasubtitlesshowing", "on");
50947
50948      container.appendChild(controller);
50949      details.appendChild(container);
50950
50951      provider.addEventListener("waiting", () => container.classList.add("loading"));
50952      provider.addEventListener("playing", () => container.classList.remove("loading"));
50953      provider.addEventListener("play", () => container.classList.remove("loading"));
50954
50955      provider._textTracks.addEventListener("change", () => {
50956        const enabled = provider._captionsEnabled;
50957        captionsBtn.setAttribute("aria-checked", enabled);
50958        if (enabled) {
50959          controller.setAttribute("mediasubtitlesshowing", "on");
50960        } else {
50961          controller.removeAttribute("mediasubtitlesshowing");
50962        }
50963      });
50964
50965      provider.addEventListener("qualitychange", (e) => {
50966        qualityBtn.textContent = e.detail.quality;
50967      });
50968    }
50969
50970    function initLazyPlayers() {
50971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
50972        if (details.dataset.lazyInit) return;
50973        details.dataset.lazyInit = "true";
50974        details.addEventListener("toggle", () => {
50975          if (details.open && !details.querySelector(".player-container")) {
50976            createPlayer(details);
50977          } else if (!details.open) {
50978            const provider = details.querySelector("youtube-media-provider");
50979            if (provider && typeof provider.pause === "function") {
50980              provider.pause();
50981            }
50982          }
50983        });
50984      });
50985    }
50986
50987    if (document.readyState === "loading") {
50988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
50989    } else {
50990      initLazyPlayers();
50991    }
50992  })();
50993</script>
50993<script>
50994  (() => {
50995    if (window.__courseCompletionsInitialized) return;
50996    window.__courseCompletionsInitialized = true;
50997
50998    const STORAGE_KEY = "course-completions";
50999
51000    function slugify(text) {
51001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
51002    }
51003
51004    function getCompletions() {
51005      try {
51006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
51007      } catch {
51008        return [];
51009      }
51010    }
51011
51012    function saveCompletions(completions) {
51013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
51014    }
51015
51016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
51017    function canonicalFromStored(storedId) {
51018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51019      if (!m) return slugify(storedId);
51020      const [, slug, p, mv, title] = m;
51021      return `${slug}-${p}-${mv}-${slugify(title)}`;
51022    }
51023
51024    function canonicalPlusOne(storedId) {
51025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51026      if (!m) return null;
51027      const [, slug, p, mv, title] = m;
51028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
51029    }
51030
51031    function isCompleted(canonicalId, completions) {
51032      if (completions.includes(canonicalId)) return true;
51033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
51034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
51035    }
51036
51037    function migrateCompletions() {
51038      const completions = getCompletions();
51039      let changed = false;
51040      const migrated = completions.map((c) => {
51041        const canon = canonicalFromStored(c);
51042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
51043        const plusOne = canonicalPlusOne(c);
51044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
51045          if (plusOne !== c) changed = true;
51046          return plusOne;
51047        }
51048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
51049          changed = true;
51050          return canon;
51051        }
51052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
51053        if (canon !== c) {
51054          changed = true;
51055          return canon;
51056        }
51057        return c;
51058      });
51059      // Elimina duplicados tras migración
51060      const deduped = [...new Set(migrated)];
51061      if (changed || deduped.length !== completions.length) {
51062        saveCompletions(deduped);
51063        return deduped;
51064      }
51065      return completions;
51066    }
51067
51068    function updateButtonState(btn, completions) {
51069      const id = btn.dataset.completionId;
51070      const list = completions ?? getCompletions();
51071      btn.classList.toggle("completed", isCompleted(id, list));
51072    }
51073
51074    function checkModuleCompletion(completions) {
51075      const list = completions ?? getCompletions();
51076      document.querySelectorAll(".course-video").forEach((module) => {
51077        const btns = module.querySelectorAll(".complete-btn");
51078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
51079        const check = module.querySelector(".module-check");
51080        if (check) check.classList.toggle("visible", allCompleted);
51081      });
51082    }
51083
51084    function initCompletionButtons() {
51085      const completions = migrateCompletions();
51086
51087      document.querySelectorAll(".complete-btn").forEach((btn) => {
51088        if (btn.dataset.completionInit) return;
51089        btn.dataset.completionInit = "true";
51090        updateButtonState(btn, completions);
51091
51092        btn.addEventListener("click", (e) => {
51093          e.preventDefault();
51094          e.stopPropagation();
51095
51096          const id = btn.dataset.completionId;
51097          let list = getCompletions();
51098          const completed = isCompleted(id, list);
51099
51100          if (completed) {
51101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
51102            list = list.filter((c) =>
51102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
51103          } else {
51104            list.push(id);
51105          }
51106
51107          saveCompletions(list);
51108          updateButtonState(btn, list);
51109          checkModuleCompletion(list);
51110        });
51111      });
51112
51113      checkModuleCompletion(completions);
51114    }
51115
51116    if (document.readyState === "loading") {
51117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
51118    } else {
51119      initCompletionButtons();
51120    }
51121  })();
51122</script>
51122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="rOZyQuSwSS0" data-start="14535" data-end="16860" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>13. SQLite</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>39 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-2-sqlite" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
51122<script>
51123  (() => {
51124    if (window.__courseLazyPlayersInitialized) return;
51125    window.__courseLazyPlayersInitialized = true;
51126
51127    function createPlayer(details) {
51128      const videoId = details.dataset.videoId;
51129      const start = details.dataset.start;
51130      const end = details.dataset.end;
51131
51132      const container = document.createElement("div");
51133      container.className = "player-container";
51134
51135      const spinner = document.createElement("div");
51136      spinner.className = "spinner";
51137      container.appendChild(spinner);
51138
51139      const controller = document.createElement("media-controller");
51140      controller.setAttribute("defaultsubtitles", "");
51141
51142      const provider = document.createElement("youtube-media-provider");
51143      provider.setAttribute("slot", "media");
51144      provider.setAttribute("video-id", videoId);
51145      provider.setAttribute("start", start);
51146      provider.setAttribute("end", end);
51147
51148      const controlBar = document.createElement("media-control-bar");
51149
51150      const playBtn = document.createElement("media-play-button");
51151      const timeDisplay = document.createElement("media-time-display");
51152      timeDisplay.setAttribute("showduration", "");
51153      const timeRange = document.createElement("media-time-range");
51154      const playbackRate = document.createElement("media-playback-rate-button");
51155      const muteBtn = document.createElement("media-mute-button");
51156      const volumeRange = document.createElement("media-volume-range");
51157
51158      const captionsBtn = document.createElement("media-captions-button");
51159      captionsBtn.addEventListener("click", (e) => {
51160        e.stopPropagation();
51161        const provider = container.querySelector("youtube-media-provider");
51162        if (provider) provider.toggleCaptions();
51163      });
51164
51165      const qualityBtn = document.createElement("media-chrome-button");
51166      qualityBtn.textContent = "--";
51167      qualityBtn.addEventListener("click", () => {
51168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
51169      });
51170
51171      const fullscreenBtn = document.createElement("media-fullscreen-button");
51172
51173      controlBar.append(
51174        playBtn, timeDisplay, timeRange,
51175        playbackRate, muteBtn, volumeRange,
51176        captionsBtn, qualityBtn, fullscreenBtn
51177      );
51178
51179      const poster = document.createElement("media-poster-image");
51180      poster.setAttribute("slot", "poster");
51181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
51182
51183      controller.append(provider, poster, controlBar);
51184
51185      captionsBtn.setAttribute("aria-checked", "true");
51186      controller.setAttribute("mediasubtitlesshowing", "on");
51187
51188      container.appendChild(controller);
51189      details.appendChild(container);
51190
51191      provider.addEventListener("waiting", () => container.classList.add("loading"));
51192      provider.addEventListener("playing", () => container.classList.remove("loading"));
51193      provider.addEventListener("play", () => container.classList.remove("loading"));
51194
51195      provider._textTracks.addEventListener("change", () => {
51196        const enabled = provider._captionsEnabled;
51197        captionsBtn.setAttribute("aria-checked", enabled);
51198        if (enabled) {
51199          controller.setAttribute("mediasubtitlesshowing", "on");
51200        } else {
51201          controller.removeAttribute("mediasubtitlesshowing");
51202        }
51203      });
51204
51205      provider.addEventListener("qualitychange", (e) => {
51206        qualityBtn.textContent = e.detail.quality;
51207      });
51208    }
51209
51210    function initLazyPlayers() {
51211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
51212        if (details.dataset.lazyInit) return;
51213        details.dataset.lazyInit = "true";
51214        details.addEventListener("toggle", () => {
51215          if (details.open && !details.querySelector(".player-container")) {
51216            createPlayer(details);
51217          } else if (!details.open) {
51218            const provider = details.querySelector("youtube-media-provider");
51219            if (provider && typeof provider.pause === "function") {
51220              provider.pause();
51221            }
51222          }
51223        });
51224      });
51225    }
51226
51227    if (document.readyState === "loading") {
51228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
51229    } else {
51230      initLazyPlayers();
51231    }
51232  })();
51233</script>
51233<script>
51234  (() => {
51235    if (window.__courseCompletionsInitialized) return;
51236    window.__courseCompletionsInitialized = true;
51237
51238    const STORAGE_KEY = "course-completions";
51239
51240    function slugify(text) {
51241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
51242    }
51243
51244    function getCompletions() {
51245      try {
51246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
51247      } catch {
51248        return [];
51249      }
51250    }
51251
51252    function saveCompletions(completions) {
51253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
51254    }
51255
51256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
51257    function canonicalFromStored(storedId) {
51258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51259      if (!m) return slugify(storedId);
51260      const [, slug, p, mv, title] = m;
51261      return `${slug}-${p}-${mv}-${slugify(title)}`;
51262    }
51263
51264    function canonicalPlusOne(storedId) {
51265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51266      if (!m) return null;
51267      const [, slug, p, mv, title] = m;
51268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
51269    }
51270
51271    function isCompleted(canonicalId, completions) {
51272      if (completions.includes(canonicalId)) return true;
51273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
51274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
51275    }
51276
51277    function migrateCompletions() {
51278      const completions = getCompletions();
51279      let changed = false;
51280      const migrated = completions.map((c) => {
51281        const canon = canonicalFromStored(c);
51282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
51283        const plusOne = canonicalPlusOne(c);
51284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
51285          if (plusOne !== c) changed = true;
51286          return plusOne;
51287        }
51288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
51289          changed = true;
51290          return canon;
51291        }
51292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
51293        if (canon !== c) {
51294          changed = true;
51295          return canon;
51296        }
51297        return c;
51298      });
51299      // Elimina duplicados tras migración
51300      const deduped = [...new Set(migrated)];
51301      if (changed || deduped.length !== completions.length) {
51302        saveCompletions(deduped);
51303        return deduped;
51304      }
51305      return completions;
51306    }
51307
51308    function updateButtonState(btn, completions) {
51309      const id = btn.dataset.completionId;
51310      const list = completions ?? getCompletions();
51311      btn.classList.toggle("completed", isCompleted(id, list));
51312    }
51313
51314    function checkModuleCompletion(completions) {
51315      const list = completions ?? getCompletions();
51316      document.querySelectorAll(".course-video").forEach((module) => {
51317        const btns = module.querySelectorAll(".complete-btn");
51318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
51319        const check = module.querySelector(".module-check");
51320        if (check) check.classList.toggle("visible", allCompleted);
51321      });
51322    }
51323
51324    function initCompletionButtons() {
51325      const completions = migrateCompletions();
51326
51327      document.querySelectorAll(".complete-btn").forEach((btn) => {
51328        if (btn.dataset.completionInit) return;
51329        btn.dataset.completionInit = "true";
51330        updateButtonState(btn, completions);
51331
51332        btn.addEventListener("click", (e) => {
51333          e.preventDefault();
51334          e.stopPropagation();
51335
51336          const id = btn.dataset.completionId;
51337          let list = getCompletions();
51338          const completed = isCompleted(id, list);
51339
51340          if (completed) {
51341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
51342            list = list.filter((c) =>
51342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
51343          } else {
51344            list.push(id);
51345          }
51346
51347          saveCompletions(list);
51348          updateButtonState(btn, list);
51349          checkModuleCompletion(list);
51350        });
51351      });
51352
51353      checkModuleCompletion(completions);
51354    }
51355
51356    if (document.readyState === "loading") {
51357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
51358    } else {
51359      initCompletionButtons();
51360    }
51361  })();
51362</script>
51362</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 19 📗 Astro<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="1500" data-end="3270" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción a Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>30 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-introduccion-a-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
51362<script>
51363  (() => {
51364    if (window.__courseLazyPlayersInitialized) return;
51365    window.__courseLazyPlayersInitialized = true;
51366
51367    function createPlayer(details) {
51368      const videoId = details.dataset.videoId;
51369      const start = details.dataset.start;
51370      const end = details.dataset.end;
51371
51372      const container = document.createElement("div");
51373      container.className = "player-container";
51374
51375      const spinner = document.createElement("div");
51376      spinner.className = "spinner";
51377      container.appendChild(spinner);
51378
51379      const controller = document.createElement("media-controller");
51380      controller.setAttribute("defaultsubtitles", "");
51381
51382      const provider = document.createElement("youtube-media-provider");
51383      provider.setAttribute("slot", "media");
51384      provider.setAttribute("video-id", videoId);
51385      provider.setAttribute("start", start);
51386      provider.setAttribute("end", end);
51387
51388      const controlBar = document.createElement("media-control-bar");
51389
51390      const playBtn = document.createElement("media-play-button");
51391      const timeDisplay = document.createElement("media-time-display");
51392      timeDisplay.setAttribute("showduration", "");
51393      const timeRange = document.createElement("media-time-range");
51394      const playbackRate = document.createElement("media-playback-rate-button");
51395      const muteBtn = document.createElement("media-mute-button");
51396      const volumeRange = document.createElement("media-volume-range");
51397
51398      const captionsBtn = document.createElement("media-captions-button");
51399      captionsBtn.addEventListener("click", (e) => {
51400        e.stopPropagation();
51401        const provider = container.querySelector("youtube-media-provider");
51402        if (provider) provider.toggleCaptions();
51403      });
51404
51405      const qualityBtn = document.createElement("media-chrome-button");
51406      qualityBtn.textContent = "--";
51407      qualityBtn.addEventListener("click", () => {
51408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
51409      });
51410
51411      const fullscreenBtn = document.createElement("media-fullscreen-button");
51412
51413      controlBar.append(
51414        playBtn, timeDisplay, timeRange,
51415        playbackRate, muteBtn, volumeRange,
51416        captionsBtn, qualityBtn, fullscreenBtn
51417      );
51418
51419      const poster = document.createElement("media-poster-image");
51420      poster.setAttribute("slot", "poster");
51421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
51422
51423      controller.append(provider, poster, controlBar);
51424
51425      captionsBtn.setAttribute("aria-checked", "true");
51426      controller.setAttribute("mediasubtitlesshowing", "on");
51427
51428      container.appendChild(controller);
51429      details.appendChild(container);
51430
51431      provider.addEventListener("waiting", () => container.classList.add("loading"));
51432      provider.addEventListener("playing", () => container.classList.remove("loading"));
51433      provider.addEventListener("play", () => container.classList.remove("loading"));
51434
51435      provider._textTracks.addEventListener("change", () => {
51436        const enabled = provider._captionsEnabled;
51437        captionsBtn.setAttribute("aria-checked", enabled);
51438        if (enabled) {
51439          controller.setAttribute("mediasubtitlesshowing", "on");
51440        } else {
51441          controller.removeAttribute("mediasubtitlesshowing");
51442        }
51443      });
51444
51445      provider.addEventListener("qualitychange", (e) => {
51446        qualityBtn.textContent = e.detail.quality;
51447      });
51448    }
51449
51450    function initLazyPlayers() {
51451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
51452        if (details.dataset.lazyInit) return;
51453        details.dataset.lazyInit = "true";
51454        details.addEventListener("toggle", () => {
51455          if (details.open && !details.querySelector(".player-container")) {
51456            createPlayer(details);
51457          } else if (!details.open) {
51458            const provider = details.querySelector("youtube-media-provider");
51459            if (provider && typeof provider.pause === "function") {
51460              provider.pause();
51461            }
51462          }
51463        });
51464      });
51465    }
51466
51467    if (document.readyState === "loading") {
51468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
51469    } else {
51470      initLazyPlayers();
51471    }
51472  })();
51473</script>
51473<script>
51474  (() => {
51475    if (window.__courseCompletionsInitialized) return;
51476    window.__courseCompletionsInitialized = true;
51477
51478    const STORAGE_KEY = "course-completions";
51479
51480    function slugify(text) {
51481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
51482    }
51483
51484    function getCompletions() {
51485      try {
51486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
51487      } catch {
51488        return [];
51489      }
51490    }
51491
51492    function saveCompletions(completions) {
51493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
51494    }
51495
51496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
51497    function canonicalFromStored(storedId) {
51498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51499      if (!m) return slugify(storedId);
51500      const [, slug, p, mv, title] = m;
51501      return `${slug}-${p}-${mv}-${slugify(title)}`;
51502    }
51503
51504    function canonicalPlusOne(storedId) {
51505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51506      if (!m) return null;
51507      const [, slug, p, mv, title] = m;
51508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
51509    }
51510
51511    function isCompleted(canonicalId, completions) {
51512      if (completions.includes(canonicalId)) return true;
51513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
51514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
51515    }
51516
51517    function migrateCompletions() {
51518      const completions = getCompletions();
51519      let changed = false;
51520      const migrated = completions.map((c) => {
51521        const canon = canonicalFromStored(c);
51522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
51523        const plusOne = canonicalPlusOne(c);
51524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
51525          if (plusOne !== c) changed = true;
51526          return plusOne;
51527        }
51528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
51529          changed = true;
51530          return canon;
51531        }
51532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
51533        if (canon !== c) {
51534          changed = true;
51535          return canon;
51536        }
51537        return c;
51538      });
51539      // Elimina duplicados tras migración
51540      const deduped = [...new Set(migrated)];
51541      if (changed || deduped.length !== completions.length) {
51542        saveCompletions(deduped);
51543        return deduped;
51544      }
51545      return completions;
51546    }
51547
51548    function updateButtonState(btn, completions) {
51549      const id = btn.dataset.completionId;
51550      const list = completions ?? getCompletions();
51551      btn.classList.toggle("completed", isCompleted(id, list));
51552    }
51553
51554    function checkModuleCompletion(completions) {
51555      const list = completions ?? getCompletions();
51556      document.querySelectorAll(".course-video").forEach((module) => {
51557        const btns = module.querySelectorAll(".complete-btn");
51558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
51559        const check = module.querySelector(".module-check");
51560        if (check) check.classList.toggle("visible", allCompleted);
51561      });
51562    }
51563
51564    function initCompletionButtons() {
51565      const completions = migrateCompletions();
51566
51567      document.querySelectorAll(".complete-btn").forEach((btn) => {
51568        if (btn.dataset.completionInit) return;
51569        btn.dataset.completionInit = "true";
51570        updateButtonState(btn, completions);
51571
51572        btn.addEventListener("click", (e) => {
51573          e.preventDefault();
51574          e.stopPropagation();
51575
51576          const id = btn.dataset.completionId;
51577          let list = getCompletions();
51578          const completed = isCompleted(id, list);
51579
51580          if (completed) {
51581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
51582            list = list.filter((c) =>
51582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
51583          } else {
51584            list.push(id);
51585          }
51586
51587          saveCompletions(list);
51588          updateButtonState(btn, list);
51589          checkModuleCompletion(list);
51590        });
51591      });
51592
51593      checkModuleCompletion(completions);
51594    }
51595
51596    if (document.readyState === "loading") {
51597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
51598    } else {
51599      initCompletionButtons();
51600    }
51601  })();
51602</script>
51602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="3270" data-end="3945" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Instalación de Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-instalacion-de-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
51602<script>
51603  (() => {
51604    if (window.__courseLazyPlayersInitialized) return;
51605    window.__courseLazyPlayersInitialized = true;
51606
51607    function createPlayer(details) {
51608      const videoId = details.dataset.videoId;
51609      const start = details.dataset.start;
51610      const end = details.dataset.end;
51611
51612      const container = document.createElement("div");
51613      container.className = "player-container";
51614
51615      const spinner = document.createElement("div");
51616      spinner.className = "spinner";
51617      container.appendChild(spinner);
51618
51619      const controller = document.createElement("media-controller");
51620      controller.setAttribute("defaultsubtitles", "");
51621
51622      const provider = document.createElement("youtube-media-provider");
51623      provider.setAttribute("slot", "media");
51624      provider.setAttribute("video-id", videoId);
51625      provider.setAttribute("start", start);
51626      provider.setAttribute("end", end);
51627
51628      const controlBar = document.createElement("media-control-bar");
51629
51630      const playBtn = document.createElement("media-play-button");
51631      const timeDisplay = document.createElement("media-time-display");
51632      timeDisplay.setAttribute("showduration", "");
51633      const timeRange = document.createElement("media-time-range");
51634      const playbackRate = document.createElement("media-playback-rate-button");
51635      const muteBtn = document.createElement("media-mute-button");
51636      const volumeRange = document.createElement("media-volume-range");
51637
51638      const captionsBtn = document.createElement("media-captions-button");
51639      captionsBtn.addEventListener("click", (e) => {
51640        e.stopPropagation();
51641        const provider = container.querySelector("youtube-media-provider");
51642        if (provider) provider.toggleCaptions();
51643      });
51644
51645      const qualityBtn = document.createElement("media-chrome-button");
51646      qualityBtn.textContent = "--";
51647      qualityBtn.addEventListener("click", () => {
51648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
51649      });
51650
51651      const fullscreenBtn = document.createElement("media-fullscreen-button");
51652
51653      controlBar.append(
51654        playBtn, timeDisplay, timeRange,
51655        playbackRate, muteBtn, volumeRange,
51656        captionsBtn, qualityBtn, fullscreenBtn
51657      );
51658
51659      const poster = document.createElement("media-poster-image");
51660      poster.setAttribute("slot", "poster");
51661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
51662
51663      controller.append(provider, poster, controlBar);
51664
51665      captionsBtn.setAttribute("aria-checked", "true");
51666      controller.setAttribute("mediasubtitlesshowing", "on");
51667
51668      container.appendChild(controller);
51669      details.appendChild(container);
51670
51671      provider.addEventListener("waiting", () => container.classList.add("loading"));
51672      provider.addEventListener("playing", () => container.classList.remove("loading"));
51673      provider.addEventListener("play", () => container.classList.remove("loading"));
51674
51675      provider._textTracks.addEventListener("change", () => {
51676        const enabled = provider._captionsEnabled;
51677        captionsBtn.setAttribute("aria-checked", enabled);
51678        if (enabled) {
51679          controller.setAttribute("mediasubtitlesshowing", "on");
51680        } else {
51681          controller.removeAttribute("mediasubtitlesshowing");
51682        }
51683      });
51684
51685      provider.addEventListener("qualitychange", (e) => {
51686        qualityBtn.textContent = e.detail.quality;
51687      });
51688    }
51689
51690    function initLazyPlayers() {
51691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
51692        if (details.dataset.lazyInit) return;
51693        details.dataset.lazyInit = "true";
51694        details.addEventListener("toggle", () => {
51695          if (details.open && !details.querySelector(".player-container")) {
51696            createPlayer(details);
51697          } else if (!details.open) {
51698            const provider = details.querySelector("youtube-media-provider");
51699            if (provider && typeof provider.pause === "function") {
51700              provider.pause();
51701            }
51702          }
51703        });
51704      });
51705    }
51706
51707    if (document.readyState === "loading") {
51708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
51709    } else {
51710      initLazyPlayers();
51711    }
51712  })();
51713</script>
51713<script>
51714  (() => {
51715    if (window.__courseCompletionsInitialized) return;
51716    window.__courseCompletionsInitialized = true;
51717
51718    const STORAGE_KEY = "course-completions";
51719
51720    function slugify(text) {
51721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
51722    }
51723
51724    function getCompletions() {
51725      try {
51726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
51727      } catch {
51728        return [];
51729      }
51730    }
51731
51732    function saveCompletions(completions) {
51733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
51734    }
51735
51736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
51737    function canonicalFromStored(storedId) {
51738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51739      if (!m) return slugify(storedId);
51740      const [, slug, p, mv, title] = m;
51741      return `${slug}-${p}-${mv}-${slugify(title)}`;
51742    }
51743
51744    function canonicalPlusOne(storedId) {
51745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51746      if (!m) return null;
51747      const [, slug, p, mv, title] = m;
51748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
51749    }
51750
51751    function isCompleted(canonicalId, completions) {
51752      if (completions.includes(canonicalId)) return true;
51753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
51754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
51755    }
51756
51757    function migrateCompletions() {
51758      const completions = getCompletions();
51759      let changed = false;
51760      const migrated = completions.map((c) => {
51761        const canon = canonicalFromStored(c);
51762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
51763        const plusOne = canonicalPlusOne(c);
51764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
51765          if (plusOne !== c) changed = true;
51766          return plusOne;
51767        }
51768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
51769          changed = true;
51770          return canon;
51771        }
51772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
51773        if (canon !== c) {
51774          changed = true;
51775          return canon;
51776        }
51777        return c;
51778      });
51779      // Elimina duplicados tras migración
51780      const deduped = [...new Set(migrated)];
51781      if (changed || deduped.length !== completions.length) {
51782        saveCompletions(deduped);
51783        return deduped;
51784      }
51785      return completions;
51786    }
51787
51788    function updateButtonState(btn, completions) {
51789      const id = btn.dataset.completionId;
51790      const list = completions ?? getCompletions();
51791      btn.classList.toggle("completed", isCompleted(id, list));
51792    }
51793
51794    function checkModuleCompletion(completions) {
51795      const list = completions ?? getCompletions();
51796      document.querySelectorAll(".course-video").forEach((module) => {
51797        const btns = module.querySelectorAll(".complete-btn");
51798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
51799        const check = module.querySelector(".module-check");
51800        if (check) check.classList.toggle("visible", allCompleted);
51801      });
51802    }
51803
51804    function initCompletionButtons() {
51805      const completions = migrateCompletions();
51806
51807      document.querySelectorAll(".complete-btn").forEach((btn) => {
51808        if (btn.dataset.completionInit) return;
51809        btn.dataset.completionInit = "true";
51810        updateButtonState(btn, completions);
51811
51812        btn.addEventListener("click", (e) => {
51813          e.preventDefault();
51814          e.stopPropagation();
51815
51816          const id = btn.dataset.completionId;
51817          let list = getCompletions();
51818          const completed = isCompleted(id, list);
51819
51820          if (completed) {
51821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
51822            list = list.filter((c) =>
51822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
51823          } else {
51824            list.push(id);
51825          }
51826
51827          saveCompletions(list);
51828          updateButtonState(btn, list);
51829          checkModuleCompletion(list);
51830        });
51831      });
51832
51833      checkModuleCompletion(completions);
51834    }
51835
51836    if (document.readyState === "loading") {
51837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
51838    } else {
51839      initCompletionButtons();
51840    }
51841  })();
51842</script>
51842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="3945" data-end="5280" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Estructura de carpetas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>22 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-estructura-de-carpetas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
51842<script>
51843  (() => {
51844    if (window.__courseLazyPlayersInitialized) return;
51845    window.__courseLazyPlayersInitialized = true;
51846
51847    function createPlayer(details) {
51848      const videoId = details.dataset.videoId;
51849      const start = details.dataset.start;
51850      const end = details.dataset.end;
51851
51852      const container = document.createElement("div");
51853      container.className = "player-container";
51854
51855      const spinner = document.createElement("div");
51856      spinner.className = "spinner";
51857      container.appendChild(spinner);
51858
51859      const controller = document.createElement("media-controller");
51860      controller.setAttribute("defaultsubtitles", "");
51861
51862      const provider = document.createElement("youtube-media-provider");
51863      provider.setAttribute("slot", "media");
51864      provider.setAttribute("video-id", videoId);
51865      provider.setAttribute("start", start);
51866      provider.setAttribute("end", end);
51867
51868      const controlBar = document.createElement("media-control-bar");
51869
51870      const playBtn = document.createElement("media-play-button");
51871      const timeDisplay = document.createElement("media-time-display");
51872      timeDisplay.setAttribute("showduration", "");
51873      const timeRange = document.createElement("media-time-range");
51874      const playbackRate = document.createElement("media-playback-rate-button");
51875      const muteBtn = document.createElement("media-mute-button");
51876      const volumeRange = document.createElement("media-volume-range");
51877
51878      const captionsBtn = document.createElement("media-captions-button");
51879      captionsBtn.addEventListener("click", (e) => {
51880        e.stopPropagation();
51881        const provider = container.querySelector("youtube-media-provider");
51882        if (provider) provider.toggleCaptions();
51883      });
51884
51885      const qualityBtn = document.createElement("media-chrome-button");
51886      qualityBtn.textContent = "--";
51887      qualityBtn.addEventListener("click", () => {
51888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
51889      });
51890
51891      const fullscreenBtn = document.createElement("media-fullscreen-button");
51892
51893      controlBar.append(
51894        playBtn, timeDisplay, timeRange,
51895        playbackRate, muteBtn, volumeRange,
51896        captionsBtn, qualityBtn, fullscreenBtn
51897      );
51898
51899      const poster = document.createElement("media-poster-image");
51900      poster.setAttribute("slot", "poster");
51901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
51902
51903      controller.append(provider, poster, controlBar);
51904
51905      captionsBtn.setAttribute("aria-checked", "true");
51906      controller.setAttribute("mediasubtitlesshowing", "on");
51907
51908      container.appendChild(controller);
51909      details.appendChild(container);
51910
51911      provider.addEventListener("waiting", () => container.classList.add("loading"));
51912      provider.addEventListener("playing", () => container.classList.remove("loading"));
51913      provider.addEventListener("play", () => container.classList.remove("loading"));
51914
51915      provider._textTracks.addEventListener("change", () => {
51916        const enabled = provider._captionsEnabled;
51917        captionsBtn.setAttribute("aria-checked", enabled);
51918        if (enabled) {
51919          controller.setAttribute("mediasubtitlesshowing", "on");
51920        } else {
51921          controller.removeAttribute("mediasubtitlesshowing");
51922        }
51923      });
51924
51925      provider.addEventListener("qualitychange", (e) => {
51926        qualityBtn.textContent = e.detail.quality;
51927      });
51928    }
51929
51930    function initLazyPlayers() {
51931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
51932        if (details.dataset.lazyInit) return;
51933        details.dataset.lazyInit = "true";
51934        details.addEventListener("toggle", () => {
51935          if (details.open && !details.querySelector(".player-container")) {
51936            createPlayer(details);
51937          } else if (!details.open) {
51938            const provider = details.querySelector("youtube-media-provider");
51939            if (provider && typeof provider.pause === "function") {
51940              provider.pause();
51941            }
51942          }
51943        });
51944      });
51945    }
51946
51947    if (document.readyState === "loading") {
51948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
51949    } else {
51950      initLazyPlayers();
51951    }
51952  })();
51953</script>
51953<script>
51954  (() => {
51955    if (window.__courseCompletionsInitialized) return;
51956    window.__courseCompletionsInitialized = true;
51957
51958    const STORAGE_KEY = "course-completions";
51959
51960    function slugify(text) {
51961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
51962    }
51963
51964    function getCompletions() {
51965      try {
51966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
51967      } catch {
51968        return [];
51969      }
51970    }
51971
51972    function saveCompletions(completions) {
51973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
51974    }
51975
51976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
51977    function canonicalFromStored(storedId) {
51978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51979      if (!m) return slugify(storedId);
51980      const [, slug, p, mv, title] = m;
51981      return `${slug}-${p}-${mv}-${slugify(title)}`;
51982    }
51983
51984    function canonicalPlusOne(storedId) {
51985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
51986      if (!m) return null;
51987      const [, slug, p, mv, title] = m;
51988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
51989    }
51990
51991    function isCompleted(canonicalId, completions) {
51992      if (completions.includes(canonicalId)) return true;
51993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
51994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
51995    }
51996
51997    function migrateCompletions() {
51998      const completions = getCompletions();
51999      let changed = false;
52000      const migrated = completions.map((c) => {
52001        const canon = canonicalFromStored(c);
52002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
52003        const plusOne = canonicalPlusOne(c);
52004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
52005          if (plusOne !== c) changed = true;
52006          return plusOne;
52007        }
52008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
52009          changed = true;
52010          return canon;
52011        }
52012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
52013        if (canon !== c) {
52014          changed = true;
52015          return canon;
52016        }
52017        return c;
52018      });
52019      // Elimina duplicados tras migración
52020      const deduped = [...new Set(migrated)];
52021      if (changed || deduped.length !== completions.length) {
52022        saveCompletions(deduped);
52023        return deduped;
52024      }
52025      return completions;
52026    }
52027
52028    function updateButtonState(btn, completions) {
52029      const id = btn.dataset.completionId;
52030      const list = completions ?? getCompletions();
52031      btn.classList.toggle("completed", isCompleted(id, list));
52032    }
52033
52034    function checkModuleCompletion(completions) {
52035      const list = completions ?? getCompletions();
52036      document.querySelectorAll(".course-video").forEach((module) => {
52037        const btns = module.querySelectorAll(".complete-btn");
52038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
52039        const check = module.querySelector(".module-check");
52040        if (check) check.classList.toggle("visible", allCompleted);
52041      });
52042    }
52043
52044    function initCompletionButtons() {
52045      const completions = migrateCompletions();
52046
52047      document.querySelectorAll(".complete-btn").forEach((btn) => {
52048        if (btn.dataset.completionInit) return;
52049        btn.dataset.completionInit = "true";
52050        updateButtonState(btn, completions);
52051
52052        btn.addEventListener("click", (e) => {
52053          e.preventDefault();
52054          e.stopPropagation();
52055
52056          const id = btn.dataset.completionId;
52057          let list = getCompletions();
52058          const completed = isCompleted(id, list);
52059
52060          if (completed) {
52061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
52062            list = list.filter((c) =>
52062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
52063          } else {
52064            list.push(id);
52065          }
52066
52067          saveCompletions(list);
52068          updateButtonState(btn, list);
52069          checkModuleCompletion(list);
52070        });
52071      });
52072
52073      checkModuleCompletion(completions);
52074    }
52075
52076    if (document.readyState === "loading") {
52077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
52078    } else {
52079      initCompletionButtons();
52080    }
52081  })();
52082</script>
52082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="5280" data-end="6270" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. FrontMatter de Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-frontmatter-de-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
52082<script>
52083  (() => {
52084    if (window.__courseLazyPlayersInitialized) return;
52085    window.__courseLazyPlayersInitialized = true;
52086
52087    function createPlayer(details) {
52088      const videoId = details.dataset.videoId;
52089      const start = details.dataset.start;
52090      const end = details.dataset.end;
52091
52092      const container = document.createElement("div");
52093      container.className = "player-container";
52094
52095      const spinner = document.createElement("div");
52096      spinner.className = "spinner";
52097      container.appendChild(spinner);
52098
52099      const controller = document.createElement("media-controller");
52100      controller.setAttribute("defaultsubtitles", "");
52101
52102      const provider = document.createElement("youtube-media-provider");
52103      provider.setAttribute("slot", "media");
52104      provider.setAttribute("video-id", videoId);
52105      provider.setAttribute("start", start);
52106      provider.setAttribute("end", end);
52107
52108      const controlBar = document.createElement("media-control-bar");
52109
52110      const playBtn = document.createElement("media-play-button");
52111      const timeDisplay = document.createElement("media-time-display");
52112      timeDisplay.setAttribute("showduration", "");
52113      const timeRange = document.createElement("media-time-range");
52114      const playbackRate = document.createElement("media-playback-rate-button");
52115      const muteBtn = document.createElement("media-mute-button");
52116      const volumeRange = document.createElement("media-volume-range");
52117
52118      const captionsBtn = document.createElement("media-captions-button");
52119      captionsBtn.addEventListener("click", (e) => {
52120        e.stopPropagation();
52121        const provider = container.querySelector("youtube-media-provider");
52122        if (provider) provider.toggleCaptions();
52123      });
52124
52125      const qualityBtn = document.createElement("media-chrome-button");
52126      qualityBtn.textContent = "--";
52127      qualityBtn.addEventListener("click", () => {
52128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
52129      });
52130
52131      const fullscreenBtn = document.createElement("media-fullscreen-button");
52132
52133      controlBar.append(
52134        playBtn, timeDisplay, timeRange,
52135        playbackRate, muteBtn, volumeRange,
52136        captionsBtn, qualityBtn, fullscreenBtn
52137      );
52138
52139      const poster = document.createElement("media-poster-image");
52140      poster.setAttribute("slot", "poster");
52141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
52142
52143      controller.append(provider, poster, controlBar);
52144
52145      captionsBtn.setAttribute("aria-checked", "true");
52146      controller.setAttribute("mediasubtitlesshowing", "on");
52147
52148      container.appendChild(controller);
52149      details.appendChild(container);
52150
52151      provider.addEventListener("waiting", () => container.classList.add("loading"));
52152      provider.addEventListener("playing", () => container.classList.remove("loading"));
52153      provider.addEventListener("play", () => container.classList.remove("loading"));
52154
52155      provider._textTracks.addEventListener("change", () => {
52156        const enabled = provider._captionsEnabled;
52157        captionsBtn.setAttribute("aria-checked", enabled);
52158        if (enabled) {
52159          controller.setAttribute("mediasubtitlesshowing", "on");
52160        } else {
52161          controller.removeAttribute("mediasubtitlesshowing");
52162        }
52163      });
52164
52165      provider.addEventListener("qualitychange", (e) => {
52166        qualityBtn.textContent = e.detail.quality;
52167      });
52168    }
52169
52170    function initLazyPlayers() {
52171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
52172        if (details.dataset.lazyInit) return;
52173        details.dataset.lazyInit = "true";
52174        details.addEventListener("toggle", () => {
52175          if (details.open && !details.querySelector(".player-container")) {
52176            createPlayer(details);
52177          } else if (!details.open) {
52178            const provider = details.querySelector("youtube-media-provider");
52179            if (provider && typeof provider.pause === "function") {
52180              provider.pause();
52181            }
52182          }
52183        });
52184      });
52185    }
52186
52187    if (document.readyState === "loading") {
52188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
52189    } else {
52190      initLazyPlayers();
52191    }
52192  })();
52193</script>
52193<script>
52194  (() => {
52195    if (window.__courseCompletionsInitialized) return;
52196    window.__courseCompletionsInitialized = true;
52197
52198    const STORAGE_KEY = "course-completions";
52199
52200    function slugify(text) {
52201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
52202    }
52203
52204    function getCompletions() {
52205      try {
52206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
52207      } catch {
52208        return [];
52209      }
52210    }
52211
52212    function saveCompletions(completions) {
52213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
52214    }
52215
52216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
52217    function canonicalFromStored(storedId) {
52218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52219      if (!m) return slugify(storedId);
52220      const [, slug, p, mv, title] = m;
52221      return `${slug}-${p}-${mv}-${slugify(title)}`;
52222    }
52223
52224    function canonicalPlusOne(storedId) {
52225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52226      if (!m) return null;
52227      const [, slug, p, mv, title] = m;
52228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
52229    }
52230
52231    function isCompleted(canonicalId, completions) {
52232      if (completions.includes(canonicalId)) return true;
52233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
52234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
52235    }
52236
52237    function migrateCompletions() {
52238      const completions = getCompletions();
52239      let changed = false;
52240      const migrated = completions.map((c) => {
52241        const canon = canonicalFromStored(c);
52242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
52243        const plusOne = canonicalPlusOne(c);
52244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
52245          if (plusOne !== c) changed = true;
52246          return plusOne;
52247        }
52248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
52249          changed = true;
52250          return canon;
52251        }
52252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
52253        if (canon !== c) {
52254          changed = true;
52255          return canon;
52256        }
52257        return c;
52258      });
52259      // Elimina duplicados tras migración
52260      const deduped = [...new Set(migrated)];
52261      if (changed || deduped.length !== completions.length) {
52262        saveCompletions(deduped);
52263        return deduped;
52264      }
52265      return completions;
52266    }
52267
52268    function updateButtonState(btn, completions) {
52269      const id = btn.dataset.completionId;
52270      const list = completions ?? getCompletions();
52271      btn.classList.toggle("completed", isCompleted(id, list));
52272    }
52273
52274    function checkModuleCompletion(completions) {
52275      const list = completions ?? getCompletions();
52276      document.querySelectorAll(".course-video").forEach((module) => {
52277        const btns = module.querySelectorAll(".complete-btn");
52278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
52279        const check = module.querySelector(".module-check");
52280        if (check) check.classList.toggle("visible", allCompleted);
52281      });
52282    }
52283
52284    function initCompletionButtons() {
52285      const completions = migrateCompletions();
52286
52287      document.querySelectorAll(".complete-btn").forEach((btn) => {
52288        if (btn.dataset.completionInit) return;
52289        btn.dataset.completionInit = "true";
52290        updateButtonState(btn, completions);
52291
52292        btn.addEventListener("click", (e) => {
52293          e.preventDefault();
52294          e.stopPropagation();
52295
52296          const id = btn.dataset.completionId;
52297          let list = getCompletions();
52298          const completed = isCompleted(id, list);
52299
52300          if (completed) {
52301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
52302            list = list.filter((c) =>
52302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
52303          } else {
52304            list.push(id);
52305          }
52306
52307          saveCompletions(list);
52308          updateButtonState(btn, list);
52309          checkModuleCompletion(list);
52310        });
52311      });
52312
52313      checkModuleCompletion(completions);
52314    }
52315
52316    if (document.readyState === "loading") {
52317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
52318    } else {
52319      initCompletionButtons();
52320    }
52321  })();
52322</script>
52322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="6270" data-end="6564" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. La filosofía de Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>5 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-la-filosofia-de-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
52322<script>
52323  (() => {
52324    if (window.__courseLazyPlayersInitialized) return;
52325    window.__courseLazyPlayersInitialized = true;
52326
52327    function createPlayer(details) {
52328      const videoId = details.dataset.videoId;
52329      const start = details.dataset.start;
52330      const end = details.dataset.end;
52331
52332      const container = document.createElement("div");
52333      container.className = "player-container";
52334
52335      const spinner = document.createElement("div");
52336      spinner.className = "spinner";
52337      container.appendChild(spinner);
52338
52339      const controller = document.createElement("media-controller");
52340      controller.setAttribute("defaultsubtitles", "");
52341
52342      const provider = document.createElement("youtube-media-provider");
52343      provider.setAttribute("slot", "media");
52344      provider.setAttribute("video-id", videoId);
52345      provider.setAttribute("start", start);
52346      provider.setAttribute("end", end);
52347
52348      const controlBar = document.createElement("media-control-bar");
52349
52350      const playBtn = document.createElement("media-play-button");
52351      const timeDisplay = document.createElement("media-time-display");
52352      timeDisplay.setAttribute("showduration", "");
52353      const timeRange = document.createElement("media-time-range");
52354      const playbackRate = document.createElement("media-playback-rate-button");
52355      const muteBtn = document.createElement("media-mute-button");
52356      const volumeRange = document.createElement("media-volume-range");
52357
52358      const captionsBtn = document.createElement("media-captions-button");
52359      captionsBtn.addEventListener("click", (e) => {
52360        e.stopPropagation();
52361        const provider = container.querySelector("youtube-media-provider");
52362        if (provider) provider.toggleCaptions();
52363      });
52364
52365      const qualityBtn = document.createElement("media-chrome-button");
52366      qualityBtn.textContent = "--";
52367      qualityBtn.addEventListener("click", () => {
52368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
52369      });
52370
52371      const fullscreenBtn = document.createElement("media-fullscreen-button");
52372
52373      controlBar.append(
52374        playBtn, timeDisplay, timeRange,
52375        playbackRate, muteBtn, volumeRange,
52376        captionsBtn, qualityBtn, fullscreenBtn
52377      );
52378
52379      const poster = document.createElement("media-poster-image");
52380      poster.setAttribute("slot", "poster");
52381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
52382
52383      controller.append(provider, poster, controlBar);
52384
52385      captionsBtn.setAttribute("aria-checked", "true");
52386      controller.setAttribute("mediasubtitlesshowing", "on");
52387
52388      container.appendChild(controller);
52389      details.appendChild(container);
52390
52391      provider.addEventListener("waiting", () => container.classList.add("loading"));
52392      provider.addEventListener("playing", () => container.classList.remove("loading"));
52393      provider.addEventListener("play", () => container.classList.remove("loading"));
52394
52395      provider._textTracks.addEventListener("change", () => {
52396        const enabled = provider._captionsEnabled;
52397        captionsBtn.setAttribute("aria-checked", enabled);
52398        if (enabled) {
52399          controller.setAttribute("mediasubtitlesshowing", "on");
52400        } else {
52401          controller.removeAttribute("mediasubtitlesshowing");
52402        }
52403      });
52404
52405      provider.addEventListener("qualitychange", (e) => {
52406        qualityBtn.textContent = e.detail.quality;
52407      });
52408    }
52409
52410    function initLazyPlayers() {
52411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
52412        if (details.dataset.lazyInit) return;
52413        details.dataset.lazyInit = "true";
52414        details.addEventListener("toggle", () => {
52415          if (details.open && !details.querySelector(".player-container")) {
52416            createPlayer(details);
52417          } else if (!details.open) {
52418            const provider = details.querySelector("youtube-media-provider");
52419            if (provider && typeof provider.pause === "function") {
52420              provider.pause();
52421            }
52422          }
52423        });
52424      });
52425    }
52426
52427    if (document.readyState === "loading") {
52428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
52429    } else {
52430      initLazyPlayers();
52431    }
52432  })();
52433</script>
52433<script>
52434  (() => {
52435    if (window.__courseCompletionsInitialized) return;
52436    window.__courseCompletionsInitialized = true;
52437
52438    const STORAGE_KEY = "course-completions";
52439
52440    function slugify(text) {
52441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
52442    }
52443
52444    function getCompletions() {
52445      try {
52446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
52447      } catch {
52448        return [];
52449      }
52450    }
52451
52452    function saveCompletions(completions) {
52453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
52454    }
52455
52456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
52457    function canonicalFromStored(storedId) {
52458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52459      if (!m) return slugify(storedId);
52460      const [, slug, p, mv, title] = m;
52461      return `${slug}-${p}-${mv}-${slugify(title)}`;
52462    }
52463
52464    function canonicalPlusOne(storedId) {
52465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52466      if (!m) return null;
52467      const [, slug, p, mv, title] = m;
52468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
52469    }
52470
52471    function isCompleted(canonicalId, completions) {
52472      if (completions.includes(canonicalId)) return true;
52473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
52474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
52475    }
52476
52477    function migrateCompletions() {
52478      const completions = getCompletions();
52479      let changed = false;
52480      const migrated = completions.map((c) => {
52481        const canon = canonicalFromStored(c);
52482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
52483        const plusOne = canonicalPlusOne(c);
52484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
52485          if (plusOne !== c) changed = true;
52486          return plusOne;
52487        }
52488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
52489          changed = true;
52490          return canon;
52491        }
52492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
52493        if (canon !== c) {
52494          changed = true;
52495          return canon;
52496        }
52497        return c;
52498      });
52499      // Elimina duplicados tras migración
52500      const deduped = [...new Set(migrated)];
52501      if (changed || deduped.length !== completions.length) {
52502        saveCompletions(deduped);
52503        return deduped;
52504      }
52505      return completions;
52506    }
52507
52508    function updateButtonState(btn, completions) {
52509      const id = btn.dataset.completionId;
52510      const list = completions ?? getCompletions();
52511      btn.classList.toggle("completed", isCompleted(id, list));
52512    }
52513
52514    function checkModuleCompletion(completions) {
52515      const list = completions ?? getCompletions();
52516      document.querySelectorAll(".course-video").forEach((module) => {
52517        const btns = module.querySelectorAll(".complete-btn");
52518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
52519        const check = module.querySelector(".module-check");
52520        if (check) check.classList.toggle("visible", allCompleted);
52521      });
52522    }
52523
52524    function initCompletionButtons() {
52525      const completions = migrateCompletions();
52526
52527      document.querySelectorAll(".complete-btn").forEach((btn) => {
52528        if (btn.dataset.completionInit) return;
52529        btn.dataset.completionInit = "true";
52530        updateButtonState(btn, completions);
52531
52532        btn.addEventListener("click", (e) => {
52533          e.preventDefault();
52534          e.stopPropagation();
52535
52536          const id = btn.dataset.completionId;
52537          let list = getCompletions();
52538          const completed = isCompleted(id, list);
52539
52540          if (completed) {
52541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
52542            list = list.filter((c) =>
52542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
52543          } else {
52544            list.push(id);
52545          }
52546
52547          saveCompletions(list);
52548          updateButtonState(btn, list);
52549          checkModuleCompletion(list);
52550        });
52551      });
52552
52553      checkModuleCompletion(completions);
52554    }
52555
52556    if (document.readyState === "loading") {
52557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
52558    } else {
52559      initCompletionButtons();
52560    }
52561  })();
52562</script>
52562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="6564" data-end="7101" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Rutas y componentes</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-rutas-y-componentes" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
52562<script>
52563  (() => {
52564    if (window.__courseLazyPlayersInitialized) return;
52565    window.__courseLazyPlayersInitialized = true;
52566
52567    function createPlayer(details) {
52568      const videoId = details.dataset.videoId;
52569      const start = details.dataset.start;
52570      const end = details.dataset.end;
52571
52572      const container = document.createElement("div");
52573      container.className = "player-container";
52574
52575      const spinner = document.createElement("div");
52576      spinner.className = "spinner";
52577      container.appendChild(spinner);
52578
52579      const controller = document.createElement("media-controller");
52580      controller.setAttribute("defaultsubtitles", "");
52581
52582      const provider = document.createElement("youtube-media-provider");
52583      provider.setAttribute("slot", "media");
52584      provider.setAttribute("video-id", videoId);
52585      provider.setAttribute("start", start);
52586      provider.setAttribute("end", end);
52587
52588      const controlBar = document.createElement("media-control-bar");
52589
52590      const playBtn = document.createElement("media-play-button");
52591      const timeDisplay = document.createElement("media-time-display");
52592      timeDisplay.setAttribute("showduration", "");
52593      const timeRange = document.createElement("media-time-range");
52594      const playbackRate = document.createElement("media-playback-rate-button");
52595      const muteBtn = document.createElement("media-mute-button");
52596      const volumeRange = document.createElement("media-volume-range");
52597
52598      const captionsBtn = document.createElement("media-captions-button");
52599      captionsBtn.addEventListener("click", (e) => {
52600        e.stopPropagation();
52601        const provider = container.querySelector("youtube-media-provider");
52602        if (provider) provider.toggleCaptions();
52603      });
52604
52605      const qualityBtn = document.createElement("media-chrome-button");
52606      qualityBtn.textContent = "--";
52607      qualityBtn.addEventListener("click", () => {
52608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
52609      });
52610
52611      const fullscreenBtn = document.createElement("media-fullscreen-button");
52612
52613      controlBar.append(
52614        playBtn, timeDisplay, timeRange,
52615        playbackRate, muteBtn, volumeRange,
52616        captionsBtn, qualityBtn, fullscreenBtn
52617      );
52618
52619      const poster = document.createElement("media-poster-image");
52620      poster.setAttribute("slot", "poster");
52621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
52622
52623      controller.append(provider, poster, controlBar);
52624
52625      captionsBtn.setAttribute("aria-checked", "true");
52626      controller.setAttribute("mediasubtitlesshowing", "on");
52627
52628      container.appendChild(controller);
52629      details.appendChild(container);
52630
52631      provider.addEventListener("waiting", () => container.classList.add("loading"));
52632      provider.addEventListener("playing", () => container.classList.remove("loading"));
52633      provider.addEventListener("play", () => container.classList.remove("loading"));
52634
52635      provider._textTracks.addEventListener("change", () => {
52636        const enabled = provider._captionsEnabled;
52637        captionsBtn.setAttribute("aria-checked", enabled);
52638        if (enabled) {
52639          controller.setAttribute("mediasubtitlesshowing", "on");
52640        } else {
52641          controller.removeAttribute("mediasubtitlesshowing");
52642        }
52643      });
52644
52645      provider.addEventListener("qualitychange", (e) => {
52646        qualityBtn.textContent = e.detail.quality;
52647      });
52648    }
52649
52650    function initLazyPlayers() {
52651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
52652        if (details.dataset.lazyInit) return;
52653        details.dataset.lazyInit = "true";
52654        details.addEventListener("toggle", () => {
52655          if (details.open && !details.querySelector(".player-container")) {
52656            createPlayer(details);
52657          } else if (!details.open) {
52658            const provider = details.querySelector("youtube-media-provider");
52659            if (provider && typeof provider.pause === "function") {
52660              provider.pause();
52661            }
52662          }
52663        });
52664      });
52665    }
52666
52667    if (document.readyState === "loading") {
52668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
52669    } else {
52670      initLazyPlayers();
52671    }
52672  })();
52673</script>
52673<script>
52674  (() => {
52675    if (window.__courseCompletionsInitialized) return;
52676    window.__courseCompletionsInitialized = true;
52677
52678    const STORAGE_KEY = "course-completions";
52679
52680    function slugify(text) {
52681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
52682    }
52683
52684    function getCompletions() {
52685      try {
52686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
52687      } catch {
52688        return [];
52689      }
52690    }
52691
52692    function saveCompletions(completions) {
52693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
52694    }
52695
52696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
52697    function canonicalFromStored(storedId) {
52698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52699      if (!m) return slugify(storedId);
52700      const [, slug, p, mv, title] = m;
52701      return `${slug}-${p}-${mv}-${slugify(title)}`;
52702    }
52703
52704    function canonicalPlusOne(storedId) {
52705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52706      if (!m) return null;
52707      const [, slug, p, mv, title] = m;
52708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
52709    }
52710
52711    function isCompleted(canonicalId, completions) {
52712      if (completions.includes(canonicalId)) return true;
52713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
52714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
52715    }
52716
52717    function migrateCompletions() {
52718      const completions = getCompletions();
52719      let changed = false;
52720      const migrated = completions.map((c) => {
52721        const canon = canonicalFromStored(c);
52722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
52723        const plusOne = canonicalPlusOne(c);
52724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
52725          if (plusOne !== c) changed = true;
52726          return plusOne;
52727        }
52728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
52729          changed = true;
52730          return canon;
52731        }
52732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
52733        if (canon !== c) {
52734          changed = true;
52735          return canon;
52736        }
52737        return c;
52738      });
52739      // Elimina duplicados tras migración
52740      const deduped = [...new Set(migrated)];
52741      if (changed || deduped.length !== completions.length) {
52742        saveCompletions(deduped);
52743        return deduped;
52744      }
52745      return completions;
52746    }
52747
52748    function updateButtonState(btn, completions) {
52749      const id = btn.dataset.completionId;
52750      const list = completions ?? getCompletions();
52751      btn.classList.toggle("completed", isCompleted(id, list));
52752    }
52753
52754    function checkModuleCompletion(completions) {
52755      const list = completions ?? getCompletions();
52756      document.querySelectorAll(".course-video").forEach((module) => {
52757        const btns = module.querySelectorAll(".complete-btn");
52758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
52759        const check = module.querySelector(".module-check");
52760        if (check) check.classList.toggle("visible", allCompleted);
52761      });
52762    }
52763
52764    function initCompletionButtons() {
52765      const completions = migrateCompletions();
52766
52767      document.querySelectorAll(".complete-btn").forEach((btn) => {
52768        if (btn.dataset.completionInit) return;
52769        btn.dataset.completionInit = "true";
52770        updateButtonState(btn, completions);
52771
52772        btn.addEventListener("click", (e) => {
52773          e.preventDefault();
52774          e.stopPropagation();
52775
52776          const id = btn.dataset.completionId;
52777          let list = getCompletions();
52778          const completed = isCompleted(id, list);
52779
52780          if (completed) {
52781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
52782            list = list.filter((c) =>
52782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
52783          } else {
52784            list.push(id);
52785          }
52786
52787          saveCompletions(list);
52788          updateButtonState(btn, list);
52789          checkModuleCompletion(list);
52790        });
52791      });
52792
52793      checkModuleCompletion(completions);
52794    }
52795
52796    if (document.readyState === "loading") {
52797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
52798    } else {
52799      initCompletionButtons();
52800    }
52801  })();
52802</script>
52802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="7425" data-end="7975" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Componentes de Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>9 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-componentes-de-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
52802<script>
52803  (() => {
52804    if (window.__courseLazyPlayersInitialized) return;
52805    window.__courseLazyPlayersInitialized = true;
52806
52807    function createPlayer(details) {
52808      const videoId = details.dataset.videoId;
52809      const start = details.dataset.start;
52810      const end = details.dataset.end;
52811
52812      const container = document.createElement("div");
52813      container.className = "player-container";
52814
52815      const spinner = document.createElement("div");
52816      spinner.className = "spinner";
52817      container.appendChild(spinner);
52818
52819      const controller = document.createElement("media-controller");
52820      controller.setAttribute("defaultsubtitles", "");
52821
52822      const provider = document.createElement("youtube-media-provider");
52823      provider.setAttribute("slot", "media");
52824      provider.setAttribute("video-id", videoId);
52825      provider.setAttribute("start", start);
52826      provider.setAttribute("end", end);
52827
52828      const controlBar = document.createElement("media-control-bar");
52829
52830      const playBtn = document.createElement("media-play-button");
52831      const timeDisplay = document.createElement("media-time-display");
52832      timeDisplay.setAttribute("showduration", "");
52833      const timeRange = document.createElement("media-time-range");
52834      const playbackRate = document.createElement("media-playback-rate-button");
52835      const muteBtn = document.createElement("media-mute-button");
52836      const volumeRange = document.createElement("media-volume-range");
52837
52838      const captionsBtn = document.createElement("media-captions-button");
52839      captionsBtn.addEventListener("click", (e) => {
52840        e.stopPropagation();
52841        const provider = container.querySelector("youtube-media-provider");
52842        if (provider) provider.toggleCaptions();
52843      });
52844
52845      const qualityBtn = document.createElement("media-chrome-button");
52846      qualityBtn.textContent = "--";
52847      qualityBtn.addEventListener("click", () => {
52848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
52849      });
52850
52851      const fullscreenBtn = document.createElement("media-fullscreen-button");
52852
52853      controlBar.append(
52854        playBtn, timeDisplay, timeRange,
52855        playbackRate, muteBtn, volumeRange,
52856        captionsBtn, qualityBtn, fullscreenBtn
52857      );
52858
52859      const poster = document.createElement("media-poster-image");
52860      poster.setAttribute("slot", "poster");
52861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
52862
52863      controller.append(provider, poster, controlBar);
52864
52865      captionsBtn.setAttribute("aria-checked", "true");
52866      controller.setAttribute("mediasubtitlesshowing", "on");
52867
52868      container.appendChild(controller);
52869      details.appendChild(container);
52870
52871      provider.addEventListener("waiting", () => container.classList.add("loading"));
52872      provider.addEventListener("playing", () => container.classList.remove("loading"));
52873      provider.addEventListener("play", () => container.classList.remove("loading"));
52874
52875      provider._textTracks.addEventListener("change", () => {
52876        const enabled = provider._captionsEnabled;
52877        captionsBtn.setAttribute("aria-checked", enabled);
52878        if (enabled) {
52879          controller.setAttribute("mediasubtitlesshowing", "on");
52880        } else {
52881          controller.removeAttribute("mediasubtitlesshowing");
52882        }
52883      });
52884
52885      provider.addEventListener("qualitychange", (e) => {
52886        qualityBtn.textContent = e.detail.quality;
52887      });
52888    }
52889
52890    function initLazyPlayers() {
52891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
52892        if (details.dataset.lazyInit) return;
52893        details.dataset.lazyInit = "true";
52894        details.addEventListener("toggle", () => {
52895          if (details.open && !details.querySelector(".player-container")) {
52896            createPlayer(details);
52897          } else if (!details.open) {
52898            const provider = details.querySelector("youtube-media-provider");
52899            if (provider && typeof provider.pause === "function") {
52900              provider.pause();
52901            }
52902          }
52903        });
52904      });
52905    }
52906
52907    if (document.readyState === "loading") {
52908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
52909    } else {
52910      initLazyPlayers();
52911    }
52912  })();
52913</script>
52913<script>
52914  (() => {
52915    if (window.__courseCompletionsInitialized) return;
52916    window.__courseCompletionsInitialized = true;
52917
52918    const STORAGE_KEY = "course-completions";
52919
52920    function slugify(text) {
52921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
52922    }
52923
52924    function getCompletions() {
52925      try {
52926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
52927      } catch {
52928        return [];
52929      }
52930    }
52931
52932    function saveCompletions(completions) {
52933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
52934    }
52935
52936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
52937    function canonicalFromStored(storedId) {
52938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52939      if (!m) return slugify(storedId);
52940      const [, slug, p, mv, title] = m;
52941      return `${slug}-${p}-${mv}-${slugify(title)}`;
52942    }
52943
52944    function canonicalPlusOne(storedId) {
52945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
52946      if (!m) return null;
52947      const [, slug, p, mv, title] = m;
52948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
52949    }
52950
52951    function isCompleted(canonicalId, completions) {
52952      if (completions.includes(canonicalId)) return true;
52953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
52954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
52955    }
52956
52957    function migrateCompletions() {
52958      const completions = getCompletions();
52959      let changed = false;
52960      const migrated = completions.map((c) => {
52961        const canon = canonicalFromStored(c);
52962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
52963        const plusOne = canonicalPlusOne(c);
52964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
52965          if (plusOne !== c) changed = true;
52966          return plusOne;
52967        }
52968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
52969          changed = true;
52970          return canon;
52971        }
52972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
52973        if (canon !== c) {
52974          changed = true;
52975          return canon;
52976        }
52977        return c;
52978      });
52979      // Elimina duplicados tras migración
52980      const deduped = [...new Set(migrated)];
52981      if (changed || deduped.length !== completions.length) {
52982        saveCompletions(deduped);
52983        return deduped;
52984      }
52985      return completions;
52986    }
52987
52988    function updateButtonState(btn, completions) {
52989      const id = btn.dataset.completionId;
52990      const list = completions ?? getCompletions();
52991      btn.classList.toggle("completed", isCompleted(id, list));
52992    }
52993
52994    function checkModuleCompletion(completions) {
52995      const list = completions ?? getCompletions();
52996      document.querySelectorAll(".course-video").forEach((module) => {
52997        const btns = module.querySelectorAll(".complete-btn");
52998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
52999        const check = module.querySelector(".module-check");
53000        if (check) check.classList.toggle("visible", allCompleted);
53001      });
53002    }
53003
53004    function initCompletionButtons() {
53005      const completions = migrateCompletions();
53006
53007      document.querySelectorAll(".complete-btn").forEach((btn) => {
53008        if (btn.dataset.completionInit) return;
53009        btn.dataset.completionInit = "true";
53010        updateButtonState(btn, completions);
53011
53012        btn.addEventListener("click", (e) => {
53013          e.preventDefault();
53014          e.stopPropagation();
53015
53016          const id = btn.dataset.completionId;
53017          let list = getCompletions();
53018          const completed = isCompleted(id, list);
53019
53020          if (completed) {
53021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
53022            list = list.filter((c) =>
53022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
53023          } else {
53024            list.push(id);
53025          }
53026
53027          saveCompletions(list);
53028          updateButtonState(btn, list);
53029          checkModuleCompletion(list);
53030        });
53031      });
53032
53033      checkModuleCompletion(completions);
53034    }
53035
53036    if (document.readyState === "loading") {
53037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
53038    } else {
53039      initCompletionButtons();
53040    }
53041  })();
53042</script>
53042<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="7975" data-end="8896" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Slots y props</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>15 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-slots-y-props" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
53042<script>
53043  (() => {
53044    if (window.__courseLazyPlayersInitialized) return;
53045    window.__courseLazyPlayersInitialized = true;
53046
53047    function createPlayer(details) {
53048      const videoId = details.dataset.videoId;
53049      const start = details.dataset.start;
53050      const end = details.dataset.end;
53051
53052      const container = document.createElement("div");
53053      container.className = "player-container";
53054
53055      const spinner = document.createElement("div");
53056      spinner.className = "spinner";
53057      container.appendChild(spinner);
53058
53059      const controller = document.createElement("media-controller");
53060      controller.setAttribute("defaultsubtitles", "");
53061
53062      const provider = document.createElement("youtube-media-provider");
53063      provider.setAttribute("slot", "media");
53064      provider.setAttribute("video-id", videoId);
53065      provider.setAttribute("start", start);
53066      provider.setAttribute("end", end);
53067
53068      const controlBar = document.createElement("media-control-bar");
53069
53070      const playBtn = document.createElement("media-play-button");
53071      const timeDisplay = document.createElement("media-time-display");
53072      timeDisplay.setAttribute("showduration", "");
53073      const timeRange = document.createElement("media-time-range");
53074      const playbackRate = document.createElement("media-playback-rate-button");
53075      const muteBtn = document.createElement("media-mute-button");
53076      const volumeRange = document.createElement("media-volume-range");
53077
53078      const captionsBtn = document.createElement("media-captions-button");
53079      captionsBtn.addEventListener("click", (e) => {
53080        e.stopPropagation();
53081        const provider = container.querySelector("youtube-media-provider");
53082        if (provider) provider.toggleCaptions();
53083      });
53084
53085      const qualityBtn = document.createElement("media-chrome-button");
53086      qualityBtn.textContent = "--";
53087      qualityBtn.addEventListener("click", () => {
53088        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
53089      });
53090
53091      const fullscreenBtn = document.createElement("media-fullscreen-button");
53092
53093      controlBar.append(
53094        playBtn, timeDisplay, timeRange,
53095        playbackRate, muteBtn, volumeRange,
53096        captionsBtn, qualityBtn, fullscreenBtn
53097      );
53098
53099      const poster = document.createElement("media-poster-image");
53100      poster.setAttribute("slot", "poster");
53101      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
53102
53103      controller.append(provider, poster, controlBar);
53104
53105      captionsBtn.setAttribute("aria-checked", "true");
53106      controller.setAttribute("mediasubtitlesshowing", "on");
53107
53108      container.appendChild(controller);
53109      details.appendChild(container);
53110
53111      provider.addEventListener("waiting", () => container.classList.add("loading"));
53112      provider.addEventListener("playing", () => container.classList.remove("loading"));
53113      provider.addEventListener("play", () => container.classList.remove("loading"));
53114
53115      provider._textTracks.addEventListener("change", () => {
53116        const enabled = provider._captionsEnabled;
53117        captionsBtn.setAttribute("aria-checked", enabled);
53118        if (enabled) {
53119          controller.setAttribute("mediasubtitlesshowing", "on");
53120        } else {
53121          controller.removeAttribute("mediasubtitlesshowing");
53122        }
53123      });
53124
53125      provider.addEventListener("qualitychange", (e) => {
53126        qualityBtn.textContent = e.detail.quality;
53127      });
53128    }
53129
53130    function initLazyPlayers() {
53131      document.querySelectorAll("details[data-video-id]").forEach((details) => {
53132        if (details.dataset.lazyInit) return;
53133        details.dataset.lazyInit = "true";
53134        details.addEventListener("toggle", () => {
53135          if (details.open && !details.querySelector(".player-container")) {
53136            createPlayer(details);
53137          } else if (!details.open) {
53138            const provider = details.querySelector("youtube-media-provider");
53139            if (provider && typeof provider.pause === "function") {
53140              provider.pause();
53141            }
53142          }
53143        });
53144      });
53145    }
53146
53147    if (document.readyState === "loading") {
53148      document.addEventListener("DOMContentLoaded", initLazyPlayers);
53149    } else {
53150      initLazyPlayers();
53151    }
53152  })();
53153</script>
53153<script>
53154  (() => {
53155    if (window.__courseCompletionsInitialized) return;
53156    window.__courseCompletionsInitialized = true;
53157
53158    const STORAGE_KEY = "course-completions";
53159
53160    function slugify(text) {
53161      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
53162    }
53163
53164    function getCompletions() {
53165      try {
53166        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
53167      } catch {
53168        return [];
53169      }
53170    }
53171
53172    function saveCompletions(completions) {
53173      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
53174    }
53175
53176    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
53177    function canonicalFromStored(storedId) {
53178      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53179      if (!m) return slugify(storedId);
53180      const [, slug, p, mv, title] = m;
53181      return `${slug}-${p}-${mv}-${slugify(title)}`;
53182    }
53183
53184    function canonicalPlusOne(storedId) {
53185      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53186      if (!m) return null;
53187      const [, slug, p, mv, title] = m;
53188      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
53189    }
53190
53191    function isCompleted(canonicalId, completions) {
53192      if (completions.includes(canonicalId)) return true;
53193      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
53194      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
53195    }
53196
53197    function migrateCompletions() {
53198      const completions = getCompletions();
53199      let changed = false;
53200      const migrated = completions.map((c) => {
53201        const canon = canonicalFromStored(c);
53202        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
53203        const plusOne = canonicalPlusOne(c);
53204        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
53205          if (plusOne !== c) changed = true;
53206          return plusOne;
53207        }
53208        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
53209          changed = true;
53210          return canon;
53211        }
53212        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
53213        if (canon !== c) {
53214          changed = true;
53215          return canon;
53216        }
53217        return c;
53218      });
53219      // Elimina duplicados tras migración
53220      const deduped = [...new Set(migrated)];
53221      if (changed || deduped.length !== completions.length) {
53222        saveCompletions(deduped);
53223        return deduped;
53224      }
53225      return completions;
53226    }
53227
53228    function updateButtonState(btn, completions) {
53229      const id = btn.dataset.completionId;
53230      const list = completions ?? getCompletions();
53231      btn.classList.toggle("completed", isCompleted(id, list));
53232    }
53233
53234    function checkModuleCompletion(completions) {
53235      const list = completions ?? getCompletions();
53236      document.querySelectorAll(".course-video").forEach((module) => {
53237        const btns = module.querySelectorAll(".complete-btn");
53238        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
53239        const check = module.querySelector(".module-check");
53240        if (check) check.classList.toggle("visible", allCompleted);
53241      });
53242    }
53243
53244    function initCompletionButtons() {
53245      const completions = migrateCompletions();
53246
53247      document.querySelectorAll(".complete-btn").forEach((btn) => {
53248        if (btn.dataset.completionInit) return;
53249        btn.dataset.completionInit = "true";
53250        updateButtonState(btn, completions);
53251
53252        btn.addEventListener("click", (e) => {
53253          e.preventDefault();
53254          e.stopPropagation();
53255
53256          const id = btn.dataset.completionId;
53257          let list = getCompletions();
53258          const completed = isCompleted(id, list);
53259
53260          if (completed) {
53261            // Elimina canónico y cualquier variante legacy que mapee al mismo id
53262            list = list.filter((c) =>
53262 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
53263          } else {
53264            list.push(id);
53265          }
53266
53267          saveCompletions(list);
53268          updateButtonState(btn, list);
53269          checkModuleCompletion(list);
53270        });
53271      });
53272
53273      checkModuleCompletion(completions);
53274    }
53275
53276    if (document.readyState === "loading") {
53277      document.addEventListener("DOMContentLoaded", initCompletionButtons);
53278    } else {
53279      initCompletionButtons();
53280    }
53281  })();
53282</script>
53282<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="8896" data-end="10800" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. CSS y Javascript en Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>32 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-css-y-javascript-en-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
53282<script>
53283  (() => {
53284    if (window.__courseLazyPlayersInitialized) return;
53285    window.__courseLazyPlayersInitialized = true;
53286
53287    function createPlayer(details) {
53288      const videoId = details.dataset.videoId;
53289      const start = details.dataset.start;
53290      const end = details.dataset.end;
53291
53292      const container = document.createElement("div");
53293      container.className = "player-container";
53294
53295      const spinner = document.createElement("div");
53296      spinner.className = "spinner";
53297      container.appendChild(spinner);
53298
53299      const controller = document.createElement("media-controller");
53300      controller.setAttribute("defaultsubtitles", "");
53301
53302      const provider = document.createElement("youtube-media-provider");
53303      provider.setAttribute("slot", "media");
53304      provider.setAttribute("video-id", videoId);
53305      provider.setAttribute("start", start);
53306      provider.setAttribute("end", end);
53307
53308      const controlBar = document.createElement("media-control-bar");
53309
53310      const playBtn = document.createElement("media-play-button");
53311      const timeDisplay = document.createElement("media-time-display");
53312      timeDisplay.setAttribute("showduration", "");
53313      const timeRange = document.createElement("media-time-range");
53314      const playbackRate = document.createElement("media-playback-rate-button");
53315      const muteBtn = document.createElement("media-mute-button");
53316      const volumeRange = document.createElement("media-volume-range");
53317
53318      const captionsBtn = document.createElement("media-captions-button");
53319      captionsBtn.addEventListener("click", (e) => {
53320        e.stopPropagation();
53321        const provider = container.querySelector("youtube-media-provider");
53322        if (provider) provider.toggleCaptions();
53323      });
53324
53325      const qualityBtn = document.createElement("media-chrome-button");
53326      qualityBtn.textContent = "--";
53327      qualityBtn.addEventListener("click", () => {
53328        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
53329      });
53330
53331      const fullscreenBtn = document.createElement("media-fullscreen-button");
53332
53333      controlBar.append(
53334        playBtn, timeDisplay, timeRange,
53335        playbackRate, muteBtn, volumeRange,
53336        captionsBtn, qualityBtn, fullscreenBtn
53337      );
53338
53339      const poster = document.createElement("media-poster-image");
53340      poster.setAttribute("slot", "poster");
53341      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
53342
53343      controller.append(provider, poster, controlBar);
53344
53345      captionsBtn.setAttribute("aria-checked", "true");
53346      controller.setAttribute("mediasubtitlesshowing", "on");
53347
53348      container.appendChild(controller);
53349      details.appendChild(container);
53350
53351      provider.addEventListener("waiting", () => container.classList.add("loading"));
53352      provider.addEventListener("playing", () => container.classList.remove("loading"));
53353      provider.addEventListener("play", () => container.classList.remove("loading"));
53354
53355      provider._textTracks.addEventListener("change", () => {
53356        const enabled = provider._captionsEnabled;
53357        captionsBtn.setAttribute("aria-checked", enabled);
53358        if (enabled) {
53359          controller.setAttribute("mediasubtitlesshowing", "on");
53360        } else {
53361          controller.removeAttribute("mediasubtitlesshowing");
53362        }
53363      });
53364
53365      provider.addEventListener("qualitychange", (e) => {
53366        qualityBtn.textContent = e.detail.quality;
53367      });
53368    }
53369
53370    function initLazyPlayers() {
53371      document.querySelectorAll("details[data-video-id]").forEach((details) => {
53372        if (details.dataset.lazyInit) return;
53373        details.dataset.lazyInit = "true";
53374        details.addEventListener("toggle", () => {
53375          if (details.open && !details.querySelector(".player-container")) {
53376            createPlayer(details);
53377          } else if (!details.open) {
53378            const provider = details.querySelector("youtube-media-provider");
53379            if (provider && typeof provider.pause === "function") {
53380              provider.pause();
53381            }
53382          }
53383        });
53384      });
53385    }
53386
53387    if (document.readyState === "loading") {
53388      document.addEventListener("DOMContentLoaded", initLazyPlayers);
53389    } else {
53390      initLazyPlayers();
53391    }
53392  })();
53393</script>
53393<script>
53394  (() => {
53395    if (window.__courseCompletionsInitialized) return;
53396    window.__courseCompletionsInitialized = true;
53397
53398    const STORAGE_KEY = "course-completions";
53399
53400    function slugify(text) {
53401      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
53402    }
53403
53404    function getCompletions() {
53405      try {
53406        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
53407      } catch {
53408        return [];
53409      }
53410    }
53411
53412    function saveCompletions(completions) {
53413      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
53414    }
53415
53416    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
53417    function canonicalFromStored(storedId) {
53418      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53419      if (!m) return slugify(storedId);
53420      const [, slug, p, mv, title] = m;
53421      return `${slug}-${p}-${mv}-${slugify(title)}`;
53422    }
53423
53424    function canonicalPlusOne(storedId) {
53425      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53426      if (!m) return null;
53427      const [, slug, p, mv, title] = m;
53428      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
53429    }
53430
53431    function isCompleted(canonicalId, completions) {
53432      if (completions.includes(canonicalId)) return true;
53433      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
53434      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
53435    }
53436
53437    function migrateCompletions() {
53438      const completions = getCompletions();
53439      let changed = false;
53440      const migrated = completions.map((c) => {
53441        const canon = canonicalFromStored(c);
53442        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
53443        const plusOne = canonicalPlusOne(c);
53444        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
53445          if (plusOne !== c) changed = true;
53446          return plusOne;
53447        }
53448        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
53449          changed = true;
53450          return canon;
53451        }
53452        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
53453        if (canon !== c) {
53454          changed = true;
53455          return canon;
53456        }
53457        return c;
53458      });
53459      // Elimina duplicados tras migración
53460      const deduped = [...new Set(migrated)];
53461      if (changed || deduped.length !== completions.length) {
53462        saveCompletions(deduped);
53463        return deduped;
53464      }
53465      return completions;
53466    }
53467
53468    function updateButtonState(btn, completions) {
53469      const id = btn.dataset.completionId;
53470      const list = completions ?? getCompletions();
53471      btn.classList.toggle("completed", isCompleted(id, list));
53472    }
53473
53474    function checkModuleCompletion(completions) {
53475      const list = completions ?? getCompletions();
53476      document.querySelectorAll(".course-video").forEach((module) => {
53477        const btns = module.querySelectorAll(".complete-btn");
53478        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
53479        const check = module.querySelector(".module-check");
53480        if (check) check.classList.toggle("visible", allCompleted);
53481      });
53482    }
53483
53484    function initCompletionButtons() {
53485      const completions = migrateCompletions();
53486
53487      document.querySelectorAll(".complete-btn").forEach((btn) => {
53488        if (btn.dataset.completionInit) return;
53489        btn.dataset.completionInit = "true";
53490        updateButtonState(btn, completions);
53491
53492        btn.addEventListener("click", (e) => {
53493          e.preventDefault();
53494          e.stopPropagation();
53495
53496          const id = btn.dataset.completionId;
53497          let list = getCompletions();
53498          const completed = isCompleted(id, list);
53499
53500          if (completed) {
53501            // Elimina canónico y cualquier variante legacy que mapee al mismo id
53502            list = list.filter((c) =>
53502 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
53503          } else {
53504            list.push(id);
53505          }
53506
53507          saveCompletions(list);
53508          updateButtonState(btn, list);
53509          checkModuleCompletion(list);
53510        });
53511      });
53512
53513      checkModuleCompletion(completions);
53514    }
53515
53516    if (document.readyState === "loading") {
53517      document.addEventListener("DOMContentLoaded", initCompletionButtons);
53518    } else {
53519      initCompletionButtons();
53520    }
53521  })();
53522</script>
53522<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="10800" data-end="11639" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. Plugin de Astro (SuperSVG)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>14 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-plugin-de-astro-supersvg" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
53522<script>
53523  (() => {
53524    if (window.__courseLazyPlayersInitialized) return;
53525    window.__courseLazyPlayersInitialized = true;
53526
53527    function createPlayer(details) {
53528      const videoId = details.dataset.videoId;
53529      const start = details.dataset.start;
53530      const end = details.dataset.end;
53531
53532      const container = document.createElement("div");
53533      container.className = "player-container";
53534
53535      const spinner = document.createElement("div");
53536      spinner.className = "spinner";
53537      container.appendChild(spinner);
53538
53539      const controller = document.createElement("media-controller");
53540      controller.setAttribute("defaultsubtitles", "");
53541
53542      const provider = document.createElement("youtube-media-provider");
53543      provider.setAttribute("slot", "media");
53544      provider.setAttribute("video-id", videoId);
53545      provider.setAttribute("start", start);
53546      provider.setAttribute("end", end);
53547
53548      const controlBar = document.createElement("media-control-bar");
53549
53550      const playBtn = document.createElement("media-play-button");
53551      const timeDisplay = document.createElement("media-time-display");
53552      timeDisplay.setAttribute("showduration", "");
53553      const timeRange = document.createElement("media-time-range");
53554      const playbackRate = document.createElement("media-playback-rate-button");
53555      const muteBtn = document.createElement("media-mute-button");
53556      const volumeRange = document.createElement("media-volume-range");
53557
53558      const captionsBtn = document.createElement("media-captions-button");
53559      captionsBtn.addEventListener("click", (e) => {
53560        e.stopPropagation();
53561        const provider = container.querySelector("youtube-media-provider");
53562        if (provider) provider.toggleCaptions();
53563      });
53564
53565      const qualityBtn = document.createElement("media-chrome-button");
53566      qualityBtn.textContent = "--";
53567      qualityBtn.addEventListener("click", () => {
53568        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
53569      });
53570
53571      const fullscreenBtn = document.createElement("media-fullscreen-button");
53572
53573      controlBar.append(
53574        playBtn, timeDisplay, timeRange,
53575        playbackRate, muteBtn, volumeRange,
53576        captionsBtn, qualityBtn, fullscreenBtn
53577      );
53578
53579      const poster = document.createElement("media-poster-image");
53580      poster.setAttribute("slot", "poster");
53581      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
53582
53583      controller.append(provider, poster, controlBar);
53584
53585      captionsBtn.setAttribute("aria-checked", "true");
53586      controller.setAttribute("mediasubtitlesshowing", "on");
53587
53588      container.appendChild(controller);
53589      details.appendChild(container);
53590
53591      provider.addEventListener("waiting", () => container.classList.add("loading"));
53592      provider.addEventListener("playing", () => container.classList.remove("loading"));
53593      provider.addEventListener("play", () => container.classList.remove("loading"));
53594
53595      provider._textTracks.addEventListener("change", () => {
53596        const enabled = provider._captionsEnabled;
53597        captionsBtn.setAttribute("aria-checked", enabled);
53598        if (enabled) {
53599          controller.setAttribute("mediasubtitlesshowing", "on");
53600        } else {
53601          controller.removeAttribute("mediasubtitlesshowing");
53602        }
53603      });
53604
53605      provider.addEventListener("qualitychange", (e) => {
53606        qualityBtn.textContent = e.detail.quality;
53607      });
53608    }
53609
53610    function initLazyPlayers() {
53611      document.querySelectorAll("details[data-video-id]").forEach((details) => {
53612        if (details.dataset.lazyInit) return;
53613        details.dataset.lazyInit = "true";
53614        details.addEventListener("toggle", () => {
53615          if (details.open && !details.querySelector(".player-container")) {
53616            createPlayer(details);
53617          } else if (!details.open) {
53618            const provider = details.querySelector("youtube-media-provider");
53619            if (provider && typeof provider.pause === "function") {
53620              provider.pause();
53621            }
53622          }
53623        });
53624      });
53625    }
53626
53627    if (document.readyState === "loading") {
53628      document.addEventListener("DOMContentLoaded", initLazyPlayers);
53629    } else {
53630      initLazyPlayers();
53631    }
53632  })();
53633</script>
53633<script>
53634  (() => {
53635    if (window.__courseCompletionsInitialized) return;
53636    window.__courseCompletionsInitialized = true;
53637
53638    const STORAGE_KEY = "course-completions";
53639
53640    function slugify(text) {
53641      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
53642    }
53643
53644    function getCompletions() {
53645      try {
53646        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
53647      } catch {
53648        return [];
53649      }
53650    }
53651
53652    function saveCompletions(completions) {
53653      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
53654    }
53655
53656    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
53657    function canonicalFromStored(storedId) {
53658      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53659      if (!m) return slugify(storedId);
53660      const [, slug, p, mv, title] = m;
53661      return `${slug}-${p}-${mv}-${slugify(title)}`;
53662    }
53663
53664    function canonicalPlusOne(storedId) {
53665      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53666      if (!m) return null;
53667      const [, slug, p, mv, title] = m;
53668      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
53669    }
53670
53671    function isCompleted(canonicalId, completions) {
53672      if (completions.includes(canonicalId)) return true;
53673      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
53674      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
53675    }
53676
53677    function migrateCompletions() {
53678      const completions = getCompletions();
53679      let changed = false;
53680      const migrated = completions.map((c) => {
53681        const canon = canonicalFromStored(c);
53682        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
53683        const plusOne = canonicalPlusOne(c);
53684        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
53685          if (plusOne !== c) changed = true;
53686          return plusOne;
53687        }
53688        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
53689          changed = true;
53690          return canon;
53691        }
53692        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
53693        if (canon !== c) {
53694          changed = true;
53695          return canon;
53696        }
53697        return c;
53698      });
53699      // Elimina duplicados tras migración
53700      const deduped = [...new Set(migrated)];
53701      if (changed || deduped.length !== completions.length) {
53702        saveCompletions(deduped);
53703        return deduped;
53704      }
53705      return completions;
53706    }
53707
53708    function updateButtonState(btn, completions) {
53709      const id = btn.dataset.completionId;
53710      const list = completions ?? getCompletions();
53711      btn.classList.toggle("completed", isCompleted(id, list));
53712    }
53713
53714    function checkModuleCompletion(completions) {
53715      const list = completions ?? getCompletions();
53716      document.querySelectorAll(".course-video").forEach((module) => {
53717        const btns = module.querySelectorAll(".complete-btn");
53718        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
53719        const check = module.querySelector(".module-check");
53720        if (check) check.classList.toggle("visible", allCompleted);
53721      });
53722    }
53723
53724    function initCompletionButtons() {
53725      const completions = migrateCompletions();
53726
53727      document.querySelectorAll(".complete-btn").forEach((btn) => {
53728        if (btn.dataset.completionInit) return;
53729        btn.dataset.completionInit = "true";
53730        updateButtonState(btn, completions);
53731
53732        btn.addEventListener("click", (e) => {
53733          e.preventDefault();
53734          e.stopPropagation();
53735
53736          const id = btn.dataset.completionId;
53737          let list = getCompletions();
53738          const completed = isCompleted(id, list);
53739
53740          if (completed) {
53741            // Elimina canónico y cualquier variante legacy que mapee al mismo id
53742            list = list.filter((c) =>
53742 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
53743          } else {
53744            list.push(id);
53745          }
53746
53747          saveCompletions(list);
53748          updateButtonState(btn, list);
53749          checkModuleCompletion(list);
53750        });
53751      });
53752
53753      checkModuleCompletion(completions);
53754    }
53755
53756    if (document.readyState === "loading") {
53757      document.addEventListener("DOMContentLoaded", initCompletionButtons);
53758    } else {
53759      initCompletionButtons();
53760    }
53761  })();
53762</script>
53762<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="12010" data-end="13710" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Librerías en Astro (MarkdownIt, VanillaTilt)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>28 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-librerias-en-astro-markdownit-vanillatilt" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
53762<script>
53763  (() => {
53764    if (window.__courseLazyPlayersInitialized) return;
53765    window.__courseLazyPlayersInitialized = true;
53766
53767    function createPlayer(details) {
53768      const videoId = details.dataset.videoId;
53769      const start = details.dataset.start;
53770      const end = details.dataset.end;
53771
53772      const container = document.createElement("div");
53773      container.className = "player-container";
53774
53775      const spinner = document.createElement("div");
53776      spinner.className = "spinner";
53777      container.appendChild(spinner);
53778
53779      const controller = document.createElement("media-controller");
53780      controller.setAttribute("defaultsubtitles", "");
53781
53782      const provider = document.createElement("youtube-media-provider");
53783      provider.setAttribute("slot", "media");
53784      provider.setAttribute("video-id", videoId);
53785      provider.setAttribute("start", start);
53786      provider.setAttribute("end", end);
53787
53788      const controlBar = document.createElement("media-control-bar");
53789
53790      const playBtn = document.createElement("media-play-button");
53791      const timeDisplay = document.createElement("media-time-display");
53792      timeDisplay.setAttribute("showduration", "");
53793      const timeRange = document.createElement("media-time-range");
53794      const playbackRate = document.createElement("media-playback-rate-button");
53795      const muteBtn = document.createElement("media-mute-button");
53796      const volumeRange = document.createElement("media-volume-range");
53797
53798      const captionsBtn = document.createElement("media-captions-button");
53799      captionsBtn.addEventListener("click", (e) => {
53800        e.stopPropagation();
53801        const provider = container.querySelector("youtube-media-provider");
53802        if (provider) provider.toggleCaptions();
53803      });
53804
53805      const qualityBtn = document.createElement("media-chrome-button");
53806      qualityBtn.textContent = "--";
53807      qualityBtn.addEventListener("click", () => {
53808        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
53809      });
53810
53811      const fullscreenBtn = document.createElement("media-fullscreen-button");
53812
53813      controlBar.append(
53814        playBtn, timeDisplay, timeRange,
53815        playbackRate, muteBtn, volumeRange,
53816        captionsBtn, qualityBtn, fullscreenBtn
53817      );
53818
53819      const poster = document.createElement("media-poster-image");
53820      poster.setAttribute("slot", "poster");
53821      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
53822
53823      controller.append(provider, poster, controlBar);
53824
53825      captionsBtn.setAttribute("aria-checked", "true");
53826      controller.setAttribute("mediasubtitlesshowing", "on");
53827
53828      container.appendChild(controller);
53829      details.appendChild(container);
53830
53831      provider.addEventListener("waiting", () => container.classList.add("loading"));
53832      provider.addEventListener("playing", () => container.classList.remove("loading"));
53833      provider.addEventListener("play", () => container.classList.remove("loading"));
53834
53835      provider._textTracks.addEventListener("change", () => {
53836        const enabled = provider._captionsEnabled;
53837        captionsBtn.setAttribute("aria-checked", enabled);
53838        if (enabled) {
53839          controller.setAttribute("mediasubtitlesshowing", "on");
53840        } else {
53841          controller.removeAttribute("mediasubtitlesshowing");
53842        }
53843      });
53844
53845      provider.addEventListener("qualitychange", (e) => {
53846        qualityBtn.textContent = e.detail.quality;
53847      });
53848    }
53849
53850    function initLazyPlayers() {
53851      document.querySelectorAll("details[data-video-id]").forEach((details) => {
53852        if (details.dataset.lazyInit) return;
53853        details.dataset.lazyInit = "true";
53854        details.addEventListener("toggle", () => {
53855          if (details.open && !details.querySelector(".player-container")) {
53856            createPlayer(details);
53857          } else if (!details.open) {
53858            const provider = details.querySelector("youtube-media-provider");
53859            if (provider && typeof provider.pause === "function") {
53860              provider.pause();
53861            }
53862          }
53863        });
53864      });
53865    }
53866
53867    if (document.readyState === "loading") {
53868      document.addEventListener("DOMContentLoaded", initLazyPlayers);
53869    } else {
53870      initLazyPlayers();
53871    }
53872  })();
53873</script>
53873<script>
53874  (() => {
53875    if (window.__courseCompletionsInitialized) return;
53876    window.__courseCompletionsInitialized = true;
53877
53878    const STORAGE_KEY = "course-completions";
53879
53880    function slugify(text) {
53881      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
53882    }
53883
53884    function getCompletions() {
53885      try {
53886        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
53887      } catch {
53888        return [];
53889      }
53890    }
53891
53892    function saveCompletions(completions) {
53893      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
53894    }
53895
53896    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
53897    function canonicalFromStored(storedId) {
53898      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53899      if (!m) return slugify(storedId);
53900      const [, slug, p, mv, title] = m;
53901      return `${slug}-${p}-${mv}-${slugify(title)}`;
53902    }
53903
53904    function canonicalPlusOne(storedId) {
53905      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
53906      if (!m) return null;
53907      const [, slug, p, mv, title] = m;
53908      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
53909    }
53910
53911    function isCompleted(canonicalId, completions) {
53912      if (completions.includes(canonicalId)) return true;
53913      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
53914      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
53915    }
53916
53917    function migrateCompletions() {
53918      const completions = getCompletions();
53919      let changed = false;
53920      const migrated = completions.map((c) => {
53921        const canon = canonicalFromStored(c);
53922        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
53923        const plusOne = canonicalPlusOne(c);
53924        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
53925          if (plusOne !== c) changed = true;
53926          return plusOne;
53927        }
53928        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
53929          changed = true;
53930          return canon;
53931        }
53932        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
53933        if (canon !== c) {
53934          changed = true;
53935          return canon;
53936        }
53937        return c;
53938      });
53939      // Elimina duplicados tras migración
53940      const deduped = [...new Set(migrated)];
53941      if (changed || deduped.length !== completions.length) {
53942        saveCompletions(deduped);
53943        return deduped;
53944      }
53945      return completions;
53946    }
53947
53948    function updateButtonState(btn, completions) {
53949      const id = btn.dataset.completionId;
53950      const list = completions ?? getCompletions();
53951      btn.classList.toggle("completed", isCompleted(id, list));
53952    }
53953
53954    function checkModuleCompletion(completions) {
53955      const list = completions ?? getCompletions();
53956      document.querySelectorAll(".course-video").forEach((module) => {
53957        const btns = module.querySelectorAll(".complete-btn");
53958        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
53959        const check = module.querySelector(".module-check");
53960        if (check) check.classList.toggle("visible", allCompleted);
53961      });
53962    }
53963
53964    function initCompletionButtons() {
53965      const completions = migrateCompletions();
53966
53967      document.querySelectorAll(".complete-btn").forEach((btn) => {
53968        if (btn.dataset.completionInit) return;
53969        btn.dataset.completionInit = "true";
53970        updateButtonState(btn, completions);
53971
53972        btn.addEventListener("click", (e) => {
53973          e.preventDefault();
53974          e.stopPropagation();
53975
53976          const id = btn.dataset.completionId;
53977          let list = getCompletions();
53978          const completed = isCompleted(id, list);
53979
53980          if (completed) {
53981            // Elimina canónico y cualquier variante legacy que mapee al mismo id
53982            list = list.filter((c) =>
53982 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
53983          } else {
53984            list.push(id);
53985          }
53986
53987          saveCompletions(list);
53988          updateButtonState(btn, list);
53989          checkModuleCompletion(list);
53990        });
53991      });
53992
53993      checkModuleCompletion(completions);
53994    }
53995
53996    if (document.readyState === "loading") {
53997      document.addEventListener("DOMContentLoaded", initCompletionButtons);
53998    } else {
53999      initCompletionButtons();
54000    }
54001  })();
54002</script>
54002<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="o0eWJ6JpyZY" data-start="13710" data-end="16816" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>12. App de ejemplo</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>52 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-3-app-de-ejemplo" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
54002<script>
54003  (() => {
54004    if (window.__courseLazyPlayersInitialized) return;
54005    window.__courseLazyPlayersInitialized = true;
54006
54007    function createPlayer(details) {
54008      const videoId = details.dataset.videoId;
54009      const start = details.dataset.start;
54010      const end = details.dataset.end;
54011
54012      const container = document.createElement("div");
54013      container.className = "player-container";
54014
54015      const spinner = document.createElement("div");
54016      spinner.className = "spinner";
54017      container.appendChild(spinner);
54018
54019      const controller = document.createElement("media-controller");
54020      controller.setAttribute("defaultsubtitles", "");
54021
54022      const provider = document.createElement("youtube-media-provider");
54023      provider.setAttribute("slot", "media");
54024      provider.setAttribute("video-id", videoId);
54025      provider.setAttribute("start", start);
54026      provider.setAttribute("end", end);
54027
54028      const controlBar = document.createElement("media-control-bar");
54029
54030      const playBtn = document.createElement("media-play-button");
54031      const timeDisplay = document.createElement("media-time-display");
54032      timeDisplay.setAttribute("showduration", "");
54033      const timeRange = document.createElement("media-time-range");
54034      const playbackRate = document.createElement("media-playback-rate-button");
54035      const muteBtn = document.createElement("media-mute-button");
54036      const volumeRange = document.createElement("media-volume-range");
54037
54038      const captionsBtn = document.createElement("media-captions-button");
54039      captionsBtn.addEventListener("click", (e) => {
54040        e.stopPropagation();
54041        const provider = container.querySelector("youtube-media-provider");
54042        if (provider) provider.toggleCaptions();
54043      });
54044
54045      const qualityBtn = document.createElement("media-chrome-button");
54046      qualityBtn.textContent = "--";
54047      qualityBtn.addEventListener("click", () => {
54048        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
54049      });
54050
54051      const fullscreenBtn = document.createElement("media-fullscreen-button");
54052
54053      controlBar.append(
54054        playBtn, timeDisplay, timeRange,
54055        playbackRate, muteBtn, volumeRange,
54056        captionsBtn, qualityBtn, fullscreenBtn
54057      );
54058
54059      const poster = document.createElement("media-poster-image");
54060      poster.setAttribute("slot", "poster");
54061      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
54062
54063      controller.append(provider, poster, controlBar);
54064
54065      captionsBtn.setAttribute("aria-checked", "true");
54066      controller.setAttribute("mediasubtitlesshowing", "on");
54067
54068      container.appendChild(controller);
54069      details.appendChild(container);
54070
54071      provider.addEventListener("waiting", () => container.classList.add("loading"));
54072      provider.addEventListener("playing", () => container.classList.remove("loading"));
54073      provider.addEventListener("play", () => container.classList.remove("loading"));
54074
54075      provider._textTracks.addEventListener("change", () => {
54076        const enabled = provider._captionsEnabled;
54077        captionsBtn.setAttribute("aria-checked", enabled);
54078        if (enabled) {
54079          controller.setAttribute("mediasubtitlesshowing", "on");
54080        } else {
54081          controller.removeAttribute("mediasubtitlesshowing");
54082        }
54083      });
54084
54085      provider.addEventListener("qualitychange", (e) => {
54086        qualityBtn.textContent = e.detail.quality;
54087      });
54088    }
54089
54090    function initLazyPlayers() {
54091      document.querySelectorAll("details[data-video-id]").forEach((details) => {
54092        if (details.dataset.lazyInit) return;
54093        details.dataset.lazyInit = "true";
54094        details.addEventListener("toggle", () => {
54095          if (details.open && !details.querySelector(".player-container")) {
54096            createPlayer(details);
54097          } else if (!details.open) {
54098            const provider = details.querySelector("youtube-media-provider");
54099            if (provider && typeof provider.pause === "function") {
54100              provider.pause();
54101            }
54102          }
54103        });
54104      });
54105    }
54106
54107    if (document.readyState === "loading") {
54108      document.addEventListener("DOMContentLoaded", initLazyPlayers);
54109    } else {
54110      initLazyPlayers();
54111    }
54112  })();
54113</script>
54113<script>
54114  (() => {
54115    if (window.__courseCompletionsInitialized) return;
54116    window.__courseCompletionsInitialized = true;
54117
54118    const STORAGE_KEY = "course-completions";
54119
54120    function slugify(text) {
54121      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
54122    }
54123
54124    function getCompletions() {
54125      try {
54126        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
54127      } catch {
54128        return [];
54129      }
54130    }
54131
54132    function saveCompletions(completions) {
54133      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
54134    }
54135
54136    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
54137    function canonicalFromStored(storedId) {
54138      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54139      if (!m) return slugify(storedId);
54140      const [, slug, p, mv, title] = m;
54141      return `${slug}-${p}-${mv}-${slugify(title)}`;
54142    }
54143
54144    function canonicalPlusOne(storedId) {
54145      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54146      if (!m) return null;
54147      const [, slug, p, mv, title] = m;
54148      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
54149    }
54150
54151    function isCompleted(canonicalId, completions) {
54152      if (completions.includes(canonicalId)) return true;
54153      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
54154      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
54155    }
54156
54157    function migrateCompletions() {
54158      const completions = getCompletions();
54159      let changed = false;
54160      const migrated = completions.map((c) => {
54161        const canon = canonicalFromStored(c);
54162        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
54163        const plusOne = canonicalPlusOne(c);
54164        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
54165          if (plusOne !== c) changed = true;
54166          return plusOne;
54167        }
54168        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
54169          changed = true;
54170          return canon;
54171        }
54172        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
54173        if (canon !== c) {
54174          changed = true;
54175          return canon;
54176        }
54177        return c;
54178      });
54179      // Elimina duplicados tras migración
54180      const deduped = [...new Set(migrated)];
54181      if (changed || deduped.length !== completions.length) {
54182        saveCompletions(deduped);
54183        return deduped;
54184      }
54185      return completions;
54186    }
54187
54188    function updateButtonState(btn, completions) {
54189      const id = btn.dataset.completionId;
54190      const list = completions ?? getCompletions();
54191      btn.classList.toggle("completed", isCompleted(id, list));
54192    }
54193
54194    function checkModuleCompletion(completions) {
54195      const list = completions ?? getCompletions();
54196      document.querySelectorAll(".course-video").forEach((module) => {
54197        const btns = module.querySelectorAll(".complete-btn");
54198        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
54199        const check = module.querySelector(".module-check");
54200        if (check) check.classList.toggle("visible", allCompleted);
54201      });
54202    }
54203
54204    function initCompletionButtons() {
54205      const completions = migrateCompletions();
54206
54207      document.querySelectorAll(".complete-btn").forEach((btn) => {
54208        if (btn.dataset.completionInit) return;
54209        btn.dataset.completionInit = "true";
54210        updateButtonState(btn, completions);
54211
54212        btn.addEventListener("click", (e) => {
54213          e.preventDefault();
54214          e.stopPropagation();
54215
54216          const id = btn.dataset.completionId;
54217          let list = getCompletions();
54218          const completed = isCompleted(id, list);
54219
54220          if (completed) {
54221            // Elimina canónico y cualquier variante legacy que mapee al mismo id
54222            list = list.filter((c) =>
54222 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
54223          } else {
54224            list.push(id);
54225          }
54226
54227          saveCompletions(list);
54228          updateButtonState(btn, list);
54229          checkModuleCompletion(list);
54230        });
54231      });
54232
54233      checkModuleCompletion(completions);
54234    }
54235
54236    if (document.readyState === "loading") {
54237      document.addEventListener("DOMContentLoaded", initCompletionButtons);
54238    } else {
54239      initCompletionButtons();
54240    }
54241  })();
54242</script>
54242</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 20 📗 Servidor VPS<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="1280" data-end="2826" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Que es un servidor VPS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>26 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-que-es-un-servidor-vps" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
54242<script>
54243  (() => {
54244    if (window.__courseLazyPlayersInitialized) return;
54245    window.__courseLazyPlayersInitialized = true;
54246
54247    function createPlayer(details) {
54248      const videoId = details.dataset.videoId;
54249      const start = details.dataset.start;
54250      const end = details.dataset.end;
54251
54252      const container = document.createElement("div");
54253      container.className = "player-container";
54254
54255      const spinner = document.createElement("div");
54256      spinner.className = "spinner";
54257      container.appendChild(spinner);
54258
54259      const controller = document.createElement("media-controller");
54260      controller.setAttribute("defaultsubtitles", "");
54261
54262      const provider = document.createElement("youtube-media-provider");
54263      provider.setAttribute("slot", "media");
54264      provider.setAttribute("video-id", videoId);
54265      provider.setAttribute("start", start);
54266      provider.setAttribute("end", end);
54267
54268      const controlBar = document.createElement("media-control-bar");
54269
54270      const playBtn = document.createElement("media-play-button");
54271      const timeDisplay = document.createElement("media-time-display");
54272      timeDisplay.setAttribute("showduration", "");
54273      const timeRange = document.createElement("media-time-range");
54274      const playbackRate = document.createElement("media-playback-rate-button");
54275      const muteBtn = document.createElement("media-mute-button");
54276      const volumeRange = document.createElement("media-volume-range");
54277
54278      const captionsBtn = document.createElement("media-captions-button");
54279      captionsBtn.addEventListener("click", (e) => {
54280        e.stopPropagation();
54281        const provider = container.querySelector("youtube-media-provider");
54282        if (provider) provider.toggleCaptions();
54283      });
54284
54285      const qualityBtn = document.createElement("media-chrome-button");
54286      qualityBtn.textContent = "--";
54287      qualityBtn.addEventListener("click", () => {
54288        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
54289      });
54290
54291      const fullscreenBtn = document.createElement("media-fullscreen-button");
54292
54293      controlBar.append(
54294        playBtn, timeDisplay, timeRange,
54295        playbackRate, muteBtn, volumeRange,
54296        captionsBtn, qualityBtn, fullscreenBtn
54297      );
54298
54299      const poster = document.createElement("media-poster-image");
54300      poster.setAttribute("slot", "poster");
54301      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
54302
54303      controller.append(provider, poster, controlBar);
54304
54305      captionsBtn.setAttribute("aria-checked", "true");
54306      controller.setAttribute("mediasubtitlesshowing", "on");
54307
54308      container.appendChild(controller);
54309      details.appendChild(container);
54310
54311      provider.addEventListener("waiting", () => container.classList.add("loading"));
54312      provider.addEventListener("playing", () => container.classList.remove("loading"));
54313      provider.addEventListener("play", () => container.classList.remove("loading"));
54314
54315      provider._textTracks.addEventListener("change", () => {
54316        const enabled = provider._captionsEnabled;
54317        captionsBtn.setAttribute("aria-checked", enabled);
54318        if (enabled) {
54319          controller.setAttribute("mediasubtitlesshowing", "on");
54320        } else {
54321          controller.removeAttribute("mediasubtitlesshowing");
54322        }
54323      });
54324
54325      provider.addEventListener("qualitychange", (e) => {
54326        qualityBtn.textContent = e.detail.quality;
54327      });
54328    }
54329
54330    function initLazyPlayers() {
54331      document.querySelectorAll("details[data-video-id]").forEach((details) => {
54332        if (details.dataset.lazyInit) return;
54333        details.dataset.lazyInit = "true";
54334        details.addEventListener("toggle", () => {
54335          if (details.open && !details.querySelector(".player-container")) {
54336            createPlayer(details);
54337          } else if (!details.open) {
54338            const provider = details.querySelector("youtube-media-provider");
54339            if (provider && typeof provider.pause === "function") {
54340              provider.pause();
54341            }
54342          }
54343        });
54344      });
54345    }
54346
54347    if (document.readyState === "loading") {
54348      document.addEventListener("DOMContentLoaded", initLazyPlayers);
54349    } else {
54350      initLazyPlayers();
54351    }
54352  })();
54353</script>
54353<script>
54354  (() => {
54355    if (window.__courseCompletionsInitialized) return;
54356    window.__courseCompletionsInitialized = true;
54357
54358    const STORAGE_KEY = "course-completions";
54359
54360    function slugify(text) {
54361      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
54362    }
54363
54364    function getCompletions() {
54365      try {
54366        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
54367      } catch {
54368        return [];
54369      }
54370    }
54371
54372    function saveCompletions(completions) {
54373      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
54374    }
54375
54376    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
54377    function canonicalFromStored(storedId) {
54378      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54379      if (!m) return slugify(storedId);
54380      const [, slug, p, mv, title] = m;
54381      return `${slug}-${p}-${mv}-${slugify(title)}`;
54382    }
54383
54384    function canonicalPlusOne(storedId) {
54385      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54386      if (!m) return null;
54387      const [, slug, p, mv, title] = m;
54388      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
54389    }
54390
54391    function isCompleted(canonicalId, completions) {
54392      if (completions.includes(canonicalId)) return true;
54393      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
54394      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
54395    }
54396
54397    function migrateCompletions() {
54398      const completions = getCompletions();
54399      let changed = false;
54400      const migrated = completions.map((c) => {
54401        const canon = canonicalFromStored(c);
54402        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
54403        const plusOne = canonicalPlusOne(c);
54404        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
54405          if (plusOne !== c) changed = true;
54406          return plusOne;
54407        }
54408        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
54409          changed = true;
54410          return canon;
54411        }
54412        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
54413        if (canon !== c) {
54414          changed = true;
54415          return canon;
54416        }
54417        return c;
54418      });
54419      // Elimina duplicados tras migración
54420      const deduped = [...new Set(migrated)];
54421      if (changed || deduped.length !== completions.length) {
54422        saveCompletions(deduped);
54423        return deduped;
54424      }
54425      return completions;
54426    }
54427
54428    function updateButtonState(btn, completions) {
54429      const id = btn.dataset.completionId;
54430      const list = completions ?? getCompletions();
54431      btn.classList.toggle("completed", isCompleted(id, list));
54432    }
54433
54434    function checkModuleCompletion(completions) {
54435      const list = completions ?? getCompletions();
54436      document.querySelectorAll(".course-video").forEach((module) => {
54437        const btns = module.querySelectorAll(".complete-btn");
54438        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
54439        const check = module.querySelector(".module-check");
54440        if (check) check.classList.toggle("visible", allCompleted);
54441      });
54442    }
54443
54444    function initCompletionButtons() {
54445      const completions = migrateCompletions();
54446
54447      document.querySelectorAll(".complete-btn").forEach((btn) => {
54448        if (btn.dataset.completionInit) return;
54449        btn.dataset.completionInit = "true";
54450        updateButtonState(btn, completions);
54451
54452        btn.addEventListener("click", (e) => {
54453          e.preventDefault();
54454          e.stopPropagation();
54455
54456          const id = btn.dataset.completionId;
54457          let list = getCompletions();
54458          const completed = isCompleted(id, list);
54459
54460          if (completed) {
54461            // Elimina canónico y cualquier variante legacy que mapee al mismo id
54462            list = list.filter((c) =>
54462 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
54463          } else {
54464            list.push(id);
54465          }
54466
54467          saveCompletions(list);
54468          updateButtonState(btn, list);
54469          checkModuleCompletion(list);
54470        });
54471      });
54472
54473      checkModuleCompletion(completions);
54474    }
54475
54476    if (document.readyState === "loading") {
54477      document.addEventListener("DOMContentLoaded", initCompletionButtons);
54478    } else {
54479      initCompletionButtons();
54480    }
54481  })();
54482</script>
54482<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="2826" data-end="5905" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Droplets de Digital Ocean</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>51 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-droplets-de-digital-ocean" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
54482<script>
54483  (() => {
54484    if (window.__courseLazyPlayersInitialized) return;
54485    window.__courseLazyPlayersInitialized = true;
54486
54487    function createPlayer(details) {
54488      const videoId = details.dataset.videoId;
54489      const start = details.dataset.start;
54490      const end = details.dataset.end;
54491
54492      const container = document.createElement("div");
54493      container.className = "player-container";
54494
54495      const spinner = document.createElement("div");
54496      spinner.className = "spinner";
54497      container.appendChild(spinner);
54498
54499      const controller = document.createElement("media-controller");
54500      controller.setAttribute("defaultsubtitles", "");
54501
54502      const provider = document.createElement("youtube-media-provider");
54503      provider.setAttribute("slot", "media");
54504      provider.setAttribute("video-id", videoId);
54505      provider.setAttribute("start", start);
54506      provider.setAttribute("end", end);
54507
54508      const controlBar = document.createElement("media-control-bar");
54509
54510      const playBtn = document.createElement("media-play-button");
54511      const timeDisplay = document.createElement("media-time-display");
54512      timeDisplay.setAttribute("showduration", "");
54513      const timeRange = document.createElement("media-time-range");
54514      const playbackRate = document.createElement("media-playback-rate-button");
54515      const muteBtn = document.createElement("media-mute-button");
54516      const volumeRange = document.createElement("media-volume-range");
54517
54518      const captionsBtn = document.createElement("media-captions-button");
54519      captionsBtn.addEventListener("click", (e) => {
54520        e.stopPropagation();
54521        const provider = container.querySelector("youtube-media-provider");
54522        if (provider) provider.toggleCaptions();
54523      });
54524
54525      const qualityBtn = document.createElement("media-chrome-button");
54526      qualityBtn.textContent = "--";
54527      qualityBtn.addEventListener("click", () => {
54528        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
54529      });
54530
54531      const fullscreenBtn = document.createElement("media-fullscreen-button");
54532
54533      controlBar.append(
54534        playBtn, timeDisplay, timeRange,
54535        playbackRate, muteBtn, volumeRange,
54536        captionsBtn, qualityBtn, fullscreenBtn
54537      );
54538
54539      const poster = document.createElement("media-poster-image");
54540      poster.setAttribute("slot", "poster");
54541      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
54542
54543      controller.append(provider, poster, controlBar);
54544
54545      captionsBtn.setAttribute("aria-checked", "true");
54546      controller.setAttribute("mediasubtitlesshowing", "on");
54547
54548      container.appendChild(controller);
54549      details.appendChild(container);
54550
54551      provider.addEventListener("waiting", () => container.classList.add("loading"));
54552      provider.addEventListener("playing", () => container.classList.remove("loading"));
54553      provider.addEventListener("play", () => container.classList.remove("loading"));
54554
54555      provider._textTracks.addEventListener("change", () => {
54556        const enabled = provider._captionsEnabled;
54557        captionsBtn.setAttribute("aria-checked", enabled);
54558        if (enabled) {
54559          controller.setAttribute("mediasubtitlesshowing", "on");
54560        } else {
54561          controller.removeAttribute("mediasubtitlesshowing");
54562        }
54563      });
54564
54565      provider.addEventListener("qualitychange", (e) => {
54566        qualityBtn.textContent = e.detail.quality;
54567      });
54568    }
54569
54570    function initLazyPlayers() {
54571      document.querySelectorAll("details[data-video-id]").forEach((details) => {
54572        if (details.dataset.lazyInit) return;
54573        details.dataset.lazyInit = "true";
54574        details.addEventListener("toggle", () => {
54575          if (details.open && !details.querySelector(".player-container")) {
54576            createPlayer(details);
54577          } else if (!details.open) {
54578            const provider = details.querySelector("youtube-media-provider");
54579            if (provider && typeof provider.pause === "function") {
54580              provider.pause();
54581            }
54582          }
54583        });
54584      });
54585    }
54586
54587    if (document.readyState === "loading") {
54588      document.addEventListener("DOMContentLoaded", initLazyPlayers);
54589    } else {
54590      initLazyPlayers();
54591    }
54592  })();
54593</script>
54593<script>
54594  (() => {
54595    if (window.__courseCompletionsInitialized) return;
54596    window.__courseCompletionsInitialized = true;
54597
54598    const STORAGE_KEY = "course-completions";
54599
54600    function slugify(text) {
54601      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
54602    }
54603
54604    function getCompletions() {
54605      try {
54606        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
54607      } catch {
54608        return [];
54609      }
54610    }
54611
54612    function saveCompletions(completions) {
54613      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
54614    }
54615
54616    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
54617    function canonicalFromStored(storedId) {
54618      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54619      if (!m) return slugify(storedId);
54620      const [, slug, p, mv, title] = m;
54621      return `${slug}-${p}-${mv}-${slugify(title)}`;
54622    }
54623
54624    function canonicalPlusOne(storedId) {
54625      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54626      if (!m) return null;
54627      const [, slug, p, mv, title] = m;
54628      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
54629    }
54630
54631    function isCompleted(canonicalId, completions) {
54632      if (completions.includes(canonicalId)) return true;
54633      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
54634      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
54635    }
54636
54637    function migrateCompletions() {
54638      const completions = getCompletions();
54639      let changed = false;
54640      const migrated = completions.map((c) => {
54641        const canon = canonicalFromStored(c);
54642        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
54643        const plusOne = canonicalPlusOne(c);
54644        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
54645          if (plusOne !== c) changed = true;
54646          return plusOne;
54647        }
54648        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
54649          changed = true;
54650          return canon;
54651        }
54652        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
54653        if (canon !== c) {
54654          changed = true;
54655          return canon;
54656        }
54657        return c;
54658      });
54659      // Elimina duplicados tras migración
54660      const deduped = [...new Set(migrated)];
54661      if (changed || deduped.length !== completions.length) {
54662        saveCompletions(deduped);
54663        return deduped;
54664      }
54665      return completions;
54666    }
54667
54668    function updateButtonState(btn, completions) {
54669      const id = btn.dataset.completionId;
54670      const list = completions ?? getCompletions();
54671      btn.classList.toggle("completed", isCompleted(id, list));
54672    }
54673
54674    function checkModuleCompletion(completions) {
54675      const list = completions ?? getCompletions();
54676      document.querySelectorAll(".course-video").forEach((module) => {
54677        const btns = module.querySelectorAll(".complete-btn");
54678        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
54679        const check = module.querySelector(".module-check");
54680        if (check) check.classList.toggle("visible", allCompleted);
54681      });
54682    }
54683
54684    function initCompletionButtons() {
54685      const completions = migrateCompletions();
54686
54687      document.querySelectorAll(".complete-btn").forEach((btn) => {
54688        if (btn.dataset.completionInit) return;
54689        btn.dataset.completionInit = "true";
54690        updateButtonState(btn, completions);
54691
54692        btn.addEventListener("click", (e) => {
54693          e.preventDefault();
54694          e.stopPropagation();
54695
54696          const id = btn.dataset.completionId;
54697          let list = getCompletions();
54698          const completed = isCompleted(id, list);
54699
54700          if (completed) {
54701            // Elimina canónico y cualquier variante legacy que mapee al mismo id
54702            list = list.filter((c) =>
54702 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
54703          } else {
54704            list.push(id);
54705          }
54706
54707          saveCompletions(list);
54708          updateButtonState(btn, list);
54709          checkModuleCompletion(list);
54710        });
54711      });
54712
54713      checkModuleCompletion(completions);
54714    }
54715
54716    if (document.readyState === "loading") {
54717      document.addEventListener("DOMContentLoaded", initCompletionButtons);
54718    } else {
54719      initCompletionButtons();
54720    }
54721  })();
54722</script>
54722<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="6374" data-end="7556" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Preparando el VPS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>20 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-preparando-el-vps" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
54722<script>
54723  (() => {
54724    if (window.__courseLazyPlayersInitialized) return;
54725    window.__courseLazyPlayersInitialized = true;
54726
54727    function createPlayer(details) {
54728      const videoId = details.dataset.videoId;
54729      const start = details.dataset.start;
54730      const end = details.dataset.end;
54731
54732      const container = document.createElement("div");
54733      container.className = "player-container";
54734
54735      const spinner = document.createElement("div");
54736      spinner.className = "spinner";
54737      container.appendChild(spinner);
54738
54739      const controller = document.createElement("media-controller");
54740      controller.setAttribute("defaultsubtitles", "");
54741
54742      const provider = document.createElement("youtube-media-provider");
54743      provider.setAttribute("slot", "media");
54744      provider.setAttribute("video-id", videoId);
54745      provider.setAttribute("start", start);
54746      provider.setAttribute("end", end);
54747
54748      const controlBar = document.createElement("media-control-bar");
54749
54750      const playBtn = document.createElement("media-play-button");
54751      const timeDisplay = document.createElement("media-time-display");
54752      timeDisplay.setAttribute("showduration", "");
54753      const timeRange = document.createElement("media-time-range");
54754      const playbackRate = document.createElement("media-playback-rate-button");
54755      const muteBtn = document.createElement("media-mute-button");
54756      const volumeRange = document.createElement("media-volume-range");
54757
54758      const captionsBtn = document.createElement("media-captions-button");
54759      captionsBtn.addEventListener("click", (e) => {
54760        e.stopPropagation();
54761        const provider = container.querySelector("youtube-media-provider");
54762        if (provider) provider.toggleCaptions();
54763      });
54764
54765      const qualityBtn = document.createElement("media-chrome-button");
54766      qualityBtn.textContent = "--";
54767      qualityBtn.addEventListener("click", () => {
54768        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
54769      });
54770
54771      const fullscreenBtn = document.createElement("media-fullscreen-button");
54772
54773      controlBar.append(
54774        playBtn, timeDisplay, timeRange,
54775        playbackRate, muteBtn, volumeRange,
54776        captionsBtn, qualityBtn, fullscreenBtn
54777      );
54778
54779      const poster = document.createElement("media-poster-image");
54780      poster.setAttribute("slot", "poster");
54781      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
54782
54783      controller.append(provider, poster, controlBar);
54784
54785      captionsBtn.setAttribute("aria-checked", "true");
54786      controller.setAttribute("mediasubtitlesshowing", "on");
54787
54788      container.appendChild(controller);
54789      details.appendChild(container);
54790
54791      provider.addEventListener("waiting", () => container.classList.add("loading"));
54792      provider.addEventListener("playing", () => container.classList.remove("loading"));
54793      provider.addEventListener("play", () => container.classList.remove("loading"));
54794
54795      provider._textTracks.addEventListener("change", () => {
54796        const enabled = provider._captionsEnabled;
54797        captionsBtn.setAttribute("aria-checked", enabled);
54798        if (enabled) {
54799          controller.setAttribute("mediasubtitlesshowing", "on");
54800        } else {
54801          controller.removeAttribute("mediasubtitlesshowing");
54802        }
54803      });
54804
54805      provider.addEventListener("qualitychange", (e) => {
54806        qualityBtn.textContent = e.detail.quality;
54807      });
54808    }
54809
54810    function initLazyPlayers() {
54811      document.querySelectorAll("details[data-video-id]").forEach((details) => {
54812        if (details.dataset.lazyInit) return;
54813        details.dataset.lazyInit = "true";
54814        details.addEventListener("toggle", () => {
54815          if (details.open && !details.querySelector(".player-container")) {
54816            createPlayer(details);
54817          } else if (!details.open) {
54818            const provider = details.querySelector("youtube-media-provider");
54819            if (provider && typeof provider.pause === "function") {
54820              provider.pause();
54821            }
54822          }
54823        });
54824      });
54825    }
54826
54827    if (document.readyState === "loading") {
54828      document.addEventListener("DOMContentLoaded", initLazyPlayers);
54829    } else {
54830      initLazyPlayers();
54831    }
54832  })();
54833</script>
54833<script>
54834  (() => {
54835    if (window.__courseCompletionsInitialized) return;
54836    window.__courseCompletionsInitialized = true;
54837
54838    const STORAGE_KEY = "course-completions";
54839
54840    function slugify(text) {
54841      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
54842    }
54843
54844    function getCompletions() {
54845      try {
54846        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
54847      } catch {
54848        return [];
54849      }
54850    }
54851
54852    function saveCompletions(completions) {
54853      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
54854    }
54855
54856    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
54857    function canonicalFromStored(storedId) {
54858      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54859      if (!m) return slugify(storedId);
54860      const [, slug, p, mv, title] = m;
54861      return `${slug}-${p}-${mv}-${slugify(title)}`;
54862    }
54863
54864    function canonicalPlusOne(storedId) {
54865      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
54866      if (!m) return null;
54867      const [, slug, p, mv, title] = m;
54868      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
54869    }
54870
54871    function isCompleted(canonicalId, completions) {
54872      if (completions.includes(canonicalId)) return true;
54873      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
54874      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
54875    }
54876
54877    function migrateCompletions() {
54878      const completions = getCompletions();
54879      let changed = false;
54880      const migrated = completions.map((c) => {
54881        const canon = canonicalFromStored(c);
54882        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
54883        const plusOne = canonicalPlusOne(c);
54884        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
54885          if (plusOne !== c) changed = true;
54886          return plusOne;
54887        }
54888        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
54889          changed = true;
54890          return canon;
54891        }
54892        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
54893        if (canon !== c) {
54894          changed = true;
54895          return canon;
54896        }
54897        return c;
54898      });
54899      // Elimina duplicados tras migración
54900      const deduped = [...new Set(migrated)];
54901      if (changed || deduped.length !== completions.length) {
54902        saveCompletions(deduped);
54903        return deduped;
54904      }
54905      return completions;
54906    }
54907
54908    function updateButtonState(btn, completions) {
54909      const id = btn.dataset.completionId;
54910      const list = completions ?? getCompletions();
54911      btn.classList.toggle("completed", isCompleted(id, list));
54912    }
54913
54914    function checkModuleCompletion(completions) {
54915      const list = completions ?? getCompletions();
54916      document.querySelectorAll(".course-video").forEach((module) => {
54917        const btns = module.querySelectorAll(".complete-btn");
54918        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
54919        const check = module.querySelector(".module-check");
54920        if (check) check.classList.toggle("visible", allCompleted);
54921      });
54922    }
54923
54924    function initCompletionButtons() {
54925      const completions = migrateCompletions();
54926
54927      document.querySelectorAll(".complete-btn").forEach((btn) => {
54928        if (btn.dataset.completionInit) return;
54929        btn.dataset.completionInit = "true";
54930        updateButtonState(btn, completions);
54931
54932        btn.addEventListener("click", (e) => {
54933          e.preventDefault();
54934          e.stopPropagation();
54935
54936          const id = btn.dataset.completionId;
54937          let list = getCompletions();
54938          const completed = isCompleted(id, list);
54939
54940          if (completed) {
54941            // Elimina canónico y cualquier variante legacy que mapee al mismo id
54942            list = list.filter((c) =>
54942 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
54943          } else {
54944            list.push(id);
54945          }
54946
54947          saveCompletions(list);
54948          updateButtonState(btn, list);
54949          checkModuleCompletion(list);
54950        });
54951      });
54952
54953      checkModuleCompletion(completions);
54954    }
54955
54956    if (document.readyState === "loading") {
54957      document.addEventListener("DOMContentLoaded", initCompletionButtons);
54958    } else {
54959      initCompletionButtons();
54960    }
54961  })();
54962</script>
54962<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="7556" data-end="8535" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Seguridad en el VPS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-seguridad-en-el-vps" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
54962<script>
54963  (() => {
54964    if (window.__courseLazyPlayersInitialized) return;
54965    window.__courseLazyPlayersInitialized = true;
54966
54967    function createPlayer(details) {
54968      const videoId = details.dataset.videoId;
54969      const start = details.dataset.start;
54970      const end = details.dataset.end;
54971
54972      const container = document.createElement("div");
54973      container.className = "player-container";
54974
54975      const spinner = document.createElement("div");
54976      spinner.className = "spinner";
54977      container.appendChild(spinner);
54978
54979      const controller = document.createElement("media-controller");
54980      controller.setAttribute("defaultsubtitles", "");
54981
54982      const provider = document.createElement("youtube-media-provider");
54983      provider.setAttribute("slot", "media");
54984      provider.setAttribute("video-id", videoId);
54985      provider.setAttribute("start", start);
54986      provider.setAttribute("end", end);
54987
54988      const controlBar = document.createElement("media-control-bar");
54989
54990      const playBtn = document.createElement("media-play-button");
54991      const timeDisplay = document.createElement("media-time-display");
54992      timeDisplay.setAttribute("showduration", "");
54993      const timeRange = document.createElement("media-time-range");
54994      const playbackRate = document.createElement("media-playback-rate-button");
54995      const muteBtn = document.createElement("media-mute-button");
54996      const volumeRange = document.createElement("media-volume-range");
54997
54998      const captionsBtn = document.createElement("media-captions-button");
54999      captionsBtn.addEventListener("click", (e) => {
55000        e.stopPropagation();
55001        const provider = container.querySelector("youtube-media-provider");
55002        if (provider) provider.toggleCaptions();
55003      });
55004
55005      const qualityBtn = document.createElement("media-chrome-button");
55006      qualityBtn.textContent = "--";
55007      qualityBtn.addEventListener("click", () => {
55008        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
55009      });
55010
55011      const fullscreenBtn = document.createElement("media-fullscreen-button");
55012
55013      controlBar.append(
55014        playBtn, timeDisplay, timeRange,
55015        playbackRate, muteBtn, volumeRange,
55016        captionsBtn, qualityBtn, fullscreenBtn
55017      );
55018
55019      const poster = document.createElement("media-poster-image");
55020      poster.setAttribute("slot", "poster");
55021      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
55022
55023      controller.append(provider, poster, controlBar);
55024
55025      captionsBtn.setAttribute("aria-checked", "true");
55026      controller.setAttribute("mediasubtitlesshowing", "on");
55027
55028      container.appendChild(controller);
55029      details.appendChild(container);
55030
55031      provider.addEventListener("waiting", () => container.classList.add("loading"));
55032      provider.addEventListener("playing", () => container.classList.remove("loading"));
55033      provider.addEventListener("play", () => container.classList.remove("loading"));
55034
55035      provider._textTracks.addEventListener("change", () => {
55036        const enabled = provider._captionsEnabled;
55037        captionsBtn.setAttribute("aria-checked", enabled);
55038        if (enabled) {
55039          controller.setAttribute("mediasubtitlesshowing", "on");
55040        } else {
55041          controller.removeAttribute("mediasubtitlesshowing");
55042        }
55043      });
55044
55045      provider.addEventListener("qualitychange", (e) => {
55046        qualityBtn.textContent = e.detail.quality;
55047      });
55048    }
55049
55050    function initLazyPlayers() {
55051      document.querySelectorAll("details[data-video-id]").forEach((details) => {
55052        if (details.dataset.lazyInit) return;
55053        details.dataset.lazyInit = "true";
55054        details.addEventListener("toggle", () => {
55055          if (details.open && !details.querySelector(".player-container")) {
55056            createPlayer(details);
55057          } else if (!details.open) {
55058            const provider = details.querySelector("youtube-media-provider");
55059            if (provider && typeof provider.pause === "function") {
55060              provider.pause();
55061            }
55062          }
55063        });
55064      });
55065    }
55066
55067    if (document.readyState === "loading") {
55068      document.addEventListener("DOMContentLoaded", initLazyPlayers);
55069    } else {
55070      initLazyPlayers();
55071    }
55072  })();
55073</script>
55073<script>
55074  (() => {
55075    if (window.__courseCompletionsInitialized) return;
55076    window.__courseCompletionsInitialized = true;
55077
55078    const STORAGE_KEY = "course-completions";
55079
55080    function slugify(text) {
55081      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
55082    }
55083
55084    function getCompletions() {
55085      try {
55086        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
55087      } catch {
55088        return [];
55089      }
55090    }
55091
55092    function saveCompletions(completions) {
55093      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
55094    }
55095
55096    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
55097    function canonicalFromStored(storedId) {
55098      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55099      if (!m) return slugify(storedId);
55100      const [, slug, p, mv, title] = m;
55101      return `${slug}-${p}-${mv}-${slugify(title)}`;
55102    }
55103
55104    function canonicalPlusOne(storedId) {
55105      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55106      if (!m) return null;
55107      const [, slug, p, mv, title] = m;
55108      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
55109    }
55110
55111    function isCompleted(canonicalId, completions) {
55112      if (completions.includes(canonicalId)) return true;
55113      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
55114      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
55115    }
55116
55117    function migrateCompletions() {
55118      const completions = getCompletions();
55119      let changed = false;
55120      const migrated = completions.map((c) => {
55121        const canon = canonicalFromStored(c);
55122        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
55123        const plusOne = canonicalPlusOne(c);
55124        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
55125          if (plusOne !== c) changed = true;
55126          return plusOne;
55127        }
55128        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
55129          changed = true;
55130          return canon;
55131        }
55132        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
55133        if (canon !== c) {
55134          changed = true;
55135          return canon;
55136        }
55137        return c;
55138      });
55139      // Elimina duplicados tras migración
55140      const deduped = [...new Set(migrated)];
55141      if (changed || deduped.length !== completions.length) {
55142        saveCompletions(deduped);
55143        return deduped;
55144      }
55145      return completions;
55146    }
55147
55148    function updateButtonState(btn, completions) {
55149      const id = btn.dataset.completionId;
55150      const list = completions ?? getCompletions();
55151      btn.classList.toggle("completed", isCompleted(id, list));
55152    }
55153
55154    function checkModuleCompletion(completions) {
55155      const list = completions ?? getCompletions();
55156      document.querySelectorAll(".course-video").forEach((module) => {
55157        const btns = module.querySelectorAll(".complete-btn");
55158        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
55159        const check = module.querySelector(".module-check");
55160        if (check) check.classList.toggle("visible", allCompleted);
55161      });
55162    }
55163
55164    function initCompletionButtons() {
55165      const completions = migrateCompletions();
55166
55167      document.querySelectorAll(".complete-btn").forEach((btn) => {
55168        if (btn.dataset.completionInit) return;
55169        btn.dataset.completionInit = "true";
55170        updateButtonState(btn, completions);
55171
55172        btn.addEventListener("click", (e) => {
55173          e.preventDefault();
55174          e.stopPropagation();
55175
55176          const id = btn.dataset.completionId;
55177          let list = getCompletions();
55178          const completed = isCompleted(id, list);
55179
55180          if (completed) {
55181            // Elimina canónico y cualquier variante legacy que mapee al mismo id
55182            list = list.filter((c) =>
55182 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
55183          } else {
55184            list.push(id);
55185          }
55186
55187          saveCompletions(list);
55188          updateButtonState(btn, list);
55189          checkModuleCompletion(list);
55190        });
55191      });
55192
55193      checkModuleCompletion(completions);
55194    }
55195
55196    if (document.readyState === "loading") {
55197      document.addEventListener("DOMContentLoaded", initCompletionButtons);
55198    } else {
55199      initCompletionButtons();
55200    }
55201  })();
55202</script>
55202<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="8535" data-end="9224" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Instalando nginx</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-instalando-nginx" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
55202<script>
55203  (() => {
55204    if (window.__courseLazyPlayersInitialized) return;
55205    window.__courseLazyPlayersInitialized = true;
55206
55207    function createPlayer(details) {
55208      const videoId = details.dataset.videoId;
55209      const start = details.dataset.start;
55210      const end = details.dataset.end;
55211
55212      const container = document.createElement("div");
55213      container.className = "player-container";
55214
55215      const spinner = document.createElement("div");
55216      spinner.className = "spinner";
55217      container.appendChild(spinner);
55218
55219      const controller = document.createElement("media-controller");
55220      controller.setAttribute("defaultsubtitles", "");
55221
55222      const provider = document.createElement("youtube-media-provider");
55223      provider.setAttribute("slot", "media");
55224      provider.setAttribute("video-id", videoId);
55225      provider.setAttribute("start", start);
55226      provider.setAttribute("end", end);
55227
55228      const controlBar = document.createElement("media-control-bar");
55229
55230      const playBtn = document.createElement("media-play-button");
55231      const timeDisplay = document.createElement("media-time-display");
55232      timeDisplay.setAttribute("showduration", "");
55233      const timeRange = document.createElement("media-time-range");
55234      const playbackRate = document.createElement("media-playback-rate-button");
55235      const muteBtn = document.createElement("media-mute-button");
55236      const volumeRange = document.createElement("media-volume-range");
55237
55238      const captionsBtn = document.createElement("media-captions-button");
55239      captionsBtn.addEventListener("click", (e) => {
55240        e.stopPropagation();
55241        const provider = container.querySelector("youtube-media-provider");
55242        if (provider) provider.toggleCaptions();
55243      });
55244
55245      const qualityBtn = document.createElement("media-chrome-button");
55246      qualityBtn.textContent = "--";
55247      qualityBtn.addEventListener("click", () => {
55248        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
55249      });
55250
55251      const fullscreenBtn = document.createElement("media-fullscreen-button");
55252
55253      controlBar.append(
55254        playBtn, timeDisplay, timeRange,
55255        playbackRate, muteBtn, volumeRange,
55256        captionsBtn, qualityBtn, fullscreenBtn
55257      );
55258
55259      const poster = document.createElement("media-poster-image");
55260      poster.setAttribute("slot", "poster");
55261      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
55262
55263      controller.append(provider, poster, controlBar);
55264
55265      captionsBtn.setAttribute("aria-checked", "true");
55266      controller.setAttribute("mediasubtitlesshowing", "on");
55267
55268      container.appendChild(controller);
55269      details.appendChild(container);
55270
55271      provider.addEventListener("waiting", () => container.classList.add("loading"));
55272      provider.addEventListener("playing", () => container.classList.remove("loading"));
55273      provider.addEventListener("play", () => container.classList.remove("loading"));
55274
55275      provider._textTracks.addEventListener("change", () => {
55276        const enabled = provider._captionsEnabled;
55277        captionsBtn.setAttribute("aria-checked", enabled);
55278        if (enabled) {
55279          controller.setAttribute("mediasubtitlesshowing", "on");
55280        } else {
55281          controller.removeAttribute("mediasubtitlesshowing");
55282        }
55283      });
55284
55285      provider.addEventListener("qualitychange", (e) => {
55286        qualityBtn.textContent = e.detail.quality;
55287      });
55288    }
55289
55290    function initLazyPlayers() {
55291      document.querySelectorAll("details[data-video-id]").forEach((details) => {
55292        if (details.dataset.lazyInit) return;
55293        details.dataset.lazyInit = "true";
55294        details.addEventListener("toggle", () => {
55295          if (details.open && !details.querySelector(".player-container")) {
55296            createPlayer(details);
55297          } else if (!details.open) {
55298            const provider = details.querySelector("youtube-media-provider");
55299            if (provider && typeof provider.pause === "function") {
55300              provider.pause();
55301            }
55302          }
55303        });
55304      });
55305    }
55306
55307    if (document.readyState === "loading") {
55308      document.addEventListener("DOMContentLoaded", initLazyPlayers);
55309    } else {
55310      initLazyPlayers();
55311    }
55312  })();
55313</script>
55313<script>
55314  (() => {
55315    if (window.__courseCompletionsInitialized) return;
55316    window.__courseCompletionsInitialized = true;
55317
55318    const STORAGE_KEY = "course-completions";
55319
55320    function slugify(text) {
55321      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
55322    }
55323
55324    function getCompletions() {
55325      try {
55326        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
55327      } catch {
55328        return [];
55329      }
55330    }
55331
55332    function saveCompletions(completions) {
55333      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
55334    }
55335
55336    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
55337    function canonicalFromStored(storedId) {
55338      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55339      if (!m) return slugify(storedId);
55340      const [, slug, p, mv, title] = m;
55341      return `${slug}-${p}-${mv}-${slugify(title)}`;
55342    }
55343
55344    function canonicalPlusOne(storedId) {
55345      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55346      if (!m) return null;
55347      const [, slug, p, mv, title] = m;
55348      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
55349    }
55350
55351    function isCompleted(canonicalId, completions) {
55352      if (completions.includes(canonicalId)) return true;
55353      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
55354      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
55355    }
55356
55357    function migrateCompletions() {
55358      const completions = getCompletions();
55359      let changed = false;
55360      const migrated = completions.map((c) => {
55361        const canon = canonicalFromStored(c);
55362        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
55363        const plusOne = canonicalPlusOne(c);
55364        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
55365          if (plusOne !== c) changed = true;
55366          return plusOne;
55367        }
55368        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
55369          changed = true;
55370          return canon;
55371        }
55372        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
55373        if (canon !== c) {
55374          changed = true;
55375          return canon;
55376        }
55377        return c;
55378      });
55379      // Elimina duplicados tras migración
55380      const deduped = [...new Set(migrated)];
55381      if (changed || deduped.length !== completions.length) {
55382        saveCompletions(deduped);
55383        return deduped;
55384      }
55385      return completions;
55386    }
55387
55388    function updateButtonState(btn, completions) {
55389      const id = btn.dataset.completionId;
55390      const list = completions ?? getCompletions();
55391      btn.classList.toggle("completed", isCompleted(id, list));
55392    }
55393
55394    function checkModuleCompletion(completions) {
55395      const list = completions ?? getCompletions();
55396      document.querySelectorAll(".course-video").forEach((module) => {
55397        const btns = module.querySelectorAll(".complete-btn");
55398        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
55399        const check = module.querySelector(".module-check");
55400        if (check) check.classList.toggle("visible", allCompleted);
55401      });
55402    }
55403
55404    function initCompletionButtons() {
55405      const completions = migrateCompletions();
55406
55407      document.querySelectorAll(".complete-btn").forEach((btn) => {
55408        if (btn.dataset.completionInit) return;
55409        btn.dataset.completionInit = "true";
55410        updateButtonState(btn, completions);
55411
55412        btn.addEventListener("click", (e) => {
55413          e.preventDefault();
55414          e.stopPropagation();
55415
55416          const id = btn.dataset.completionId;
55417          let list = getCompletions();
55418          const completed = isCompleted(id, list);
55419
55420          if (completed) {
55421            // Elimina canónico y cualquier variante legacy que mapee al mismo id
55422            list = list.filter((c) =>
55422 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
55423          } else {
55424            list.push(id);
55425          }
55426
55427          saveCompletions(list);
55428          updateButtonState(btn, list);
55429          checkModuleCompletion(list);
55430        });
55431      });
55432
55433      checkModuleCompletion(completions);
55434    }
55435
55436    if (document.readyState === "loading") {
55437      document.addEventListener("DOMContentLoaded", initCompletionButtons);
55438    } else {
55439      initCompletionButtons();
55440    }
55441  })();
55442</script>
55442<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="9224" data-end="9860" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Usando multiplexadores</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>11 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-usando-multiplexadores" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
55442<script>
55443  (() => {
55444    if (window.__courseLazyPlayersInitialized) return;
55445    window.__courseLazyPlayersInitialized = true;
55446
55447    function createPlayer(details) {
55448      const videoId = details.dataset.videoId;
55449      const start = details.dataset.start;
55450      const end = details.dataset.end;
55451
55452      const container = document.createElement("div");
55453      container.className = "player-container";
55454
55455      const spinner = document.createElement("div");
55456      spinner.className = "spinner";
55457      container.appendChild(spinner);
55458
55459      const controller = document.createElement("media-controller");
55460      controller.setAttribute("defaultsubtitles", "");
55461
55462      const provider = document.createElement("youtube-media-provider");
55463      provider.setAttribute("slot", "media");
55464      provider.setAttribute("video-id", videoId);
55465      provider.setAttribute("start", start);
55466      provider.setAttribute("end", end);
55467
55468      const controlBar = document.createElement("media-control-bar");
55469
55470      const playBtn = document.createElement("media-play-button");
55471      const timeDisplay = document.createElement("media-time-display");
55472      timeDisplay.setAttribute("showduration", "");
55473      const timeRange = document.createElement("media-time-range");
55474      const playbackRate = document.createElement("media-playback-rate-button");
55475      const muteBtn = document.createElement("media-mute-button");
55476      const volumeRange = document.createElement("media-volume-range");
55477
55478      const captionsBtn = document.createElement("media-captions-button");
55479      captionsBtn.addEventListener("click", (e) => {
55480        e.stopPropagation();
55481        const provider = container.querySelector("youtube-media-provider");
55482        if (provider) provider.toggleCaptions();
55483      });
55484
55485      const qualityBtn = document.createElement("media-chrome-button");
55486      qualityBtn.textContent = "--";
55487      qualityBtn.addEventListener("click", () => {
55488        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
55489      });
55490
55491      const fullscreenBtn = document.createElement("media-fullscreen-button");
55492
55493      controlBar.append(
55494        playBtn, timeDisplay, timeRange,
55495        playbackRate, muteBtn, volumeRange,
55496        captionsBtn, qualityBtn, fullscreenBtn
55497      );
55498
55499      const poster = document.createElement("media-poster-image");
55500      poster.setAttribute("slot", "poster");
55501      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
55502
55503      controller.append(provider, poster, controlBar);
55504
55505      captionsBtn.setAttribute("aria-checked", "true");
55506      controller.setAttribute("mediasubtitlesshowing", "on");
55507
55508      container.appendChild(controller);
55509      details.appendChild(container);
55510
55511      provider.addEventListener("waiting", () => container.classList.add("loading"));
55512      provider.addEventListener("playing", () => container.classList.remove("loading"));
55513      provider.addEventListener("play", () => container.classList.remove("loading"));
55514
55515      provider._textTracks.addEventListener("change", () => {
55516        const enabled = provider._captionsEnabled;
55517        captionsBtn.setAttribute("aria-checked", enabled);
55518        if (enabled) {
55519          controller.setAttribute("mediasubtitlesshowing", "on");
55520        } else {
55521          controller.removeAttribute("mediasubtitlesshowing");
55522        }
55523      });
55524
55525      provider.addEventListener("qualitychange", (e) => {
55526        qualityBtn.textContent = e.detail.quality;
55527      });
55528    }
55529
55530    function initLazyPlayers() {
55531      document.querySelectorAll("details[data-video-id]").forEach((details) => {
55532        if (details.dataset.lazyInit) return;
55533        details.dataset.lazyInit = "true";
55534        details.addEventListener("toggle", () => {
55535          if (details.open && !details.querySelector(".player-container")) {
55536            createPlayer(details);
55537          } else if (!details.open) {
55538            const provider = details.querySelector("youtube-media-provider");
55539            if (provider && typeof provider.pause === "function") {
55540              provider.pause();
55541            }
55542          }
55543        });
55544      });
55545    }
55546
55547    if (document.readyState === "loading") {
55548      document.addEventListener("DOMContentLoaded", initLazyPlayers);
55549    } else {
55550      initLazyPlayers();
55551    }
55552  })();
55553</script>
55553<script>
55554  (() => {
55555    if (window.__courseCompletionsInitialized) return;
55556    window.__courseCompletionsInitialized = true;
55557
55558    const STORAGE_KEY = "course-completions";
55559
55560    function slugify(text) {
55561      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
55562    }
55563
55564    function getCompletions() {
55565      try {
55566        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
55567      } catch {
55568        return [];
55569      }
55570    }
55571
55572    function saveCompletions(completions) {
55573      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
55574    }
55575
55576    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
55577    function canonicalFromStored(storedId) {
55578      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55579      if (!m) return slugify(storedId);
55580      const [, slug, p, mv, title] = m;
55581      return `${slug}-${p}-${mv}-${slugify(title)}`;
55582    }
55583
55584    function canonicalPlusOne(storedId) {
55585      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55586      if (!m) return null;
55587      const [, slug, p, mv, title] = m;
55588      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
55589    }
55590
55591    function isCompleted(canonicalId, completions) {
55592      if (completions.includes(canonicalId)) return true;
55593      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
55594      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
55595    }
55596
55597    function migrateCompletions() {
55598      const completions = getCompletions();
55599      let changed = false;
55600      const migrated = completions.map((c) => {
55601        const canon = canonicalFromStored(c);
55602        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
55603        const plusOne = canonicalPlusOne(c);
55604        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
55605          if (plusOne !== c) changed = true;
55606          return plusOne;
55607        }
55608        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
55609          changed = true;
55610          return canon;
55611        }
55612        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
55613        if (canon !== c) {
55614          changed = true;
55615          return canon;
55616        }
55617        return c;
55618      });
55619      // Elimina duplicados tras migración
55620      const deduped = [...new Set(migrated)];
55621      if (changed || deduped.length !== completions.length) {
55622        saveCompletions(deduped);
55623        return deduped;
55624      }
55625      return completions;
55626    }
55627
55628    function updateButtonState(btn, completions) {
55629      const id = btn.dataset.completionId;
55630      const list = completions ?? getCompletions();
55631      btn.classList.toggle("completed", isCompleted(id, list));
55632    }
55633
55634    function checkModuleCompletion(completions) {
55635      const list = completions ?? getCompletions();
55636      document.querySelectorAll(".course-video").forEach((module) => {
55637        const btns = module.querySelectorAll(".complete-btn");
55638        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
55639        const check = module.querySelector(".module-check");
55640        if (check) check.classList.toggle("visible", allCompleted);
55641      });
55642    }
55643
55644    function initCompletionButtons() {
55645      const completions = migrateCompletions();
55646
55647      document.querySelectorAll(".complete-btn").forEach((btn) => {
55648        if (btn.dataset.completionInit) return;
55649        btn.dataset.completionInit = "true";
55650        updateButtonState(btn, completions);
55651
55652        btn.addEventListener("click", (e) => {
55653          e.preventDefault();
55654          e.stopPropagation();
55655
55656          const id = btn.dataset.completionId;
55657          let list = getCompletions();
55658          const completed = isCompleted(id, list);
55659
55660          if (completed) {
55661            // Elimina canónico y cualquier variante legacy que mapee al mismo id
55662            list = list.filter((c) =>
55662 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
55663          } else {
55664            list.push(id);
55665          }
55666
55667          saveCompletions(list);
55668          updateButtonState(btn, list);
55669          checkModuleCompletion(list);
55670        });
55671      });
55672
55673      checkModuleCompletion(completions);
55674    }
55675
55676    if (document.readyState === "loading") {
55677      document.addEventListener("DOMContentLoaded", initCompletionButtons);
55678    } else {
55679      initCompletionButtons();
55680    }
55681  })();
55682</script>
55682<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="9860" data-end="12650" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. Configuración de nginx</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>47 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-configuracion-de-nginx" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
55682<script>
55683  (() => {
55684    if (window.__courseLazyPlayersInitialized) return;
55685    window.__courseLazyPlayersInitialized = true;
55686
55687    function createPlayer(details) {
55688      const videoId = details.dataset.videoId;
55689      const start = details.dataset.start;
55690      const end = details.dataset.end;
55691
55692      const container = document.createElement("div");
55693      container.className = "player-container";
55694
55695      const spinner = document.createElement("div");
55696      spinner.className = "spinner";
55697      container.appendChild(spinner);
55698
55699      const controller = document.createElement("media-controller");
55700      controller.setAttribute("defaultsubtitles", "");
55701
55702      const provider = document.createElement("youtube-media-provider");
55703      provider.setAttribute("slot", "media");
55704      provider.setAttribute("video-id", videoId);
55705      provider.setAttribute("start", start);
55706      provider.setAttribute("end", end);
55707
55708      const controlBar = document.createElement("media-control-bar");
55709
55710      const playBtn = document.createElement("media-play-button");
55711      const timeDisplay = document.createElement("media-time-display");
55712      timeDisplay.setAttribute("showduration", "");
55713      const timeRange = document.createElement("media-time-range");
55714      const playbackRate = document.createElement("media-playback-rate-button");
55715      const muteBtn = document.createElement("media-mute-button");
55716      const volumeRange = document.createElement("media-volume-range");
55717
55718      const captionsBtn = document.createElement("media-captions-button");
55719      captionsBtn.addEventListener("click", (e) => {
55720        e.stopPropagation();
55721        const provider = container.querySelector("youtube-media-provider");
55722        if (provider) provider.toggleCaptions();
55723      });
55724
55725      const qualityBtn = document.createElement("media-chrome-button");
55726      qualityBtn.textContent = "--";
55727      qualityBtn.addEventListener("click", () => {
55728        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
55729      });
55730
55731      const fullscreenBtn = document.createElement("media-fullscreen-button");
55732
55733      controlBar.append(
55734        playBtn, timeDisplay, timeRange,
55735        playbackRate, muteBtn, volumeRange,
55736        captionsBtn, qualityBtn, fullscreenBtn
55737      );
55738
55739      const poster = document.createElement("media-poster-image");
55740      poster.setAttribute("slot", "poster");
55741      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
55742
55743      controller.append(provider, poster, controlBar);
55744
55745      captionsBtn.setAttribute("aria-checked", "true");
55746      controller.setAttribute("mediasubtitlesshowing", "on");
55747
55748      container.appendChild(controller);
55749      details.appendChild(container);
55750
55751      provider.addEventListener("waiting", () => container.classList.add("loading"));
55752      provider.addEventListener("playing", () => container.classList.remove("loading"));
55753      provider.addEventListener("play", () => container.classList.remove("loading"));
55754
55755      provider._textTracks.addEventListener("change", () => {
55756        const enabled = provider._captionsEnabled;
55757        captionsBtn.setAttribute("aria-checked", enabled);
55758        if (enabled) {
55759          controller.setAttribute("mediasubtitlesshowing", "on");
55760        } else {
55761          controller.removeAttribute("mediasubtitlesshowing");
55762        }
55763      });
55764
55765      provider.addEventListener("qualitychange", (e) => {
55766        qualityBtn.textContent = e.detail.quality;
55767      });
55768    }
55769
55770    function initLazyPlayers() {
55771      document.querySelectorAll("details[data-video-id]").forEach((details) => {
55772        if (details.dataset.lazyInit) return;
55773        details.dataset.lazyInit = "true";
55774        details.addEventListener("toggle", () => {
55775          if (details.open && !details.querySelector(".player-container")) {
55776            createPlayer(details);
55777          } else if (!details.open) {
55778            const provider = details.querySelector("youtube-media-provider");
55779            if (provider && typeof provider.pause === "function") {
55780              provider.pause();
55781            }
55782          }
55783        });
55784      });
55785    }
55786
55787    if (document.readyState === "loading") {
55788      document.addEventListener("DOMContentLoaded", initLazyPlayers);
55789    } else {
55790      initLazyPlayers();
55791    }
55792  })();
55793</script>
55793<script>
55794  (() => {
55795    if (window.__courseCompletionsInitialized) return;
55796    window.__courseCompletionsInitialized = true;
55797
55798    const STORAGE_KEY = "course-completions";
55799
55800    function slugify(text) {
55801      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
55802    }
55803
55804    function getCompletions() {
55805      try {
55806        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
55807      } catch {
55808        return [];
55809      }
55810    }
55811
55812    function saveCompletions(completions) {
55813      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
55814    }
55815
55816    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
55817    function canonicalFromStored(storedId) {
55818      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55819      if (!m) return slugify(storedId);
55820      const [, slug, p, mv, title] = m;
55821      return `${slug}-${p}-${mv}-${slugify(title)}`;
55822    }
55823
55824    function canonicalPlusOne(storedId) {
55825      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
55826      if (!m) return null;
55827      const [, slug, p, mv, title] = m;
55828      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
55829    }
55830
55831    function isCompleted(canonicalId, completions) {
55832      if (completions.includes(canonicalId)) return true;
55833      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
55834      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
55835    }
55836
55837    function migrateCompletions() {
55838      const completions = getCompletions();
55839      let changed = false;
55840      const migrated = completions.map((c) => {
55841        const canon = canonicalFromStored(c);
55842        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
55843        const plusOne = canonicalPlusOne(c);
55844        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
55845          if (plusOne !== c) changed = true;
55846          return plusOne;
55847        }
55848        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
55849          changed = true;
55850          return canon;
55851        }
55852        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
55853        if (canon !== c) {
55854          changed = true;
55855          return canon;
55856        }
55857        return c;
55858      });
55859      // Elimina duplicados tras migración
55860      const deduped = [...new Set(migrated)];
55861      if (changed || deduped.length !== completions.length) {
55862        saveCompletions(deduped);
55863        return deduped;
55864      }
55865      return completions;
55866    }
55867
55868    function updateButtonState(btn, completions) {
55869      const id = btn.dataset.completionId;
55870      const list = completions ?? getCompletions();
55871      btn.classList.toggle("completed", isCompleted(id, list));
55872    }
55873
55874    function checkModuleCompletion(completions) {
55875      const list = completions ?? getCompletions();
55876      document.querySelectorAll(".course-video").forEach((module) => {
55877        const btns = module.querySelectorAll(".complete-btn");
55878        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
55879        const check = module.querySelector(".module-check");
55880        if (check) check.classList.toggle("visible", allCompleted);
55881      });
55882    }
55883
55884    function initCompletionButtons() {
55885      const completions = migrateCompletions();
55886
55887      document.querySelectorAll(".complete-btn").forEach((btn) => {
55888        if (btn.dataset.completionInit) return;
55889        btn.dataset.completionInit = "true";
55890        updateButtonState(btn, completions);
55891
55892        btn.addEventListener("click", (e) => {
55893          e.preventDefault();
55894          e.stopPropagation();
55895
55896          const id = btn.dataset.completionId;
55897          let list = getCompletions();
55898          const completed = isCompleted(id, list);
55899
55900          if (completed) {
55901            // Elimina canónico y cualquier variante legacy que mapee al mismo id
55902            list = list.filter((c) =>
55902 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
55903          } else {
55904            list.push(id);
55905          }
55906
55907          saveCompletions(list);
55908          updateButtonState(btn, list);
55909          checkModuleCompletion(list);
55910        });
55911      });
55912
55913      checkModuleCompletion(completions);
55914    }
55915
55916    if (document.readyState === "loading") {
55917      document.addEventListener("DOMContentLoaded", initCompletionButtons);
55918    } else {
55919      initCompletionButtons();
55920    }
55921  })();
55922</script>
55922<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="12650" data-end="13053" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Registros access.log</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>7 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-registros-access-log" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
55922<script>
55923  (() => {
55924    if (window.__courseLazyPlayersInitialized) return;
55925    window.__courseLazyPlayersInitialized = true;
55926
55927    function createPlayer(details) {
55928      const videoId = details.dataset.videoId;
55929      const start = details.dataset.start;
55930      const end = details.dataset.end;
55931
55932      const container = document.createElement("div");
55933      container.className = "player-container";
55934
55935      const spinner = document.createElement("div");
55936      spinner.className = "spinner";
55937      container.appendChild(spinner);
55938
55939      const controller = document.createElement("media-controller");
55940      controller.setAttribute("defaultsubtitles", "");
55941
55942      const provider = document.createElement("youtube-media-provider");
55943      provider.setAttribute("slot", "media");
55944      provider.setAttribute("video-id", videoId);
55945      provider.setAttribute("start", start);
55946      provider.setAttribute("end", end);
55947
55948      const controlBar = document.createElement("media-control-bar");
55949
55950      const playBtn = document.createElement("media-play-button");
55951      const timeDisplay = document.createElement("media-time-display");
55952      timeDisplay.setAttribute("showduration", "");
55953      const timeRange = document.createElement("media-time-range");
55954      const playbackRate = document.createElement("media-playback-rate-button");
55955      const muteBtn = document.createElement("media-mute-button");
55956      const volumeRange = document.createElement("media-volume-range");
55957
55958      const captionsBtn = document.createElement("media-captions-button");
55959      captionsBtn.addEventListener("click", (e) => {
55960        e.stopPropagation();
55961        const provider = container.querySelector("youtube-media-provider");
55962        if (provider) provider.toggleCaptions();
55963      });
55964
55965      const qualityBtn = document.createElement("media-chrome-button");
55966      qualityBtn.textContent = "--";
55967      qualityBtn.addEventListener("click", () => {
55968        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
55969      });
55970
55971      const fullscreenBtn = document.createElement("media-fullscreen-button");
55972
55973      controlBar.append(
55974        playBtn, timeDisplay, timeRange,
55975        playbackRate, muteBtn, volumeRange,
55976        captionsBtn, qualityBtn, fullscreenBtn
55977      );
55978
55979      const poster = document.createElement("media-poster-image");
55980      poster.setAttribute("slot", "poster");
55981      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
55982
55983      controller.append(provider, poster, controlBar);
55984
55985      captionsBtn.setAttribute("aria-checked", "true");
55986      controller.setAttribute("mediasubtitlesshowing", "on");
55987
55988      container.appendChild(controller);
55989      details.appendChild(container);
55990
55991      provider.addEventListener("waiting", () => container.classList.add("loading"));
55992      provider.addEventListener("playing", () => container.classList.remove("loading"));
55993      provider.addEventListener("play", () => container.classList.remove("loading"));
55994
55995      provider._textTracks.addEventListener("change", () => {
55996        const enabled = provider._captionsEnabled;
55997        captionsBtn.setAttribute("aria-checked", enabled);
55998        if (enabled) {
55999          controller.setAttribute("mediasubtitlesshowing", "on");
56000        } else {
56001          controller.removeAttribute("mediasubtitlesshowing");
56002        }
56003      });
56004
56005      provider.addEventListener("qualitychange", (e) => {
56006        qualityBtn.textContent = e.detail.quality;
56007      });
56008    }
56009
56010    function initLazyPlayers() {
56011      document.querySelectorAll("details[data-video-id]").forEach((details) => {
56012        if (details.dataset.lazyInit) return;
56013        details.dataset.lazyInit = "true";
56014        details.addEventListener("toggle", () => {
56015          if (details.open && !details.querySelector(".player-container")) {
56016            createPlayer(details);
56017          } else if (!details.open) {
56018            const provider = details.querySelector("youtube-media-provider");
56019            if (provider && typeof provider.pause === "function") {
56020              provider.pause();
56021            }
56022          }
56023        });
56024      });
56025    }
56026
56027    if (document.readyState === "loading") {
56028      document.addEventListener("DOMContentLoaded", initLazyPlayers);
56029    } else {
56030      initLazyPlayers();
56031    }
56032  })();
56033</script>
56033<script>
56034  (() => {
56035    if (window.__courseCompletionsInitialized) return;
56036    window.__courseCompletionsInitialized = true;
56037
56038    const STORAGE_KEY = "course-completions";
56039
56040    function slugify(text) {
56041      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
56042    }
56043
56044    function getCompletions() {
56045      try {
56046        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
56047      } catch {
56048        return [];
56049      }
56050    }
56051
56052    function saveCompletions(completions) {
56053      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
56054    }
56055
56056    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
56057    function canonicalFromStored(storedId) {
56058      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56059      if (!m) return slugify(storedId);
56060      const [, slug, p, mv, title] = m;
56061      return `${slug}-${p}-${mv}-${slugify(title)}`;
56062    }
56063
56064    function canonicalPlusOne(storedId) {
56065      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56066      if (!m) return null;
56067      const [, slug, p, mv, title] = m;
56068      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
56069    }
56070
56071    function isCompleted(canonicalId, completions) {
56072      if (completions.includes(canonicalId)) return true;
56073      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
56074      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
56075    }
56076
56077    function migrateCompletions() {
56078      const completions = getCompletions();
56079      let changed = false;
56080      const migrated = completions.map((c) => {
56081        const canon = canonicalFromStored(c);
56082        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
56083        const plusOne = canonicalPlusOne(c);
56084        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
56085          if (plusOne !== c) changed = true;
56086          return plusOne;
56087        }
56088        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
56089          changed = true;
56090          return canon;
56091        }
56092        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
56093        if (canon !== c) {
56094          changed = true;
56095          return canon;
56096        }
56097        return c;
56098      });
56099      // Elimina duplicados tras migración
56100      const deduped = [...new Set(migrated)];
56101      if (changed || deduped.length !== completions.length) {
56102        saveCompletions(deduped);
56103        return deduped;
56104      }
56105      return completions;
56106    }
56107
56108    function updateButtonState(btn, completions) {
56109      const id = btn.dataset.completionId;
56110      const list = completions ?? getCompletions();
56111      btn.classList.toggle("completed", isCompleted(id, list));
56112    }
56113
56114    function checkModuleCompletion(completions) {
56115      const list = completions ?? getCompletions();
56116      document.querySelectorAll(".course-video").forEach((module) => {
56117        const btns = module.querySelectorAll(".complete-btn");
56118        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
56119        const check = module.querySelector(".module-check");
56120        if (check) check.classList.toggle("visible", allCompleted);
56121      });
56122    }
56123
56124    function initCompletionButtons() {
56125      const completions = migrateCompletions();
56126
56127      document.querySelectorAll(".complete-btn").forEach((btn) => {
56128        if (btn.dataset.completionInit) return;
56129        btn.dataset.completionInit = "true";
56130        updateButtonState(btn, completions);
56131
56132        btn.addEventListener("click", (e) => {
56133          e.preventDefault();
56134          e.stopPropagation();
56135
56136          const id = btn.dataset.completionId;
56137          let list = getCompletions();
56138          const completed = isCompleted(id, list);
56139
56140          if (completed) {
56141            // Elimina canónico y cualquier variante legacy que mapee al mismo id
56142            list = list.filter((c) =>
56142 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
56143          } else {
56144            list.push(id);
56145          }
56146
56147          saveCompletions(list);
56148          updateButtonState(btn, list);
56149          checkModuleCompletion(list);
56150        });
56151      });
56152
56153      checkModuleCompletion(completions);
56154    }
56155
56156    if (document.readyState === "loading") {
56157      document.addEventListener("DOMContentLoaded", initCompletionButtons);
56158    } else {
56159      initCompletionButtons();
56160    }
56161  })();
56162</script>
56162<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="13319" data-end="14280" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Monitorización de logs nginx</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-monitorizacion-de-logs-nginx" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
56162<script>
56163  (() => {
56164    if (window.__courseLazyPlayersInitialized) return;
56165    window.__courseLazyPlayersInitialized = true;
56166
56167    function createPlayer(details) {
56168      const videoId = details.dataset.videoId;
56169      const start = details.dataset.start;
56170      const end = details.dataset.end;
56171
56172      const container = document.createElement("div");
56173      container.className = "player-container";
56174
56175      const spinner = document.createElement("div");
56176      spinner.className = "spinner";
56177      container.appendChild(spinner);
56178
56179      const controller = document.createElement("media-controller");
56180      controller.setAttribute("defaultsubtitles", "");
56181
56182      const provider = document.createElement("youtube-media-provider");
56183      provider.setAttribute("slot", "media");
56184      provider.setAttribute("video-id", videoId);
56185      provider.setAttribute("start", start);
56186      provider.setAttribute("end", end);
56187
56188      const controlBar = document.createElement("media-control-bar");
56189
56190      const playBtn = document.createElement("media-play-button");
56191      const timeDisplay = document.createElement("media-time-display");
56192      timeDisplay.setAttribute("showduration", "");
56193      const timeRange = document.createElement("media-time-range");
56194      const playbackRate = document.createElement("media-playback-rate-button");
56195      const muteBtn = document.createElement("media-mute-button");
56196      const volumeRange = document.createElement("media-volume-range");
56197
56198      const captionsBtn = document.createElement("media-captions-button");
56199      captionsBtn.addEventListener("click", (e) => {
56200        e.stopPropagation();
56201        const provider = container.querySelector("youtube-media-provider");
56202        if (provider) provider.toggleCaptions();
56203      });
56204
56205      const qualityBtn = document.createElement("media-chrome-button");
56206      qualityBtn.textContent = "--";
56207      qualityBtn.addEventListener("click", () => {
56208        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
56209      });
56210
56211      const fullscreenBtn = document.createElement("media-fullscreen-button");
56212
56213      controlBar.append(
56214        playBtn, timeDisplay, timeRange,
56215        playbackRate, muteBtn, volumeRange,
56216        captionsBtn, qualityBtn, fullscreenBtn
56217      );
56218
56219      const poster = document.createElement("media-poster-image");
56220      poster.setAttribute("slot", "poster");
56221      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
56222
56223      controller.append(provider, poster, controlBar);
56224
56225      captionsBtn.setAttribute("aria-checked", "true");
56226      controller.setAttribute("mediasubtitlesshowing", "on");
56227
56228      container.appendChild(controller);
56229      details.appendChild(container);
56230
56231      provider.addEventListener("waiting", () => container.classList.add("loading"));
56232      provider.addEventListener("playing", () => container.classList.remove("loading"));
56233      provider.addEventListener("play", () => container.classList.remove("loading"));
56234
56235      provider._textTracks.addEventListener("change", () => {
56236        const enabled = provider._captionsEnabled;
56237        captionsBtn.setAttribute("aria-checked", enabled);
56238        if (enabled) {
56239          controller.setAttribute("mediasubtitlesshowing", "on");
56240        } else {
56241          controller.removeAttribute("mediasubtitlesshowing");
56242        }
56243      });
56244
56245      provider.addEventListener("qualitychange", (e) => {
56246        qualityBtn.textContent = e.detail.quality;
56247      });
56248    }
56249
56250    function initLazyPlayers() {
56251      document.querySelectorAll("details[data-video-id]").forEach((details) => {
56252        if (details.dataset.lazyInit) return;
56253        details.dataset.lazyInit = "true";
56254        details.addEventListener("toggle", () => {
56255          if (details.open && !details.querySelector(".player-container")) {
56256            createPlayer(details);
56257          } else if (!details.open) {
56258            const provider = details.querySelector("youtube-media-provider");
56259            if (provider && typeof provider.pause === "function") {
56260              provider.pause();
56261            }
56262          }
56263        });
56264      });
56265    }
56266
56267    if (document.readyState === "loading") {
56268      document.addEventListener("DOMContentLoaded", initLazyPlayers);
56269    } else {
56270      initLazyPlayers();
56271    }
56272  })();
56273</script>
56273<script>
56274  (() => {
56275    if (window.__courseCompletionsInitialized) return;
56276    window.__courseCompletionsInitialized = true;
56277
56278    const STORAGE_KEY = "course-completions";
56279
56280    function slugify(text) {
56281      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
56282    }
56283
56284    function getCompletions() {
56285      try {
56286        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
56287      } catch {
56288        return [];
56289      }
56290    }
56291
56292    function saveCompletions(completions) {
56293      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
56294    }
56295
56296    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
56297    function canonicalFromStored(storedId) {
56298      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56299      if (!m) return slugify(storedId);
56300      const [, slug, p, mv, title] = m;
56301      return `${slug}-${p}-${mv}-${slugify(title)}`;
56302    }
56303
56304    function canonicalPlusOne(storedId) {
56305      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56306      if (!m) return null;
56307      const [, slug, p, mv, title] = m;
56308      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
56309    }
56310
56311    function isCompleted(canonicalId, completions) {
56312      if (completions.includes(canonicalId)) return true;
56313      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
56314      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
56315    }
56316
56317    function migrateCompletions() {
56318      const completions = getCompletions();
56319      let changed = false;
56320      const migrated = completions.map((c) => {
56321        const canon = canonicalFromStored(c);
56322        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
56323        const plusOne = canonicalPlusOne(c);
56324        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
56325          if (plusOne !== c) changed = true;
56326          return plusOne;
56327        }
56328        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
56329          changed = true;
56330          return canon;
56331        }
56332        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
56333        if (canon !== c) {
56334          changed = true;
56335          return canon;
56336        }
56337        return c;
56338      });
56339      // Elimina duplicados tras migración
56340      const deduped = [...new Set(migrated)];
56341      if (changed || deduped.length !== completions.length) {
56342        saveCompletions(deduped);
56343        return deduped;
56344      }
56345      return completions;
56346    }
56347
56348    function updateButtonState(btn, completions) {
56349      const id = btn.dataset.completionId;
56350      const list = completions ?? getCompletions();
56351      btn.classList.toggle("completed", isCompleted(id, list));
56352    }
56353
56354    function checkModuleCompletion(completions) {
56355      const list = completions ?? getCompletions();
56356      document.querySelectorAll(".course-video").forEach((module) => {
56357        const btns = module.querySelectorAll(".complete-btn");
56358        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
56359        const check = module.querySelector(".module-check");
56360        if (check) check.classList.toggle("visible", allCompleted);
56361      });
56362    }
56363
56364    function initCompletionButtons() {
56365      const completions = migrateCompletions();
56366
56367      document.querySelectorAll(".complete-btn").forEach((btn) => {
56368        if (btn.dataset.completionInit) return;
56369        btn.dataset.completionInit = "true";
56370        updateButtonState(btn, completions);
56371
56372        btn.addEventListener("click", (e) => {
56373          e.preventDefault();
56374          e.stopPropagation();
56375
56376          const id = btn.dataset.completionId;
56377          let list = getCompletions();
56378          const completed = isCompleted(id, list);
56379
56380          if (completed) {
56381            // Elimina canónico y cualquier variante legacy que mapee al mismo id
56382            list = list.filter((c) =>
56382 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
56383          } else {
56384            list.push(id);
56385          }
56386
56387          saveCompletions(list);
56388          updateButtonState(btn, list);
56389          checkModuleCompletion(list);
56390        });
56391      });
56392
56393      checkModuleCompletion(completions);
56394    }
56395
56396    if (document.readyState === "loading") {
56397      document.addEventListener("DOMContentLoaded", initCompletionButtons);
56398    } else {
56399      initCompletionButtons();
56400    }
56401  })();
56402</script>
56402<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="14280" data-end="15402" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>10. HTTPS / SSL</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-https-ssl" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
56402<script>
56403  (() => {
56404    if (window.__courseLazyPlayersInitialized) return;
56405    window.__courseLazyPlayersInitialized = true;
56406
56407    function createPlayer(details) {
56408      const videoId = details.dataset.videoId;
56409      const start = details.dataset.start;
56410      const end = details.dataset.end;
56411
56412      const container = document.createElement("div");
56413      container.className = "player-container";
56414
56415      const spinner = document.createElement("div");
56416      spinner.className = "spinner";
56417      container.appendChild(spinner);
56418
56419      const controller = document.createElement("media-controller");
56420      controller.setAttribute("defaultsubtitles", "");
56421
56422      const provider = document.createElement("youtube-media-provider");
56423      provider.setAttribute("slot", "media");
56424      provider.setAttribute("video-id", videoId);
56425      provider.setAttribute("start", start);
56426      provider.setAttribute("end", end);
56427
56428      const controlBar = document.createElement("media-control-bar");
56429
56430      const playBtn = document.createElement("media-play-button");
56431      const timeDisplay = document.createElement("media-time-display");
56432      timeDisplay.setAttribute("showduration", "");
56433      const timeRange = document.createElement("media-time-range");
56434      const playbackRate = document.createElement("media-playback-rate-button");
56435      const muteBtn = document.createElement("media-mute-button");
56436      const volumeRange = document.createElement("media-volume-range");
56437
56438      const captionsBtn = document.createElement("media-captions-button");
56439      captionsBtn.addEventListener("click", (e) => {
56440        e.stopPropagation();
56441        const provider = container.querySelector("youtube-media-provider");
56442        if (provider) provider.toggleCaptions();
56443      });
56444
56445      const qualityBtn = document.createElement("media-chrome-button");
56446      qualityBtn.textContent = "--";
56447      qualityBtn.addEventListener("click", () => {
56448        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
56449      });
56450
56451      const fullscreenBtn = document.createElement("media-fullscreen-button");
56452
56453      controlBar.append(
56454        playBtn, timeDisplay, timeRange,
56455        playbackRate, muteBtn, volumeRange,
56456        captionsBtn, qualityBtn, fullscreenBtn
56457      );
56458
56459      const poster = document.createElement("media-poster-image");
56460      poster.setAttribute("slot", "poster");
56461      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
56462
56463      controller.append(provider, poster, controlBar);
56464
56465      captionsBtn.setAttribute("aria-checked", "true");
56466      controller.setAttribute("mediasubtitlesshowing", "on");
56467
56468      container.appendChild(controller);
56469      details.appendChild(container);
56470
56471      provider.addEventListener("waiting", () => container.classList.add("loading"));
56472      provider.addEventListener("playing", () => container.classList.remove("loading"));
56473      provider.addEventListener("play", () => container.classList.remove("loading"));
56474
56475      provider._textTracks.addEventListener("change", () => {
56476        const enabled = provider._captionsEnabled;
56477        captionsBtn.setAttribute("aria-checked", enabled);
56478        if (enabled) {
56479          controller.setAttribute("mediasubtitlesshowing", "on");
56480        } else {
56481          controller.removeAttribute("mediasubtitlesshowing");
56482        }
56483      });
56484
56485      provider.addEventListener("qualitychange", (e) => {
56486        qualityBtn.textContent = e.detail.quality;
56487      });
56488    }
56489
56490    function initLazyPlayers() {
56491      document.querySelectorAll("details[data-video-id]").forEach((details) => {
56492        if (details.dataset.lazyInit) return;
56493        details.dataset.lazyInit = "true";
56494        details.addEventListener("toggle", () => {
56495          if (details.open && !details.querySelector(".player-container")) {
56496            createPlayer(details);
56497          } else if (!details.open) {
56498            const provider = details.querySelector("youtube-media-provider");
56499            if (provider && typeof provider.pause === "function") {
56500              provider.pause();
56501            }
56502          }
56503        });
56504      });
56505    }
56506
56507    if (document.readyState === "loading") {
56508      document.addEventListener("DOMContentLoaded", initLazyPlayers);
56509    } else {
56510      initLazyPlayers();
56511    }
56512  })();
56513</script>
56513<script>
56514  (() => {
56515    if (window.__courseCompletionsInitialized) return;
56516    window.__courseCompletionsInitialized = true;
56517
56518    const STORAGE_KEY = "course-completions";
56519
56520    function slugify(text) {
56521      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
56522    }
56523
56524    function getCompletions() {
56525      try {
56526        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
56527      } catch {
56528        return [];
56529      }
56530    }
56531
56532    function saveCompletions(completions) {
56533      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
56534    }
56535
56536    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
56537    function canonicalFromStored(storedId) {
56538      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56539      if (!m) return slugify(storedId);
56540      const [, slug, p, mv, title] = m;
56541      return `${slug}-${p}-${mv}-${slugify(title)}`;
56542    }
56543
56544    function canonicalPlusOne(storedId) {
56545      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56546      if (!m) return null;
56547      const [, slug, p, mv, title] = m;
56548      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
56549    }
56550
56551    function isCompleted(canonicalId, completions) {
56552      if (completions.includes(canonicalId)) return true;
56553      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
56554      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
56555    }
56556
56557    function migrateCompletions() {
56558      const completions = getCompletions();
56559      let changed = false;
56560      const migrated = completions.map((c) => {
56561        const canon = canonicalFromStored(c);
56562        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
56563        const plusOne = canonicalPlusOne(c);
56564        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
56565          if (plusOne !== c) changed = true;
56566          return plusOne;
56567        }
56568        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
56569          changed = true;
56570          return canon;
56571        }
56572        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
56573        if (canon !== c) {
56574          changed = true;
56575          return canon;
56576        }
56577        return c;
56578      });
56579      // Elimina duplicados tras migración
56580      const deduped = [...new Set(migrated)];
56581      if (changed || deduped.length !== completions.length) {
56582        saveCompletions(deduped);
56583        return deduped;
56584      }
56585      return completions;
56586    }
56587
56588    function updateButtonState(btn, completions) {
56589      const id = btn.dataset.completionId;
56590      const list = completions ?? getCompletions();
56591      btn.classList.toggle("completed", isCompleted(id, list));
56592    }
56593
56594    function checkModuleCompletion(completions) {
56595      const list = completions ?? getCompletions();
56596      document.querySelectorAll(".course-video").forEach((module) => {
56597        const btns = module.querySelectorAll(".complete-btn");
56598        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
56599        const check = module.querySelector(".module-check");
56600        if (check) check.classList.toggle("visible", allCompleted);
56601      });
56602    }
56603
56604    function initCompletionButtons() {
56605      const completions = migrateCompletions();
56606
56607      document.querySelectorAll(".complete-btn").forEach((btn) => {
56608        if (btn.dataset.completionInit) return;
56609        btn.dataset.completionInit = "true";
56610        updateButtonState(btn, completions);
56611
56612        btn.addEventListener("click", (e) => {
56613          e.preventDefault();
56614          e.stopPropagation();
56615
56616          const id = btn.dataset.completionId;
56617          let list = getCompletions();
56618          const completed = isCompleted(id, list);
56619
56620          if (completed) {
56621            // Elimina canónico y cualquier variante legacy que mapee al mismo id
56622            list = list.filter((c) =>
56622 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
56623          } else {
56624            list.push(id);
56625          }
56626
56627          saveCompletions(list);
56628          updateButtonState(btn, list);
56629          checkModuleCompletion(list);
56630        });
56631      });
56632
56633      checkModuleCompletion(completions);
56634    }
56635
56636    if (document.readyState === "loading") {
56637      document.addEventListener("DOMContentLoaded", initCompletionButtons);
56638    } else {
56639      initCompletionButtons();
56640    }
56641  })();
56642</script>
56642<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="zsidy6m7YHM" data-start="15402" data-end="16432" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>11. Endpoints en nginx</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>17 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-4-endpoints-en-nginx" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
56642<script>
56643  (() => {
56644    if (window.__courseLazyPlayersInitialized) return;
56645    window.__courseLazyPlayersInitialized = true;
56646
56647    function createPlayer(details) {
56648      const videoId = details.dataset.videoId;
56649      const start = details.dataset.start;
56650      const end = details.dataset.end;
56651
56652      const container = document.createElement("div");
56653      container.className = "player-container";
56654
56655      const spinner = document.createElement("div");
56656      spinner.className = "spinner";
56657      container.appendChild(spinner);
56658
56659      const controller = document.createElement("media-controller");
56660      controller.setAttribute("defaultsubtitles", "");
56661
56662      const provider = document.createElement("youtube-media-provider");
56663      provider.setAttribute("slot", "media");
56664      provider.setAttribute("video-id", videoId);
56665      provider.setAttribute("start", start);
56666      provider.setAttribute("end", end);
56667
56668      const controlBar = document.createElement("media-control-bar");
56669
56670      const playBtn = document.createElement("media-play-button");
56671      const timeDisplay = document.createElement("media-time-display");
56672      timeDisplay.setAttribute("showduration", "");
56673      const timeRange = document.createElement("media-time-range");
56674      const playbackRate = document.createElement("media-playback-rate-button");
56675      const muteBtn = document.createElement("media-mute-button");
56676      const volumeRange = document.createElement("media-volume-range");
56677
56678      const captionsBtn = document.createElement("media-captions-button");
56679      captionsBtn.addEventListener("click", (e) => {
56680        e.stopPropagation();
56681        const provider = container.querySelector("youtube-media-provider");
56682        if (provider) provider.toggleCaptions();
56683      });
56684
56685      const qualityBtn = document.createElement("media-chrome-button");
56686      qualityBtn.textContent = "--";
56687      qualityBtn.addEventListener("click", () => {
56688        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
56689      });
56690
56691      const fullscreenBtn = document.createElement("media-fullscreen-button");
56692
56693      controlBar.append(
56694        playBtn, timeDisplay, timeRange,
56695        playbackRate, muteBtn, volumeRange,
56696        captionsBtn, qualityBtn, fullscreenBtn
56697      );
56698
56699      const poster = document.createElement("media-poster-image");
56700      poster.setAttribute("slot", "poster");
56701      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
56702
56703      controller.append(provider, poster, controlBar);
56704
56705      captionsBtn.setAttribute("aria-checked", "true");
56706      controller.setAttribute("mediasubtitlesshowing", "on");
56707
56708      container.appendChild(controller);
56709      details.appendChild(container);
56710
56711      provider.addEventListener("waiting", () => container.classList.add("loading"));
56712      provider.addEventListener("playing", () => container.classList.remove("loading"));
56713      provider.addEventListener("play", () => container.classList.remove("loading"));
56714
56715      provider._textTracks.addEventListener("change", () => {
56716        const enabled = provider._captionsEnabled;
56717        captionsBtn.setAttribute("aria-checked", enabled);
56718        if (enabled) {
56719          controller.setAttribute("mediasubtitlesshowing", "on");
56720        } else {
56721          controller.removeAttribute("mediasubtitlesshowing");
56722        }
56723      });
56724
56725      provider.addEventListener("qualitychange", (e) => {
56726        qualityBtn.textContent = e.detail.quality;
56727      });
56728    }
56729
56730    function initLazyPlayers() {
56731      document.querySelectorAll("details[data-video-id]").forEach((details) => {
56732        if (details.dataset.lazyInit) return;
56733        details.dataset.lazyInit = "true";
56734        details.addEventListener("toggle", () => {
56735          if (details.open && !details.querySelector(".player-container")) {
56736            createPlayer(details);
56737          } else if (!details.open) {
56738            const provider = details.querySelector("youtube-media-provider");
56739            if (provider && typeof provider.pause === "function") {
56740              provider.pause();
56741            }
56742          }
56743        });
56744      });
56745    }
56746
56747    if (document.readyState === "loading") {
56748      document.addEventListener("DOMContentLoaded", initLazyPlayers);
56749    } else {
56750      initLazyPlayers();
56751    }
56752  })();
56753</script>
56753<script>
56754  (() => {
56755    if (window.__courseCompletionsInitialized) return;
56756    window.__courseCompletionsInitialized = true;
56757
56758    const STORAGE_KEY = "course-completions";
56759
56760    function slugify(text) {
56761      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
56762    }
56763
56764    function getCompletions() {
56765      try {
56766        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
56767      } catch {
56768        return [];
56769      }
56770    }
56771
56772    function saveCompletions(completions) {
56773      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
56774    }
56775
56776    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
56777    function canonicalFromStored(storedId) {
56778      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56779      if (!m) return slugify(storedId);
56780      const [, slug, p, mv, title] = m;
56781      return `${slug}-${p}-${mv}-${slugify(title)}`;
56782    }
56783
56784    function canonicalPlusOne(storedId) {
56785      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
56786      if (!m) return null;
56787      const [, slug, p, mv, title] = m;
56788      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
56789    }
56790
56791    function isCompleted(canonicalId, completions) {
56792      if (completions.includes(canonicalId)) return true;
56793      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
56794      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
56795    }
56796
56797    function migrateCompletions() {
56798      const completions = getCompletions();
56799      let changed = false;
56800      const migrated = completions.map((c) => {
56801        const canon = canonicalFromStored(c);
56802        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
56803        const plusOne = canonicalPlusOne(c);
56804        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
56805          if (plusOne !== c) changed = true;
56806          return plusOne;
56807        }
56808        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
56809          changed = true;
56810          return canon;
56811        }
56812        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
56813        if (canon !== c) {
56814          changed = true;
56815          return canon;
56816        }
56817        return c;
56818      });
56819      // Elimina duplicados tras migración
56820      const deduped = [...new Set(migrated)];
56821      if (changed || deduped.length !== completions.length) {
56822        saveCompletions(deduped);
56823        return deduped;
56824      }
56825      return completions;
56826    }
56827
56828    function updateButtonState(btn, completions) {
56829      const id = btn.dataset.completionId;
56830      const list = completions ?? getCompletions();
56831      btn.classList.toggle("completed", isCompleted(id, list));
56832    }
56833
56834    function checkModuleCompletion(completions) {
56835      const list = completions ?? getCompletions();
56836      document.querySelectorAll(".course-video").forEach((module) => {
56837        const btns = module.querySelectorAll(".complete-btn");
56838        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
56839        const check = module.querySelector(".module-check");
56840        if (check) check.classList.toggle("visible", allCompleted);
56841      });
56842    }
56843
56844    function initCompletionButtons() {
56845      const completions = migrateCompletions();
56846
56847      document.querySelectorAll(".complete-btn").forEach((btn) => {
56848        if (btn.dataset.completionInit) return;
56849        btn.dataset.completionInit = "true";
56850        updateButtonState(btn, completions);
56851
56852        btn.addEventListener("click", (e) => {
56853          e.preventDefault();
56854          e.stopPropagation();
56855
56856          const id = btn.dataset.completionId;
56857          let list = getCompletions();
56858          const completed = isCompleted(id, list);
56859
56860          if (completed) {
56861            // Elimina canónico y cualquier variante legacy que mapee al mismo id
56862            list = list.filter((c) =>
56862 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
56863          } else {
56864            list.push(id);
56865          }
56866
56867          saveCompletions(list);
56868          updateButtonState(btn, list);
56869          checkModuleCompletion(list);
56870        });
56871      });
56872
56873      checkModuleCompletion(completions);
56874    }
56875
56876    if (document.readyState === "loading") {
56877      document.addEventListener("DOMContentLoaded", initCompletionButtons);
56878    } else {
56879      initCompletionButtons();
56880    }
56881  })();
56882</script>
56882</div></details><details class="course-video" name="videos" data-astro-cid-3yfbwi5j><summary data-astro-cid-3yfbwi5j>Día 21 📗 Seguridad/VPS + Rutas en Astro<span class="module-check" data-astro-cid-3yfbwi5j><svg width="18" height="18" data-astro-cid-3yfbwi5j><use href="/assets/icons/icons.svg#check" data-astro-cid-3yfbwi5j></use></svg></span></summary><div class="chapters" data-astro-cid-3yfbwi5j><div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="1492" data-end="1605" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>1. Introducción</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>2 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-introduccion" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
56882<script>
56883  (() => {
56884    if (window.__courseLazyPlayersInitialized) return;
56885    window.__courseLazyPlayersInitialized = true;
56886
56887    function createPlayer(details) {
56888      const videoId = details.dataset.videoId;
56889      const start = details.dataset.start;
56890      const end = details.dataset.end;
56891
56892      const container = document.createElement("div");
56893      container.className = "player-container";
56894
56895      const spinner = document.createElement("div");
56896      spinner.className = "spinner";
56897      container.appendChild(spinner);
56898
56899      const controller = document.createElement("media-controller");
56900      controller.setAttribute("defaultsubtitles", "");
56901
56902      const provider = document.createElement("youtube-media-provider");
56903      provider.setAttribute("slot", "media");
56904      provider.setAttribute("video-id", videoId);
56905      provider.setAttribute("start", start);
56906      provider.setAttribute("end", end);
56907
56908      const controlBar = document.createElement("media-control-bar");
56909
56910      const playBtn = document.createElement("media-play-button");
56911      const timeDisplay = document.createElement("media-time-display");
56912      timeDisplay.setAttribute("showduration", "");
56913      const timeRange = document.createElement("media-time-range");
56914      const playbackRate = document.createElement("media-playback-rate-button");
56915      const muteBtn = document.createElement("media-mute-button");
56916      const volumeRange = document.createElement("media-volume-range");
56917
56918      const captionsBtn = document.createElement("media-captions-button");
56919      captionsBtn.addEventListener("click", (e) => {
56920        e.stopPropagation();
56921        const provider = container.querySelector("youtube-media-provider");
56922        if (provider) provider.toggleCaptions();
56923      });
56924
56925      const qualityBtn = document.createElement("media-chrome-button");
56926      qualityBtn.textContent = "--";
56927      qualityBtn.addEventListener("click", () => {
56928        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
56929      });
56930
56931      const fullscreenBtn = document.createElement("media-fullscreen-button");
56932
56933      controlBar.append(
56934        playBtn, timeDisplay, timeRange,
56935        playbackRate, muteBtn, volumeRange,
56936        captionsBtn, qualityBtn, fullscreenBtn
56937      );
56938
56939      const poster = document.createElement("media-poster-image");
56940      poster.setAttribute("slot", "poster");
56941      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
56942
56943      controller.append(provider, poster, controlBar);
56944
56945      captionsBtn.setAttribute("aria-checked", "true");
56946      controller.setAttribute("mediasubtitlesshowing", "on");
56947
56948      container.appendChild(controller);
56949      details.appendChild(container);
56950
56951      provider.addEventListener("waiting", () => container.classList.add("loading"));
56952      provider.addEventListener("playing", () => container.classList.remove("loading"));
56953      provider.addEventListener("play", () => container.classList.remove("loading"));
56954
56955      provider._textTracks.addEventListener("change", () => {
56956        const enabled = provider._captionsEnabled;
56957        captionsBtn.setAttribute("aria-checked", enabled);
56958        if (enabled) {
56959          controller.setAttribute("mediasubtitlesshowing", "on");
56960        } else {
56961          controller.removeAttribute("mediasubtitlesshowing");
56962        }
56963      });
56964
56965      provider.addEventListener("qualitychange", (e) => {
56966        qualityBtn.textContent = e.detail.quality;
56967      });
56968    }
56969
56970    function initLazyPlayers() {
56971      document.querySelectorAll("details[data-video-id]").forEach((details) => {
56972        if (details.dataset.lazyInit) return;
56973        details.dataset.lazyInit = "true";
56974        details.addEventListener("toggle", () => {
56975          if (details.open && !details.querySelector(".player-container")) {
56976            createPlayer(details);
56977          } else if (!details.open) {
56978            const provider = details.querySelector("youtube-media-provider");
56979            if (provider && typeof provider.pause === "function") {
56980              provider.pause();
56981            }
56982          }
56983        });
56984      });
56985    }
56986
56987    if (document.readyState === "loading") {
56988      document.addEventListener("DOMContentLoaded", initLazyPlayers);
56989    } else {
56990      initLazyPlayers();
56991    }
56992  })();
56993</script>
56993<script>
56994  (() => {
56995    if (window.__courseCompletionsInitialized) return;
56996    window.__courseCompletionsInitialized = true;
56997
56998    const STORAGE_KEY = "course-completions";
56999
57000    function slugify(text) {
57001      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57002    }
57003
57004    function getCompletions() {
57005      try {
57006        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
57007      } catch {
57008        return [];
57009      }
57010    }
57011
57012    function saveCompletions(completions) {
57013      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
57014    }
57015
57016    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
57017    function canonicalFromStored(storedId) {
57018      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57019      if (!m) return slugify(storedId);
57020      const [, slug, p, mv, title] = m;
57021      return `${slug}-${p}-${mv}-${slugify(title)}`;
57022    }
57023
57024    function canonicalPlusOne(storedId) {
57025      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57026      if (!m) return null;
57027      const [, slug, p, mv, title] = m;
57028      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
57029    }
57030
57031    function isCompleted(canonicalId, completions) {
57032      if (completions.includes(canonicalId)) return true;
57033      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
57034      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
57035    }
57036
57037    function migrateCompletions() {
57038      const completions = getCompletions();
57039      let changed = false;
57040      const migrated = completions.map((c) => {
57041        const canon = canonicalFromStored(c);
57042        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
57043        const plusOne = canonicalPlusOne(c);
57044        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
57045          if (plusOne !== c) changed = true;
57046          return plusOne;
57047        }
57048        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
57049          changed = true;
57050          return canon;
57051        }
57052        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
57053        if (canon !== c) {
57054          changed = true;
57055          return canon;
57056        }
57057        return c;
57058      });
57059      // Elimina duplicados tras migración
57060      const deduped = [...new Set(migrated)];
57061      if (changed || deduped.length !== completions.length) {
57062        saveCompletions(deduped);
57063        return deduped;
57064      }
57065      return completions;
57066    }
57067
57068    function updateButtonState(btn, completions) {
57069      const id = btn.dataset.completionId;
57070      const list = completions ?? getCompletions();
57071      btn.classList.toggle("completed", isCompleted(id, list));
57072    }
57073
57074    function checkModuleCompletion(completions) {
57075      const list = completions ?? getCompletions();
57076      document.querySelectorAll(".course-video").forEach((module) => {
57077        const btns = module.querySelectorAll(".complete-btn");
57078        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
57079        const check = module.querySelector(".module-check");
57080        if (check) check.classList.toggle("visible", allCompleted);
57081      });
57082    }
57083
57084    function initCompletionButtons() {
57085      const completions = migrateCompletions();
57086
57087      document.querySelectorAll(".complete-btn").forEach((btn) => {
57088        if (btn.dataset.completionInit) return;
57089        btn.dataset.completionInit = "true";
57090        updateButtonState(btn, completions);
57091
57092        btn.addEventListener("click", (e) => {
57093          e.preventDefault();
57094          e.stopPropagation();
57095
57096          const id = btn.dataset.completionId;
57097          let list = getCompletions();
57098          const completed = isCompleted(id, list);
57099
57100          if (completed) {
57101            // Elimina canónico y cualquier variante legacy que mapee al mismo id
57102            list = list.filter((c) =>
57102 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
57103          } else {
57104            list.push(id);
57105          }
57106
57107          saveCompletions(list);
57108          updateButtonState(btn, list);
57109          checkModuleCompletion(list);
57110        });
57111      });
57112
57113      checkModuleCompletion(completions);
57114    }
57115
57116    if (document.readyState === "loading") {
57117      document.addEventListener("DOMContentLoaded", initCompletionButtons);
57118    } else {
57119      initCompletionButtons();
57120    }
57121  })();
57122</script>
57122<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="1605" data-end="4640" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>2. Rutas dinámicas de Astro</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>51 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-rutas-dinamicas-de-astro" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
57122<script>
57123  (() => {
57124    if (window.__courseLazyPlayersInitialized) return;
57125    window.__courseLazyPlayersInitialized = true;
57126
57127    function createPlayer(details) {
57128      const videoId = details.dataset.videoId;
57129      const start = details.dataset.start;
57130      const end = details.dataset.end;
57131
57132      const container = document.createElement("div");
57133      container.className = "player-container";
57134
57135      const spinner = document.createElement("div");
57136      spinner.className = "spinner";
57137      container.appendChild(spinner);
57138
57139      const controller = document.createElement("media-controller");
57140      controller.setAttribute("defaultsubtitles", "");
57141
57142      const provider = document.createElement("youtube-media-provider");
57143      provider.setAttribute("slot", "media");
57144      provider.setAttribute("video-id", videoId);
57145      provider.setAttribute("start", start);
57146      provider.setAttribute("end", end);
57147
57148      const controlBar = document.createElement("media-control-bar");
57149
57150      const playBtn = document.createElement("media-play-button");
57151      const timeDisplay = document.createElement("media-time-display");
57152      timeDisplay.setAttribute("showduration", "");
57153      const timeRange = document.createElement("media-time-range");
57154      const playbackRate = document.createElement("media-playback-rate-button");
57155      const muteBtn = document.createElement("media-mute-button");
57156      const volumeRange = document.createElement("media-volume-range");
57157
57158      const captionsBtn = document.createElement("media-captions-button");
57159      captionsBtn.addEventListener("click", (e) => {
57160        e.stopPropagation();
57161        const provider = container.querySelector("youtube-media-provider");
57162        if (provider) provider.toggleCaptions();
57163      });
57164
57165      const qualityBtn = document.createElement("media-chrome-button");
57166      qualityBtn.textContent = "--";
57167      qualityBtn.addEventListener("click", () => {
57168        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
57169      });
57170
57171      const fullscreenBtn = document.createElement("media-fullscreen-button");
57172
57173      controlBar.append(
57174        playBtn, timeDisplay, timeRange,
57175        playbackRate, muteBtn, volumeRange,
57176        captionsBtn, qualityBtn, fullscreenBtn
57177      );
57178
57179      const poster = document.createElement("media-poster-image");
57180      poster.setAttribute("slot", "poster");
57181      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
57182
57183      controller.append(provider, poster, controlBar);
57184
57185      captionsBtn.setAttribute("aria-checked", "true");
57186      controller.setAttribute("mediasubtitlesshowing", "on");
57187
57188      container.appendChild(controller);
57189      details.appendChild(container);
57190
57191      provider.addEventListener("waiting", () => container.classList.add("loading"));
57192      provider.addEventListener("playing", () => container.classList.remove("loading"));
57193      provider.addEventListener("play", () => container.classList.remove("loading"));
57194
57195      provider._textTracks.addEventListener("change", () => {
57196        const enabled = provider._captionsEnabled;
57197        captionsBtn.setAttribute("aria-checked", enabled);
57198        if (enabled) {
57199          controller.setAttribute("mediasubtitlesshowing", "on");
57200        } else {
57201          controller.removeAttribute("mediasubtitlesshowing");
57202        }
57203      });
57204
57205      provider.addEventListener("qualitychange", (e) => {
57206        qualityBtn.textContent = e.detail.quality;
57207      });
57208    }
57209
57210    function initLazyPlayers() {
57211      document.querySelectorAll("details[data-video-id]").forEach((details) => {
57212        if (details.dataset.lazyInit) return;
57213        details.dataset.lazyInit = "true";
57214        details.addEventListener("toggle", () => {
57215          if (details.open && !details.querySelector(".player-container")) {
57216            createPlayer(details);
57217          } else if (!details.open) {
57218            const provider = details.querySelector("youtube-media-provider");
57219            if (provider && typeof provider.pause === "function") {
57220              provider.pause();
57221            }
57222          }
57223        });
57224      });
57225    }
57226
57227    if (document.readyState === "loading") {
57228      document.addEventListener("DOMContentLoaded", initLazyPlayers);
57229    } else {
57230      initLazyPlayers();
57231    }
57232  })();
57233</script>
57233<script>
57234  (() => {
57235    if (window.__courseCompletionsInitialized) return;
57236    window.__courseCompletionsInitialized = true;
57237
57238    const STORAGE_KEY = "course-completions";
57239
57240    function slugify(text) {
57241      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57242    }
57243
57244    function getCompletions() {
57245      try {
57246        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
57247      } catch {
57248        return [];
57249      }
57250    }
57251
57252    function saveCompletions(completions) {
57253      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
57254    }
57255
57256    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
57257    function canonicalFromStored(storedId) {
57258      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57259      if (!m) return slugify(storedId);
57260      const [, slug, p, mv, title] = m;
57261      return `${slug}-${p}-${mv}-${slugify(title)}`;
57262    }
57263
57264    function canonicalPlusOne(storedId) {
57265      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57266      if (!m) return null;
57267      const [, slug, p, mv, title] = m;
57268      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
57269    }
57270
57271    function isCompleted(canonicalId, completions) {
57272      if (completions.includes(canonicalId)) return true;
57273      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
57274      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
57275    }
57276
57277    function migrateCompletions() {
57278      const completions = getCompletions();
57279      let changed = false;
57280      const migrated = completions.map((c) => {
57281        const canon = canonicalFromStored(c);
57282        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
57283        const plusOne = canonicalPlusOne(c);
57284        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
57285          if (plusOne !== c) changed = true;
57286          return plusOne;
57287        }
57288        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
57289          changed = true;
57290          return canon;
57291        }
57292        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
57293        if (canon !== c) {
57294          changed = true;
57295          return canon;
57296        }
57297        return c;
57298      });
57299      // Elimina duplicados tras migración
57300      const deduped = [...new Set(migrated)];
57301      if (changed || deduped.length !== completions.length) {
57302        saveCompletions(deduped);
57303        return deduped;
57304      }
57305      return completions;
57306    }
57307
57308    function updateButtonState(btn, completions) {
57309      const id = btn.dataset.completionId;
57310      const list = completions ?? getCompletions();
57311      btn.classList.toggle("completed", isCompleted(id, list));
57312    }
57313
57314    function checkModuleCompletion(completions) {
57315      const list = completions ?? getCompletions();
57316      document.querySelectorAll(".course-video").forEach((module) => {
57317        const btns = module.querySelectorAll(".complete-btn");
57318        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
57319        const check = module.querySelector(".module-check");
57320        if (check) check.classList.toggle("visible", allCompleted);
57321      });
57322    }
57323
57324    function initCompletionButtons() {
57325      const completions = migrateCompletions();
57326
57327      document.querySelectorAll(".complete-btn").forEach((btn) => {
57328        if (btn.dataset.completionInit) return;
57329        btn.dataset.completionInit = "true";
57330        updateButtonState(btn, completions);
57331
57332        btn.addEventListener("click", (e) => {
57333          e.preventDefault();
57334          e.stopPropagation();
57335
57336          const id = btn.dataset.completionId;
57337          let list = getCompletions();
57338          const completed = isCompleted(id, list);
57339
57340          if (completed) {
57341            // Elimina canónico y cualquier variante legacy que mapee al mismo id
57342            list = list.filter((c) =>
57342 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
57343          } else {
57344            list.push(id);
57345          }
57346
57347          saveCompletions(list);
57348          updateButtonState(btn, list);
57349          checkModuleCompletion(list);
57350        });
57351      });
57352
57353      checkModuleCompletion(completions);
57354    }
57355
57356    if (document.readyState === "loading") {
57357      document.addEventListener("DOMContentLoaded", initCompletionButtons);
57358    } else {
57359      initCompletionButtons();
57360    }
57361  })();
57362</script>
57362<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="4640" data-end="5810" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>3. Seguridad en VPS</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>20 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-seguridad-en-vps" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
57362<script>
57363  (() => {
57364    if (window.__courseLazyPlayersInitialized) return;
57365    window.__courseLazyPlayersInitialized = true;
57366
57367    function createPlayer(details) {
57368      const videoId = details.dataset.videoId;
57369      const start = details.dataset.start;
57370      const end = details.dataset.end;
57371
57372      const container = document.createElement("div");
57373      container.className = "player-container";
57374
57375      const spinner = document.createElement("div");
57376      spinner.className = "spinner";
57377      container.appendChild(spinner);
57378
57379      const controller = document.createElement("media-controller");
57380      controller.setAttribute("defaultsubtitles", "");
57381
57382      const provider = document.createElement("youtube-media-provider");
57383      provider.setAttribute("slot", "media");
57384      provider.setAttribute("video-id", videoId);
57385      provider.setAttribute("start", start);
57386      provider.setAttribute("end", end);
57387
57388      const controlBar = document.createElement("media-control-bar");
57389
57390      const playBtn = document.createElement("media-play-button");
57391      const timeDisplay = document.createElement("media-time-display");
57392      timeDisplay.setAttribute("showduration", "");
57393      const timeRange = document.createElement("media-time-range");
57394      const playbackRate = document.createElement("media-playback-rate-button");
57395      const muteBtn = document.createElement("media-mute-button");
57396      const volumeRange = document.createElement("media-volume-range");
57397
57398      const captionsBtn = document.createElement("media-captions-button");
57399      captionsBtn.addEventListener("click", (e) => {
57400        e.stopPropagation();
57401        const provider = container.querySelector("youtube-media-provider");
57402        if (provider) provider.toggleCaptions();
57403      });
57404
57405      const qualityBtn = document.createElement("media-chrome-button");
57406      qualityBtn.textContent = "--";
57407      qualityBtn.addEventListener("click", () => {
57408        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
57409      });
57410
57411      const fullscreenBtn = document.createElement("media-fullscreen-button");
57412
57413      controlBar.append(
57414        playBtn, timeDisplay, timeRange,
57415        playbackRate, muteBtn, volumeRange,
57416        captionsBtn, qualityBtn, fullscreenBtn
57417      );
57418
57419      const poster = document.createElement("media-poster-image");
57420      poster.setAttribute("slot", "poster");
57421      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
57422
57423      controller.append(provider, poster, controlBar);
57424
57425      captionsBtn.setAttribute("aria-checked", "true");
57426      controller.setAttribute("mediasubtitlesshowing", "on");
57427
57428      container.appendChild(controller);
57429      details.appendChild(container);
57430
57431      provider.addEventListener("waiting", () => container.classList.add("loading"));
57432      provider.addEventListener("playing", () => container.classList.remove("loading"));
57433      provider.addEventListener("play", () => container.classList.remove("loading"));
57434
57435      provider._textTracks.addEventListener("change", () => {
57436        const enabled = provider._captionsEnabled;
57437        captionsBtn.setAttribute("aria-checked", enabled);
57438        if (enabled) {
57439          controller.setAttribute("mediasubtitlesshowing", "on");
57440        } else {
57441          controller.removeAttribute("mediasubtitlesshowing");
57442        }
57443      });
57444
57445      provider.addEventListener("qualitychange", (e) => {
57446        qualityBtn.textContent = e.detail.quality;
57447      });
57448    }
57449
57450    function initLazyPlayers() {
57451      document.querySelectorAll("details[data-video-id]").forEach((details) => {
57452        if (details.dataset.lazyInit) return;
57453        details.dataset.lazyInit = "true";
57454        details.addEventListener("toggle", () => {
57455          if (details.open && !details.querySelector(".player-container")) {
57456            createPlayer(details);
57457          } else if (!details.open) {
57458            const provider = details.querySelector("youtube-media-provider");
57459            if (provider && typeof provider.pause === "function") {
57460              provider.pause();
57461            }
57462          }
57463        });
57464      });
57465    }
57466
57467    if (document.readyState === "loading") {
57468      document.addEventListener("DOMContentLoaded", initLazyPlayers);
57469    } else {
57470      initLazyPlayers();
57471    }
57472  })();
57473</script>
57473<script>
57474  (() => {
57475    if (window.__courseCompletionsInitialized) return;
57476    window.__courseCompletionsInitialized = true;
57477
57478    const STORAGE_KEY = "course-completions";
57479
57480    function slugify(text) {
57481      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57482    }
57483
57484    function getCompletions() {
57485      try {
57486        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
57487      } catch {
57488        return [];
57489      }
57490    }
57491
57492    function saveCompletions(completions) {
57493      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
57494    }
57495
57496    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
57497    function canonicalFromStored(storedId) {
57498      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57499      if (!m) return slugify(storedId);
57500      const [, slug, p, mv, title] = m;
57501      return `${slug}-${p}-${mv}-${slugify(title)}`;
57502    }
57503
57504    function canonicalPlusOne(storedId) {
57505      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57506      if (!m) return null;
57507      const [, slug, p, mv, title] = m;
57508      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
57509    }
57510
57511    function isCompleted(canonicalId, completions) {
57512      if (completions.includes(canonicalId)) return true;
57513      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
57514      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
57515    }
57516
57517    function migrateCompletions() {
57518      const completions = getCompletions();
57519      let changed = false;
57520      const migrated = completions.map((c) => {
57521        const canon = canonicalFromStored(c);
57522        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
57523        const plusOne = canonicalPlusOne(c);
57524        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
57525          if (plusOne !== c) changed = true;
57526          return plusOne;
57527        }
57528        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
57529          changed = true;
57530          return canon;
57531        }
57532        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
57533        if (canon !== c) {
57534          changed = true;
57535          return canon;
57536        }
57537        return c;
57538      });
57539      // Elimina duplicados tras migración
57540      const deduped = [...new Set(migrated)];
57541      if (changed || deduped.length !== completions.length) {
57542        saveCompletions(deduped);
57543        return deduped;
57544      }
57545      return completions;
57546    }
57547
57548    function updateButtonState(btn, completions) {
57549      const id = btn.dataset.completionId;
57550      const list = completions ?? getCompletions();
57551      btn.classList.toggle("completed", isCompleted(id, list));
57552    }
57553
57554    function checkModuleCompletion(completions) {
57555      const list = completions ?? getCompletions();
57556      document.querySelectorAll(".course-video").forEach((module) => {
57557        const btns = module.querySelectorAll(".complete-btn");
57558        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
57559        const check = module.querySelector(".module-check");
57560        if (check) check.classList.toggle("visible", allCompleted);
57561      });
57562    }
57563
57564    function initCompletionButtons() {
57565      const completions = migrateCompletions();
57566
57567      document.querySelectorAll(".complete-btn").forEach((btn) => {
57568        if (btn.dataset.completionInit) return;
57569        btn.dataset.completionInit = "true";
57570        updateButtonState(btn, completions);
57571
57572        btn.addEventListener("click", (e) => {
57573          e.preventDefault();
57574          e.stopPropagation();
57575
57576          const id = btn.dataset.completionId;
57577          let list = getCompletions();
57578          const completed = isCompleted(id, list);
57579
57580          if (completed) {
57581            // Elimina canónico y cualquier variante legacy que mapee al mismo id
57582            list = list.filter((c) =>
57582 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
57583          } else {
57584            list.push(id);
57585          }
57586
57587          saveCompletions(list);
57588          updateButtonState(btn, list);
57589          checkModuleCompletion(list);
57590        });
57591      });
57592
57593      checkModuleCompletion(completions);
57594    }
57595
57596    if (document.readyState === "loading") {
57597      document.addEventListener("DOMContentLoaded", initCompletionButtons);
57598    } else {
57599      initCompletionButtons();
57600    }
57601  })();
57602</script>
57602<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="5810" data-end="6915" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>4. Firewall (ufw)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>18 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-firewall-ufw" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
57602<script>
57603  (() => {
57604    if (window.__courseLazyPlayersInitialized) return;
57605    window.__courseLazyPlayersInitialized = true;
57606
57607    function createPlayer(details) {
57608      const videoId = details.dataset.videoId;
57609      const start = details.dataset.start;
57610      const end = details.dataset.end;
57611
57612      const container = document.createElement("div");
57613      container.className = "player-container";
57614
57615      const spinner = document.createElement("div");
57616      spinner.className = "spinner";
57617      container.appendChild(spinner);
57618
57619      const controller = document.createElement("media-controller");
57620      controller.setAttribute("defaultsubtitles", "");
57621
57622      const provider = document.createElement("youtube-media-provider");
57623      provider.setAttribute("slot", "media");
57624      provider.setAttribute("video-id", videoId);
57625      provider.setAttribute("start", start);
57626      provider.setAttribute("end", end);
57627
57628      const controlBar = document.createElement("media-control-bar");
57629
57630      const playBtn = document.createElement("media-play-button");
57631      const timeDisplay = document.createElement("media-time-display");
57632      timeDisplay.setAttribute("showduration", "");
57633      const timeRange = document.createElement("media-time-range");
57634      const playbackRate = document.createElement("media-playback-rate-button");
57635      const muteBtn = document.createElement("media-mute-button");
57636      const volumeRange = document.createElement("media-volume-range");
57637
57638      const captionsBtn = document.createElement("media-captions-button");
57639      captionsBtn.addEventListener("click", (e) => {
57640        e.stopPropagation();
57641        const provider = container.querySelector("youtube-media-provider");
57642        if (provider) provider.toggleCaptions();
57643      });
57644
57645      const qualityBtn = document.createElement("media-chrome-button");
57646      qualityBtn.textContent = "--";
57647      qualityBtn.addEventListener("click", () => {
57648        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
57649      });
57650
57651      const fullscreenBtn = document.createElement("media-fullscreen-button");
57652
57653      controlBar.append(
57654        playBtn, timeDisplay, timeRange,
57655        playbackRate, muteBtn, volumeRange,
57656        captionsBtn, qualityBtn, fullscreenBtn
57657      );
57658
57659      const poster = document.createElement("media-poster-image");
57660      poster.setAttribute("slot", "poster");
57661      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
57662
57663      controller.append(provider, poster, controlBar);
57664
57665      captionsBtn.setAttribute("aria-checked", "true");
57666      controller.setAttribute("mediasubtitlesshowing", "on");
57667
57668      container.appendChild(controller);
57669      details.appendChild(container);
57670
57671      provider.addEventListener("waiting", () => container.classList.add("loading"));
57672      provider.addEventListener("playing", () => container.classList.remove("loading"));
57673      provider.addEventListener("play", () => container.classList.remove("loading"));
57674
57675      provider._textTracks.addEventListener("change", () => {
57676        const enabled = provider._captionsEnabled;
57677        captionsBtn.setAttribute("aria-checked", enabled);
57678        if (enabled) {
57679          controller.setAttribute("mediasubtitlesshowing", "on");
57680        } else {
57681          controller.removeAttribute("mediasubtitlesshowing");
57682        }
57683      });
57684
57685      provider.addEventListener("qualitychange", (e) => {
57686        qualityBtn.textContent = e.detail.quality;
57687      });
57688    }
57689
57690    function initLazyPlayers() {
57691      document.querySelectorAll("details[data-video-id]").forEach((details) => {
57692        if (details.dataset.lazyInit) return;
57693        details.dataset.lazyInit = "true";
57694        details.addEventListener("toggle", () => {
57695          if (details.open && !details.querySelector(".player-container")) {
57696            createPlayer(details);
57697          } else if (!details.open) {
57698            const provider = details.querySelector("youtube-media-provider");
57699            if (provider && typeof provider.pause === "function") {
57700              provider.pause();
57701            }
57702          }
57703        });
57704      });
57705    }
57706
57707    if (document.readyState === "loading") {
57708      document.addEventListener("DOMContentLoaded", initLazyPlayers);
57709    } else {
57710      initLazyPlayers();
57711    }
57712  })();
57713</script>
57713<script>
57714  (() => {
57715    if (window.__courseCompletionsInitialized) return;
57716    window.__courseCompletionsInitialized = true;
57717
57718    const STORAGE_KEY = "course-completions";
57719
57720    function slugify(text) {
57721      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57722    }
57723
57724    function getCompletions() {
57725      try {
57726        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
57727      } catch {
57728        return [];
57729      }
57730    }
57731
57732    function saveCompletions(completions) {
57733      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
57734    }
57735
57736    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
57737    function canonicalFromStored(storedId) {
57738      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57739      if (!m) return slugify(storedId);
57740      const [, slug, p, mv, title] = m;
57741      return `${slug}-${p}-${mv}-${slugify(title)}`;
57742    }
57743
57744    function canonicalPlusOne(storedId) {
57745      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57746      if (!m) return null;
57747      const [, slug, p, mv, title] = m;
57748      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
57749    }
57750
57751    function isCompleted(canonicalId, completions) {
57752      if (completions.includes(canonicalId)) return true;
57753      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
57754      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
57755    }
57756
57757    function migrateCompletions() {
57758      const completions = getCompletions();
57759      let changed = false;
57760      const migrated = completions.map((c) => {
57761        const canon = canonicalFromStored(c);
57762        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
57763        const plusOne = canonicalPlusOne(c);
57764        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
57765          if (plusOne !== c) changed = true;
57766          return plusOne;
57767        }
57768        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
57769          changed = true;
57770          return canon;
57771        }
57772        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
57773        if (canon !== c) {
57774          changed = true;
57775          return canon;
57776        }
57777        return c;
57778      });
57779      // Elimina duplicados tras migración
57780      const deduped = [...new Set(migrated)];
57781      if (changed || deduped.length !== completions.length) {
57782        saveCompletions(deduped);
57783        return deduped;
57784      }
57785      return completions;
57786    }
57787
57788    function updateButtonState(btn, completions) {
57789      const id = btn.dataset.completionId;
57790      const list = completions ?? getCompletions();
57791      btn.classList.toggle("completed", isCompleted(id, list));
57792    }
57793
57794    function checkModuleCompletion(completions) {
57795      const list = completions ?? getCompletions();
57796      document.querySelectorAll(".course-video").forEach((module) => {
57797        const btns = module.querySelectorAll(".complete-btn");
57798        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
57799        const check = module.querySelector(".module-check");
57800        if (check) check.classList.toggle("visible", allCompleted);
57801      });
57802    }
57803
57804    function initCompletionButtons() {
57805      const completions = migrateCompletions();
57806
57807      document.querySelectorAll(".complete-btn").forEach((btn) => {
57808        if (btn.dataset.completionInit) return;
57809        btn.dataset.completionInit = "true";
57810        updateButtonState(btn, completions);
57811
57812        btn.addEventListener("click", (e) => {
57813          e.preventDefault();
57814          e.stopPropagation();
57815
57816          const id = btn.dataset.completionId;
57817          let list = getCompletions();
57818          const completed = isCompleted(id, list);
57819
57820          if (completed) {
57821            // Elimina canónico y cualquier variante legacy que mapee al mismo id
57822            list = list.filter((c) =>
57822 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
57823          } else {
57824            list.push(id);
57825          }
57826
57827          saveCompletions(list);
57828          updateButtonState(btn, list);
57829          checkModuleCompletion(list);
57830        });
57831      });
57832
57833      checkModuleCompletion(completions);
57834    }
57835
57836    if (document.readyState === "loading") {
57837      document.addEventListener("DOMContentLoaded", initCompletionButtons);
57838    } else {
57839      initCompletionButtons();
57840    }
57841  })();
57842</script>
57842<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="6915" data-end="7865" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>5. Fail2ban</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>16 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-fail2ban" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
57842<script>
57843  (() => {
57844    if (window.__courseLazyPlayersInitialized) return;
57845    window.__courseLazyPlayersInitialized = true;
57846
57847    function createPlayer(details) {
57848      const videoId = details.dataset.videoId;
57849      const start = details.dataset.start;
57850      const end = details.dataset.end;
57851
57852      const container = document.createElement("div");
57853      container.className = "player-container";
57854
57855      const spinner = document.createElement("div");
57856      spinner.className = "spinner";
57857      container.appendChild(spinner);
57858
57859      const controller = document.createElement("media-controller");
57860      controller.setAttribute("defaultsubtitles", "");
57861
57862      const provider = document.createElement("youtube-media-provider");
57863      provider.setAttribute("slot", "media");
57864      provider.setAttribute("video-id", videoId);
57865      provider.setAttribute("start", start);
57866      provider.setAttribute("end", end);
57867
57868      const controlBar = document.createElement("media-control-bar");
57869
57870      const playBtn = document.createElement("media-play-button");
57871      const timeDisplay = document.createElement("media-time-display");
57872      timeDisplay.setAttribute("showduration", "");
57873      const timeRange = document.createElement("media-time-range");
57874      const playbackRate = document.createElement("media-playback-rate-button");
57875      const muteBtn = document.createElement("media-mute-button");
57876      const volumeRange = document.createElement("media-volume-range");
57877
57878      const captionsBtn = document.createElement("media-captions-button");
57879      captionsBtn.addEventListener("click", (e) => {
57880        e.stopPropagation();
57881        const provider = container.querySelector("youtube-media-provider");
57882        if (provider) provider.toggleCaptions();
57883      });
57884
57885      const qualityBtn = document.createElement("media-chrome-button");
57886      qualityBtn.textContent = "--";
57887      qualityBtn.addEventListener("click", () => {
57888        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
57889      });
57890
57891      const fullscreenBtn = document.createElement("media-fullscreen-button");
57892
57893      controlBar.append(
57894        playBtn, timeDisplay, timeRange,
57895        playbackRate, muteBtn, volumeRange,
57896        captionsBtn, qualityBtn, fullscreenBtn
57897      );
57898
57899      const poster = document.createElement("media-poster-image");
57900      poster.setAttribute("slot", "poster");
57901      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
57902
57903      controller.append(provider, poster, controlBar);
57904
57905      captionsBtn.setAttribute("aria-checked", "true");
57906      controller.setAttribute("mediasubtitlesshowing", "on");
57907
57908      container.appendChild(controller);
57909      details.appendChild(container);
57910
57911      provider.addEventListener("waiting", () => container.classList.add("loading"));
57912      provider.addEventListener("playing", () => container.classList.remove("loading"));
57913      provider.addEventListener("play", () => container.classList.remove("loading"));
57914
57915      provider._textTracks.addEventListener("change", () => {
57916        const enabled = provider._captionsEnabled;
57917        captionsBtn.setAttribute("aria-checked", enabled);
57918        if (enabled) {
57919          controller.setAttribute("mediasubtitlesshowing", "on");
57920        } else {
57921          controller.removeAttribute("mediasubtitlesshowing");
57922        }
57923      });
57924
57925      provider.addEventListener("qualitychange", (e) => {
57926        qualityBtn.textContent = e.detail.quality;
57927      });
57928    }
57929
57930    function initLazyPlayers() {
57931      document.querySelectorAll("details[data-video-id]").forEach((details) => {
57932        if (details.dataset.lazyInit) return;
57933        details.dataset.lazyInit = "true";
57934        details.addEventListener("toggle", () => {
57935          if (details.open && !details.querySelector(".player-container")) {
57936            createPlayer(details);
57937          } else if (!details.open) {
57938            const provider = details.querySelector("youtube-media-provider");
57939            if (provider && typeof provider.pause === "function") {
57940              provider.pause();
57941            }
57942          }
57943        });
57944      });
57945    }
57946
57947    if (document.readyState === "loading") {
57948      document.addEventListener("DOMContentLoaded", initLazyPlayers);
57949    } else {
57950      initLazyPlayers();
57951    }
57952  })();
57953</script>
57953<script>
57954  (() => {
57955    if (window.__courseCompletionsInitialized) return;
57956    window.__courseCompletionsInitialized = true;
57957
57958    const STORAGE_KEY = "course-completions";
57959
57960    function slugify(text) {
57961      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57962    }
57963
57964    function getCompletions() {
57965      try {
57966        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
57967      } catch {
57968        return [];
57969      }
57970    }
57971
57972    function saveCompletions(completions) {
57973      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
57974    }
57975
57976    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
57977    function canonicalFromStored(storedId) {
57978      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57979      if (!m) return slugify(storedId);
57980      const [, slug, p, mv, title] = m;
57981      return `${slug}-${p}-${mv}-${slugify(title)}`;
57982    }
57983
57984    function canonicalPlusOne(storedId) {
57985      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
57986      if (!m) return null;
57987      const [, slug, p, mv, title] = m;
57988      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
57989    }
57990
57991    function isCompleted(canonicalId, completions) {
57992      if (completions.includes(canonicalId)) return true;
57993      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
57994      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
57995    }
57996
57997    function migrateCompletions() {
57998      const completions = getCompletions();
57999      let changed = false;
58000      const migrated = completions.map((c) => {
58001        const canon = canonicalFromStored(c);
58002        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
58003        const plusOne = canonicalPlusOne(c);
58004        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
58005          if (plusOne !== c) changed = true;
58006          return plusOne;
58007        }
58008        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
58009          changed = true;
58010          return canon;
58011        }
58012        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
58013        if (canon !== c) {
58014          changed = true;
58015          return canon;
58016        }
58017        return c;
58018      });
58019      // Elimina duplicados tras migración
58020      const deduped = [...new Set(migrated)];
58021      if (changed || deduped.length !== completions.length) {
58022        saveCompletions(deduped);
58023        return deduped;
58024      }
58025      return completions;
58026    }
58027
58028    function updateButtonState(btn, completions) {
58029      const id = btn.dataset.completionId;
58030      const list = completions ?? getCompletions();
58031      btn.classList.toggle("completed", isCompleted(id, list));
58032    }
58033
58034    function checkModuleCompletion(completions) {
58035      const list = completions ?? getCompletions();
58036      document.querySelectorAll(".course-video").forEach((module) => {
58037        const btns = module.querySelectorAll(".complete-btn");
58038        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
58039        const check = module.querySelector(".module-check");
58040        if (check) check.classList.toggle("visible", allCompleted);
58041      });
58042    }
58043
58044    function initCompletionButtons() {
58045      const completions = migrateCompletions();
58046
58047      document.querySelectorAll(".complete-btn").forEach((btn) => {
58048        if (btn.dataset.completionInit) return;
58049        btn.dataset.completionInit = "true";
58050        updateButtonState(btn, completions);
58051
58052        btn.addEventListener("click", (e) => {
58053          e.preventDefault();
58054          e.stopPropagation();
58055
58056          const id = btn.dataset.completionId;
58057          let list = getCompletions();
58058          const completed = isCompleted(id, list);
58059
58060          if (completed) {
58061            // Elimina canónico y cualquier variante legacy que mapee al mismo id
58062            list = list.filter((c) =>
58062 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
58063          } else {
58064            list.push(id);
58065          }
58066
58067          saveCompletions(list);
58068          updateButtonState(btn, list);
58069          checkModuleCompletion(list);
58070        });
58071      });
58072
58073      checkModuleCompletion(completions);
58074    }
58075
58076    if (document.readyState === "loading") {
58077      document.addEventListener("DOMContentLoaded", initCompletionButtons);
58078    } else {
58079      initCompletionButtons();
58080    }
58081  })();
58082</script>
58082<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="7865" data-end="9021" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>6. Node + Nginx (VPS)</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>19 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-node-nginx-vps" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
58082<script>
58083  (() => {
58084    if (window.__courseLazyPlayersInitialized) return;
58085    window.__courseLazyPlayersInitialized = true;
58086
58087    function createPlayer(details) {
58088      const videoId = details.dataset.videoId;
58089      const start = details.dataset.start;
58090      const end = details.dataset.end;
58091
58092      const container = document.createElement("div");
58093      container.className = "player-container";
58094
58095      const spinner = document.createElement("div");
58096      spinner.className = "spinner";
58097      container.appendChild(spinner);
58098
58099      const controller = document.createElement("media-controller");
58100      controller.setAttribute("defaultsubtitles", "");
58101
58102      const provider = document.createElement("youtube-media-provider");
58103      provider.setAttribute("slot", "media");
58104      provider.setAttribute("video-id", videoId);
58105      provider.setAttribute("start", start);
58106      provider.setAttribute("end", end);
58107
58108      const controlBar = document.createElement("media-control-bar");
58109
58110      const playBtn = document.createElement("media-play-button");
58111      const timeDisplay = document.createElement("media-time-display");
58112      timeDisplay.setAttribute("showduration", "");
58113      const timeRange = document.createElement("media-time-range");
58114      const playbackRate = document.createElement("media-playback-rate-button");
58115      const muteBtn = document.createElement("media-mute-button");
58116      const volumeRange = document.createElement("media-volume-range");
58117
58118      const captionsBtn = document.createElement("media-captions-button");
58119      captionsBtn.addEventListener("click", (e) => {
58120        e.stopPropagation();
58121        const provider = container.querySelector("youtube-media-provider");
58122        if (provider) provider.toggleCaptions();
58123      });
58124
58125      const qualityBtn = document.createElement("media-chrome-button");
58126      qualityBtn.textContent = "--";
58127      qualityBtn.addEventListener("click", () => {
58128        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
58129      });
58130
58131      const fullscreenBtn = document.createElement("media-fullscreen-button");
58132
58133      controlBar.append(
58134        playBtn, timeDisplay, timeRange,
58135        playbackRate, muteBtn, volumeRange,
58136        captionsBtn, qualityBtn, fullscreenBtn
58137      );
58138
58139      const poster = document.createElement("media-poster-image");
58140      poster.setAttribute("slot", "poster");
58141      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
58142
58143      controller.append(provider, poster, controlBar);
58144
58145      captionsBtn.setAttribute("aria-checked", "true");
58146      controller.setAttribute("mediasubtitlesshowing", "on");
58147
58148      container.appendChild(controller);
58149      details.appendChild(container);
58150
58151      provider.addEventListener("waiting", () => container.classList.add("loading"));
58152      provider.addEventListener("playing", () => container.classList.remove("loading"));
58153      provider.addEventListener("play", () => container.classList.remove("loading"));
58154
58155      provider._textTracks.addEventListener("change", () => {
58156        const enabled = provider._captionsEnabled;
58157        captionsBtn.setAttribute("aria-checked", enabled);
58158        if (enabled) {
58159          controller.setAttribute("mediasubtitlesshowing", "on");
58160        } else {
58161          controller.removeAttribute("mediasubtitlesshowing");
58162        }
58163      });
58164
58165      provider.addEventListener("qualitychange", (e) => {
58166        qualityBtn.textContent = e.detail.quality;
58167      });
58168    }
58169
58170    function initLazyPlayers() {
58171      document.querySelectorAll("details[data-video-id]").forEach((details) => {
58172        if (details.dataset.lazyInit) return;
58173        details.dataset.lazyInit = "true";
58174        details.addEventListener("toggle", () => {
58175          if (details.open && !details.querySelector(".player-container")) {
58176            createPlayer(details);
58177          } else if (!details.open) {
58178            const provider = details.querySelector("youtube-media-provider");
58179            if (provider && typeof provider.pause === "function") {
58180              provider.pause();
58181            }
58182          }
58183        });
58184      });
58185    }
58186
58187    if (document.readyState === "loading") {
58188      document.addEventListener("DOMContentLoaded", initLazyPlayers);
58189    } else {
58190      initLazyPlayers();
58191    }
58192  })();
58193</script>
58193<script>
58194  (() => {
58195    if (window.__courseCompletionsInitialized) return;
58196    window.__courseCompletionsInitialized = true;
58197
58198    const STORAGE_KEY = "course-completions";
58199
58200    function slugify(text) {
58201      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
58202    }
58203
58204    function getCompletions() {
58205      try {
58206        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
58207      } catch {
58208        return [];
58209      }
58210    }
58211
58212    function saveCompletions(completions) {
58213      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
58214    }
58215
58216    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
58217    function canonicalFromStored(storedId) {
58218      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58219      if (!m) return slugify(storedId);
58220      const [, slug, p, mv, title] = m;
58221      return `${slug}-${p}-${mv}-${slugify(title)}`;
58222    }
58223
58224    function canonicalPlusOne(storedId) {
58225      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58226      if (!m) return null;
58227      const [, slug, p, mv, title] = m;
58228      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
58229    }
58230
58231    function isCompleted(canonicalId, completions) {
58232      if (completions.includes(canonicalId)) return true;
58233      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
58234      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
58235    }
58236
58237    function migrateCompletions() {
58238      const completions = getCompletions();
58239      let changed = false;
58240      const migrated = completions.map((c) => {
58241        const canon = canonicalFromStored(c);
58242        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
58243        const plusOne = canonicalPlusOne(c);
58244        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
58245          if (plusOne !== c) changed = true;
58246          return plusOne;
58247        }
58248        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
58249          changed = true;
58250          return canon;
58251        }
58252        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
58253        if (canon !== c) {
58254          changed = true;
58255          return canon;
58256        }
58257        return c;
58258      });
58259      // Elimina duplicados tras migración
58260      const deduped = [...new Set(migrated)];
58261      if (changed || deduped.length !== completions.length) {
58262        saveCompletions(deduped);
58263        return deduped;
58264      }
58265      return completions;
58266    }
58267
58268    function updateButtonState(btn, completions) {
58269      const id = btn.dataset.completionId;
58270      const list = completions ?? getCompletions();
58271      btn.classList.toggle("completed", isCompleted(id, list));
58272    }
58273
58274    function checkModuleCompletion(completions) {
58275      const list = completions ?? getCompletions();
58276      document.querySelectorAll(".course-video").forEach((module) => {
58277        const btns = module.querySelectorAll(".complete-btn");
58278        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
58279        const check = module.querySelector(".module-check");
58280        if (check) check.classList.toggle("visible", allCompleted);
58281      });
58282    }
58283
58284    function initCompletionButtons() {
58285      const completions = migrateCompletions();
58286
58287      document.querySelectorAll(".complete-btn").forEach((btn) => {
58288        if (btn.dataset.completionInit) return;
58289        btn.dataset.completionInit = "true";
58290        updateButtonState(btn, completions);
58291
58292        btn.addEventListener("click", (e) => {
58293          e.preventDefault();
58294          e.stopPropagation();
58295
58296          const id = btn.dataset.completionId;
58297          let list = getCompletions();
58298          const completed = isCompleted(id, list);
58299
58300          if (completed) {
58301            // Elimina canónico y cualquier variante legacy que mapee al mismo id
58302            list = list.filter((c) =>
58302 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
58303          } else {
58304            list.push(id);
58305          }
58306
58307          saveCompletions(list);
58308          updateButtonState(btn, list);
58309          checkModuleCompletion(list);
58310        });
58311      });
58312
58313      checkModuleCompletion(completions);
58314    }
58315
58316    if (document.readyState === "loading") {
58317      document.addEventListener("DOMContentLoaded", initCompletionButtons);
58318    } else {
58319      initCompletionButtons();
58320    }
58321  })();
58322</script>
58322<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="9021" data-end="10300" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>7. PM2 + Node</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-pm2-node" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
58322<script>
58323  (() => {
58324    if (window.__courseLazyPlayersInitialized) return;
58325    window.__courseLazyPlayersInitialized = true;
58326
58327    function createPlayer(details) {
58328      const videoId = details.dataset.videoId;
58329      const start = details.dataset.start;
58330      const end = details.dataset.end;
58331
58332      const container = document.createElement("div");
58333      container.className = "player-container";
58334
58335      const spinner = document.createElement("div");
58336      spinner.className = "spinner";
58337      container.appendChild(spinner);
58338
58339      const controller = document.createElement("media-controller");
58340      controller.setAttribute("defaultsubtitles", "");
58341
58342      const provider = document.createElement("youtube-media-provider");
58343      provider.setAttribute("slot", "media");
58344      provider.setAttribute("video-id", videoId);
58345      provider.setAttribute("start", start);
58346      provider.setAttribute("end", end);
58347
58348      const controlBar = document.createElement("media-control-bar");
58349
58350      const playBtn = document.createElement("media-play-button");
58351      const timeDisplay = document.createElement("media-time-display");
58352      timeDisplay.setAttribute("showduration", "");
58353      const timeRange = document.createElement("media-time-range");
58354      const playbackRate = document.createElement("media-playback-rate-button");
58355      const muteBtn = document.createElement("media-mute-button");
58356      const volumeRange = document.createElement("media-volume-range");
58357
58358      const captionsBtn = document.createElement("media-captions-button");
58359      captionsBtn.addEventListener("click", (e) => {
58360        e.stopPropagation();
58361        const provider = container.querySelector("youtube-media-provider");
58362        if (provider) provider.toggleCaptions();
58363      });
58364
58365      const qualityBtn = document.createElement("media-chrome-button");
58366      qualityBtn.textContent = "--";
58367      qualityBtn.addEventListener("click", () => {
58368        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
58369      });
58370
58371      const fullscreenBtn = document.createElement("media-fullscreen-button");
58372
58373      controlBar.append(
58374        playBtn, timeDisplay, timeRange,
58375        playbackRate, muteBtn, volumeRange,
58376        captionsBtn, qualityBtn, fullscreenBtn
58377      );
58378
58379      const poster = document.createElement("media-poster-image");
58380      poster.setAttribute("slot", "poster");
58381      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
58382
58383      controller.append(provider, poster, controlBar);
58384
58385      captionsBtn.setAttribute("aria-checked", "true");
58386      controller.setAttribute("mediasubtitlesshowing", "on");
58387
58388      container.appendChild(controller);
58389      details.appendChild(container);
58390
58391      provider.addEventListener("waiting", () => container.classList.add("loading"));
58392      provider.addEventListener("playing", () => container.classList.remove("loading"));
58393      provider.addEventListener("play", () => container.classList.remove("loading"));
58394
58395      provider._textTracks.addEventListener("change", () => {
58396        const enabled = provider._captionsEnabled;
58397        captionsBtn.setAttribute("aria-checked", enabled);
58398        if (enabled) {
58399          controller.setAttribute("mediasubtitlesshowing", "on");
58400        } else {
58401          controller.removeAttribute("mediasubtitlesshowing");
58402        }
58403      });
58404
58405      provider.addEventListener("qualitychange", (e) => {
58406        qualityBtn.textContent = e.detail.quality;
58407      });
58408    }
58409
58410    function initLazyPlayers() {
58411      document.querySelectorAll("details[data-video-id]").forEach((details) => {
58412        if (details.dataset.lazyInit) return;
58413        details.dataset.lazyInit = "true";
58414        details.addEventListener("toggle", () => {
58415          if (details.open && !details.querySelector(".player-container")) {
58416            createPlayer(details);
58417          } else if (!details.open) {
58418            const provider = details.querySelector("youtube-media-provider");
58419            if (provider && typeof provider.pause === "function") {
58420              provider.pause();
58421            }
58422          }
58423        });
58424      });
58425    }
58426
58427    if (document.readyState === "loading") {
58428      document.addEventListener("DOMContentLoaded", initLazyPlayers);
58429    } else {
58430      initLazyPlayers();
58431    }
58432  })();
58433</script>
58433<script>
58434  (() => {
58435    if (window.__courseCompletionsInitialized) return;
58436    window.__courseCompletionsInitialized = true;
58437
58438    const STORAGE_KEY = "course-completions";
58439
58440    function slugify(text) {
58441      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
58442    }
58443
58444    function getCompletions() {
58445      try {
58446        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
58447      } catch {
58448        return [];
58449      }
58450    }
58451
58452    function saveCompletions(completions) {
58453      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
58454    }
58455
58456    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
58457    function canonicalFromStored(storedId) {
58458      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58459      if (!m) return slugify(storedId);
58460      const [, slug, p, mv, title] = m;
58461      return `${slug}-${p}-${mv}-${slugify(title)}`;
58462    }
58463
58464    function canonicalPlusOne(storedId) {
58465      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58466      if (!m) return null;
58467      const [, slug, p, mv, title] = m;
58468      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
58469    }
58470
58471    function isCompleted(canonicalId, completions) {
58472      if (completions.includes(canonicalId)) return true;
58473      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
58474      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
58475    }
58476
58477    function migrateCompletions() {
58478      const completions = getCompletions();
58479      let changed = false;
58480      const migrated = completions.map((c) => {
58481        const canon = canonicalFromStored(c);
58482        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
58483        const plusOne = canonicalPlusOne(c);
58484        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
58485          if (plusOne !== c) changed = true;
58486          return plusOne;
58487        }
58488        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
58489          changed = true;
58490          return canon;
58491        }
58492        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
58493        if (canon !== c) {
58494          changed = true;
58495          return canon;
58496        }
58497        return c;
58498      });
58499      // Elimina duplicados tras migración
58500      const deduped = [...new Set(migrated)];
58501      if (changed || deduped.length !== completions.length) {
58502        saveCompletions(deduped);
58503        return deduped;
58504      }
58505      return completions;
58506    }
58507
58508    function updateButtonState(btn, completions) {
58509      const id = btn.dataset.completionId;
58510      const list = completions ?? getCompletions();
58511      btn.classList.toggle("completed", isCompleted(id, list));
58512    }
58513
58514    function checkModuleCompletion(completions) {
58515      const list = completions ?? getCompletions();
58516      document.querySelectorAll(".course-video").forEach((module) => {
58517        const btns = module.querySelectorAll(".complete-btn");
58518        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
58519        const check = module.querySelector(".module-check");
58520        if (check) check.classList.toggle("visible", allCompleted);
58521      });
58522    }
58523
58524    function initCompletionButtons() {
58525      const completions = migrateCompletions();
58526
58527      document.querySelectorAll(".complete-btn").forEach((btn) => {
58528        if (btn.dataset.completionInit) return;
58529        btn.dataset.completionInit = "true";
58530        updateButtonState(btn, completions);
58531
58532        btn.addEventListener("click", (e) => {
58533          e.preventDefault();
58534          e.stopPropagation();
58535
58536          const id = btn.dataset.completionId;
58537          let list = getCompletions();
58538          const completed = isCompleted(id, list);
58539
58540          if (completed) {
58541            // Elimina canónico y cualquier variante legacy que mapee al mismo id
58542            list = list.filter((c) =>
58542 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
58543          } else {
58544            list.push(id);
58545          }
58546
58547          saveCompletions(list);
58548          updateButtonState(btn, list);
58549          checkModuleCompletion(list);
58550        });
58551      });
58552
58553      checkModuleCompletion(completions);
58554    }
58555
58556    if (document.readyState === "loading") {
58557      document.addEventListener("DOMContentLoaded", initCompletionButtons);
58558    } else {
58559      initCompletionButtons();
58560    }
58561  })();
58562</script>
58562<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="10300" data-end="11540" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>8. Final: Futuros pasos</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>21 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-final-futuros-pasos" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
58562<script>
58563  (() => {
58564    if (window.__courseLazyPlayersInitialized) return;
58565    window.__courseLazyPlayersInitialized = true;
58566
58567    function createPlayer(details) {
58568      const videoId = details.dataset.videoId;
58569      const start = details.dataset.start;
58570      const end = details.dataset.end;
58571
58572      const container = document.createElement("div");
58573      container.className = "player-container";
58574
58575      const spinner = document.createElement("div");
58576      spinner.className = "spinner";
58577      container.appendChild(spinner);
58578
58579      const controller = document.createElement("media-controller");
58580      controller.setAttribute("defaultsubtitles", "");
58581
58582      const provider = document.createElement("youtube-media-provider");
58583      provider.setAttribute("slot", "media");
58584      provider.setAttribute("video-id", videoId);
58585      provider.setAttribute("start", start);
58586      provider.setAttribute("end", end);
58587
58588      const controlBar = document.createElement("media-control-bar");
58589
58590      const playBtn = document.createElement("media-play-button");
58591      const timeDisplay = document.createElement("media-time-display");
58592      timeDisplay.setAttribute("showduration", "");
58593      const timeRange = document.createElement("media-time-range");
58594      const playbackRate = document.createElement("media-playback-rate-button");
58595      const muteBtn = document.createElement("media-mute-button");
58596      const volumeRange = document.createElement("media-volume-range");
58597
58598      const captionsBtn = document.createElement("media-captions-button");
58599      captionsBtn.addEventListener("click", (e) => {
58600        e.stopPropagation();
58601        const provider = container.querySelector("youtube-media-provider");
58602        if (provider) provider.toggleCaptions();
58603      });
58604
58605      const qualityBtn = document.createElement("media-chrome-button");
58606      qualityBtn.textContent = "--";
58607      qualityBtn.addEventListener("click", () => {
58608        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
58609      });
58610
58611      const fullscreenBtn = document.createElement("media-fullscreen-button");
58612
58613      controlBar.append(
58614        playBtn, timeDisplay, timeRange,
58615        playbackRate, muteBtn, volumeRange,
58616        captionsBtn, qualityBtn, fullscreenBtn
58617      );
58618
58619      const poster = document.createElement("media-poster-image");
58620      poster.setAttribute("slot", "poster");
58621      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
58622
58623      controller.append(provider, poster, controlBar);
58624
58625      captionsBtn.setAttribute("aria-checked", "true");
58626      controller.setAttribute("mediasubtitlesshowing", "on");
58627
58628      container.appendChild(controller);
58629      details.appendChild(container);
58630
58631      provider.addEventListener("waiting", () => container.classList.add("loading"));
58632      provider.addEventListener("playing", () => container.classList.remove("loading"));
58633      provider.addEventListener("play", () => container.classList.remove("loading"));
58634
58635      provider._textTracks.addEventListener("change", () => {
58636        const enabled = provider._captionsEnabled;
58637        captionsBtn.setAttribute("aria-checked", enabled);
58638        if (enabled) {
58639          controller.setAttribute("mediasubtitlesshowing", "on");
58640        } else {
58641          controller.removeAttribute("mediasubtitlesshowing");
58642        }
58643      });
58644
58645      provider.addEventListener("qualitychange", (e) => {
58646        qualityBtn.textContent = e.detail.quality;
58647      });
58648    }
58649
58650    function initLazyPlayers() {
58651      document.querySelectorAll("details[data-video-id]").forEach((details) => {
58652        if (details.dataset.lazyInit) return;
58653        details.dataset.lazyInit = "true";
58654        details.addEventListener("toggle", () => {
58655          if (details.open && !details.querySelector(".player-container")) {
58656            createPlayer(details);
58657          } else if (!details.open) {
58658            const provider = details.querySelector("youtube-media-provider");
58659            if (provider && typeof provider.pause === "function") {
58660              provider.pause();
58661            }
58662          }
58663        });
58664      });
58665    }
58666
58667    if (document.readyState === "loading") {
58668      document.addEventListener("DOMContentLoaded", initLazyPlayers);
58669    } else {
58670      initLazyPlayers();
58671    }
58672  })();
58673</script>
58673<script>
58674  (() => {
58675    if (window.__courseCompletionsInitialized) return;
58676    window.__courseCompletionsInitialized = true;
58677
58678    const STORAGE_KEY = "course-completions";
58679
58680    function slugify(text) {
58681      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
58682    }
58683
58684    function getCompletions() {
58685      try {
58686        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
58687      } catch {
58688        return [];
58689      }
58690    }
58691
58692    function saveCompletions(completions) {
58693      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
58694    }
58695
58696    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
58697    function canonicalFromStored(storedId) {
58698      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58699      if (!m) return slugify(storedId);
58700      const [, slug, p, mv, title] = m;
58701      return `${slug}-${p}-${mv}-${slugify(title)}`;
58702    }
58703
58704    function canonicalPlusOne(storedId) {
58705      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58706      if (!m) return null;
58707      const [, slug, p, mv, title] = m;
58708      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
58709    }
58710
58711    function isCompleted(canonicalId, completions) {
58712      if (completions.includes(canonicalId)) return true;
58713      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
58714      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
58715    }
58716
58717    function migrateCompletions() {
58718      const completions = getCompletions();
58719      let changed = false;
58720      const migrated = completions.map((c) => {
58721        const canon = canonicalFromStored(c);
58722        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
58723        const plusOne = canonicalPlusOne(c);
58724        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
58725          if (plusOne !== c) changed = true;
58726          return plusOne;
58727        }
58728        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
58729          changed = true;
58730          return canon;
58731        }
58732        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
58733        if (canon !== c) {
58734          changed = true;
58735          return canon;
58736        }
58737        return c;
58738      });
58739      // Elimina duplicados tras migración
58740      const deduped = [...new Set(migrated)];
58741      if (changed || deduped.length !== completions.length) {
58742        saveCompletions(deduped);
58743        return deduped;
58744      }
58745      return completions;
58746    }
58747
58748    function updateButtonState(btn, completions) {
58749      const id = btn.dataset.completionId;
58750      const list = completions ?? getCompletions();
58751      btn.classList.toggle("completed", isCompleted(id, list));
58752    }
58753
58754    function checkModuleCompletion(completions) {
58755      const list = completions ?? getCompletions();
58756      document.querySelectorAll(".course-video").forEach((module) => {
58757        const btns = module.querySelectorAll(".complete-btn");
58758        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
58759        const check = module.querySelector(".module-check");
58760        if (check) check.classList.toggle("visible", allCompleted);
58761      });
58762    }
58763
58764    function initCompletionButtons() {
58765      const completions = migrateCompletions();
58766
58767      document.querySelectorAll(".complete-btn").forEach((btn) => {
58768        if (btn.dataset.completionInit) return;
58769        btn.dataset.completionInit = "true";
58770        updateButtonState(btn, completions);
58771
58772        btn.addEventListener("click", (e) => {
58773          e.preventDefault();
58774          e.stopPropagation();
58775
58776          const id = btn.dataset.completionId;
58777          let list = getCompletions();
58778          const completed = isCompleted(id, list);
58779
58780          if (completed) {
58781            // Elimina canónico y cualquier variante legacy que mapee al mismo id
58782            list = list.filter((c) =>
58782 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
58783          } else {
58784            list.push(id);
58785          }
58786
58787          saveCompletions(list);
58788          updateButtonState(btn, list);
58789          checkModuleCompletion(list);
58790        });
58791      });
58792
58793      checkModuleCompletion(completions);
58794    }
58795
58796    if (document.readyState === "loading") {
58797      document.addEventListener("DOMContentLoaded", initCompletionButtons);
58798    } else {
58799      initCompletionButtons();
58800    }
58801  })();
58802</script>
58802<div class="fragment" data-astro-cid-ne7k7lxr><details name="chapters" data-video-id="pULtp9uZXjk" data-start="11540" data-end="11733" data-astro-cid-ne7k7lxr><summary data-astro-cid-ne7k7lxr><span class="play-icon" data-astro-cid-ne7k7lxr>▶</span><span data-astro-cid-ne7k7lxr>9. Tomas falsas</span><span class="duration" data-astro-cid-ne7k7lxr><span data-astro-cid-ne7k7lxr>3 min</span><svg width="12" height="12" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#clock" data-astro-cid-ne7k7lxr></use></svg></span><button type="button" class="complete-btn" data-completion-id="bootcamp-5-5-tomas-falsas" title="Marcar como completado" data-astro-cid-ne7k7lxr><svg width="14" height="14" data-astro-cid-ne7k7lxr><use href="/assets/icons/icons.svg#check" data-astro-cid-ne7k7lxr></use></svg></button></summary></details></div>
58802<script>
58803  (() => {
58804    if (window.__courseLazyPlayersInitialized) return;
58805    window.__courseLazyPlayersInitialized = true;
58806
58807    function createPlayer(details) {
58808      const videoId = details.dataset.videoId;
58809      const start = details.dataset.start;
58810      const end = details.dataset.end;
58811
58812      const container = document.createElement("div");
58813      container.className = "player-container";
58814
58815      const spinner = document.createElement("div");
58816      spinner.className = "spinner";
58817      container.appendChild(spinner);
58818
58819      const controller = document.createElement("media-controller");
58820      controller.setAttribute("defaultsubtitles", "");
58821
58822      const provider = document.createElement("youtube-media-provider");
58823      provider.setAttribute("slot", "media");
58824      provider.setAttribute("video-id", videoId);
58825      provider.setAttribute("start", start);
58826      provider.setAttribute("end", end);
58827
58828      const controlBar = document.createElement("media-control-bar");
58829
58830      const playBtn = document.createElement("media-play-button");
58831      const timeDisplay = document.createElement("media-time-display");
58832      timeDisplay.setAttribute("showduration", "");
58833      const timeRange = document.createElement("media-time-range");
58834      const playbackRate = document.createElement("media-playback-rate-button");
58835      const muteBtn = document.createElement("media-mute-button");
58836      const volumeRange = document.createElement("media-volume-range");
58837
58838      const captionsBtn = document.createElement("media-captions-button");
58839      captionsBtn.addEventListener("click", (e) => {
58840        e.stopPropagation();
58841        const provider = container.querySelector("youtube-media-provider");
58842        if (provider) provider.toggleCaptions();
58843      });
58844
58845      const qualityBtn = document.createElement("media-chrome-button");
58846      qualityBtn.textContent = "--";
58847      qualityBtn.addEventListener("click", () => {
58848        window.open(`https://www.youtube.com/watch?v=${videoId}`, "_blank");
58849      });
58850
58851      const fullscreenBtn = document.createElement("media-fullscreen-button");
58852
58853      controlBar.append(
58854        playBtn, timeDisplay, timeRange,
58855        playbackRate, muteBtn, volumeRange,
58856        captionsBtn, qualityBtn, fullscreenBtn
58857      );
58858
58859      const poster = document.createElement("media-poster-image");
58860      poster.setAttribute("slot", "poster");
58861      poster.setAttribute("src", `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`);
58862
58863      controller.append(provider, poster, controlBar);
58864
58865      captionsBtn.setAttribute("aria-checked", "true");
58866      controller.setAttribute("mediasubtitlesshowing", "on");
58867
58868      container.appendChild(controller);
58869      details.appendChild(container);
58870
58871      provider.addEventListener("waiting", () => container.classList.add("loading"));
58872      provider.addEventListener("playing", () => container.classList.remove("loading"));
58873      provider.addEventListener("play", () => container.classList.remove("loading"));
58874
58875      provider._textTracks.addEventListener("change", () => {
58876        const enabled = provider._captionsEnabled;
58877        captionsBtn.setAttribute("aria-checked", enabled);
58878        if (enabled) {
58879          controller.setAttribute("mediasubtitlesshowing", "on");
58880        } else {
58881          controller.removeAttribute("mediasubtitlesshowing");
58882        }
58883      });
58884
58885      provider.addEventListener("qualitychange", (e) => {
58886        qualityBtn.textContent = e.detail.quality;
58887      });
58888    }
58889
58890    function initLazyPlayers() {
58891      document.querySelectorAll("details[data-video-id]").forEach((details) => {
58892        if (details.dataset.lazyInit) return;
58893        details.dataset.lazyInit = "true";
58894        details.addEventListener("toggle", () => {
58895          if (details.open && !details.querySelector(".player-container")) {
58896            createPlayer(details);
58897          } else if (!details.open) {
58898            const provider = details.querySelector("youtube-media-provider");
58899            if (provider && typeof provider.pause === "function") {
58900              provider.pause();
58901            }
58902          }
58903        });
58904      });
58905    }
58906
58907    if (document.readyState === "loading") {
58908      document.addEventListener("DOMContentLoaded", initLazyPlayers);
58909    } else {
58910      initLazyPlayers();
58911    }
58912  })();
58913</script>
58913<script>
58914  (() => {
58915    if (window.__courseCompletionsInitialized) return;
58916    window.__courseCompletionsInitialized = true;
58917
58918    const STORAGE_KEY = "course-completions";
58919
58920    function slugify(text) {
58921      return text.toString().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
58922    }
58923
58924    function getCompletions() {
58925      try {
58926        return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
58927      } catch {
58928        return [];
58929      }
58930    }
58931
58932    function saveCompletions(completions) {
58933      localStorage.setItem(STORAGE_KEY, JSON.stringify(completions));
58934    }
58935
58936    // Migra IDs legacy (0-based o con título sin slugificar) a formato canónico
58937    function canonicalFromStored(storedId) {
58938      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58939      if (!m) return slugify(storedId);
58940      const [, slug, p, mv, title] = m;
58941      return `${slug}-${p}-${mv}-${slugify(title)}`;
58942    }
58943
58944    function canonicalPlusOne(storedId) {
58945      const m = storedId.match(/^(.+)-(\d+)-(\d+)-(.+)$/);
58946      if (!m) return null;
58947      const [, slug, p, mv, title] = m;
58948      return `${slug}-${Number(p) + 1}-${Number(mv) + 1}-${slugify(title)}`;
58949    }
58950
58951    function isCompleted(canonicalId, completions) {
58952      if (completions.includes(canonicalId)) return true;
58953      // Comprueba variantes legacy: mismo título slugificado pero con índices -1, o título raw
58954      return completions.some((c) => canonicalFromStored(c) === canonicalId || canonicalPlusOne(c) === canonicalId);
58955    }
58956
58957    function migrateCompletions() {
58958      const completions = getCompletions();
58959      let changed = false;
58960      const migrated = completions.map((c) => {
58961        const canon = canonicalFromStored(c);
58962        // Si el canónico coincide con un botón real, úsalo; si el +1 coincide, úsalo
58963        const plusOne = canonicalPlusOne(c);
58964        if (plusOne && document.querySelector(`.complete-btn[data-completion-id="${plusOne}"]`)) {
58965          if (plusOne !== c) changed = true;
58966          return plusOne;
58967        }
58968        if (canon !== c && document.querySelector(`.complete-btn[data-completion-id="${canon}"]`)) {
58969          changed = true;
58970          return canon;
58971        }
58972        // Fallback: si el título tenía mayúsculas/espacios, slugifícalo
58973        if (canon !== c) {
58974          changed = true;
58975          return canon;
58976        }
58977        return c;
58978      });
58979      // Elimina duplicados tras migración
58980      const deduped = [...new Set(migrated)];
58981      if (changed || deduped.length !== completions.length) {
58982        saveCompletions(deduped);
58983        return deduped;
58984      }
58985      return completions;
58986    }
58987
58988    function updateButtonState(btn, completions) {
58989      const id = btn.dataset.completionId;
58990      const list = completions ?? getCompletions();
58991      btn.classList.toggle("completed", isCompleted(id, list));
58992    }
58993
58994    function checkModuleCompletion(completions) {
58995      const list = completions ?? getCompletions();
58996      document.querySelectorAll(".course-video").forEach((module) => {
58997        const btns = module.querySelectorAll(".complete-btn");
58998        const allCompleted = btns.length > 0 && [...btns].every((btn) => isCompleted(btn.dataset.completionId, list));
58999        const check = module.querySelector(".module-check");
59000        if (check) check.classList.toggle("visible", allCompleted);
59001      });
59002    }
59003
59004    function initCompletionButtons() {
59005      const completions = migrateCompletions();
59006
59007      document.querySelectorAll(".complete-btn").forEach((btn) => {
59008        if (btn.dataset.completionInit) return;
59009        btn.dataset.completionInit = "true";
59010        updateButtonState(btn, completions);
59011
59012        btn.addEventListener("click", (e) => {
59013          e.preventDefault();
59014          e.stopPropagation();
59015
59016          const id = btn.dataset.completionId;
59017          let list = getCompletions();
59018          const completed = isCompleted(id, list);
59019
59020          if (completed) {
59021            // Elimina canónico y cualquier variante legacy que mapee al mismo id
59022            list = list.filter((c) =>
59022 canonicalFromStored(c) !== id && canonicalPlusOne(c) !== id && c !== id);
59023          } else {
59024            list.push(id);
59025          }
59026
59027          saveCompletions(list);
59028          updateButtonState(btn, list);
59029          checkModuleCompletion(list);
59030        });
59031      });
59032
59033      checkModuleCompletion(completions);
59034    }
59035
59036    if (document.readyState === "loading") {
59037      document.addEventListener("DOMContentLoaded", initCompletionButtons);
59038    } else {
59039      initCompletionButtons();
59040    }
59041  })();
59042</script>
59042</div></details></div></details></main>
59042<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome@4/+esm"></script>
59042<script src="https://www.youtube.com/iframe_api"></script>
59042<script type="module" src="/components/YoutubeMediaProvider.js"></script>
59042<footer data-astro-cid-nns7i3if><div class="wrapper center column" data-astro-cid-nns7i3if><a href="/" data-astro-cid-nns7i3if><span class="start" data-astro-cid-nns7i3if></span><img class="logo" src="/assets/logo-small.webp" alt="Manz.dev" data-astro-cid-nns7i3if><span class="end" data-astro-cid-nns7i3if></span></a><p data-astro-cid-nns7i3if>Creado con 💖 en <a href="https://astro.build/" data-astro-cid-nns7i3if>Astro</a>, <a href="https://lenguajehtml.com/" data-astro-cid-nns7i3if>HTML</a>, <a href="https://lenguajecss.com/" data-astro-cid-nns7i3if>CSS</a> y <a href="https://lenguajejs.com/" data-astro-cid-nns7i3if>JS</a></p></div></footer>
59042<script type="module" src="/components/LiveStream.js"></script>
59042<script type="module">var e=document.createElement(`live-stream`);e.setAttribute(`user`,`ManzDev`),document.querySelector(`main.wrapper`)?.prepend(e);</script>
59042</body></html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.