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><canvas></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><div></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><div></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 "Voy a usar IA"</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.