Overview
The frontend is a vanilla JavaScript single-page application (SPA) with no framework dependencies. It uses ES6 modules for organization and a custom hash-based router for navigation.
Architecture Principles
Zero Dependencies No React, Vue, or framework overhead - pure JavaScript for fast load times
Modular Design ES6 modules with clear separation: services, views, components
Progressive Loading Bootstrap data first, course details loaded on-demand
Offline-First Progress LocalStorage primary, server sync secondary
Module Structure
js/
├── app_v2.js # Application entry point
├── router.js # Hash-based SPA router
├── services/
│ ├── api.js # Backend API client
│ └── state.js # Global state management
├── views/
│ ├── home.js # Dashboard with stats
│ ├── explore.js # Browse all courses
│ ├── learning.js # In-progress courses
│ ├── route.js # Learning path detail
│ ├── course.js # Course modules view
│ └── player.js # Video player + navigation
└── components/
├── navbar.js # Top navigation bar
└── card.js # Course/route cards
Application Lifecycle
Bootstrap Flow
From js/app_v2.js:30-62:
document . addEventListener ( 'DOMContentLoaded' , async () => {
console . log ( '🚀 Initializing Platzi Viewer 2.0...' );
const app = document . getElementById ( 'app' );
// Show loading while data loads
app . innerHTML = `
<div class="loading">
<div class="loading-spinner"></div>
<p>Cargando plataforma...</p>
</div>
` ;
try {
await state . init ();
} catch ( error ) {
// Display error with diagnostic info
const code = error ?. code || 'bootstrap_unavailable' ;
// ... error UI
return ;
}
// Build layout shell
app . innerHTML = `
${ Navbar . render () }
<div id="main-content"></div>
` ;
// Start Router
const router = new Router ( routes );
router . appContainer = document . getElementById ( 'main-content' );
router . handleRoute ();
});
Router System
Route Definition
From js/app_v2.js:11-18:
const routes = {
'#home' : HomeView ,
'#explore' : ExploreView ,
'#learning' : LearningView ,
'#route/:catIdx/:routeIdx' : RouteView ,
'#course/:catIdx/:routeIdx/:courseIdx' : CourseView ,
'#player/:catIdx/:routeIdx/:courseIdx/:modIdx/:classIdx' : PlayerView ,
};
Route Matching
From js/router.js:31-69:
async handleRoute () {
const hash = window . location . hash || '#home' ;
let match = null ;
let route = null ;
let params = {};
for ( const [ path , viewClass ] of Object . entries ( this . routes )) {
// Convert route pattern to regex
// #course/:catIdx/:routeIdx/:courseIdx -> ^#course/([^/]+)/([^/]+)/([^/]+)$
const regexPath = '^' + path . replace ( /: [ ^ \s/ ] + / g , '([^/]+)' ) + '$' ;
const regex = new RegExp ( regexPath );
const found = hash . match ( regex );
if ( found ) {
match = found ;
route = viewClass ;
// Extract param names and values
const paramNames = ( path . match ( /: [ ^ \s/ ] + / g ) || []). map ( n => n . slice ( 1 ));
paramNames . forEach (( name , index ) => {
params [ name ] = decodeURIComponent ( found [ index + 1 ]);
});
break ;
}
}
if ( ! route ) {
// Default to home
window . location . hash = '#home' ;
return ;
}
this . renderView ( route , params );
}
The router uses hash-based routing (#home, #course/0/1/2) to avoid server-side routing complexity. This works seamlessly with Python’s static file serving.
View Rendering
From js/router.js:72-107:
async renderView ( ViewClass , params ) {
this . _stopAllMediaPlayback (); // Clean up video elements
if ( this . currentView && this . currentView . destroy ) {
this . currentView . destroy (); // Lifecycle cleanup
}
// Show loading
this . appContainer . innerHTML = `
<div class="loading">
<div class="loading-spinner"></div>
</div>
` ;
try {
this . currentView = new ViewClass ( params );
const html = await this . currentView . render ();
this . appContainer . innerHTML = html ;
if ( this . currentView . mounted ) {
this . currentView . mounted (); // Post-render lifecycle
}
} catch ( error ) {
console . error ( 'Error rendering view:' , error );
// ... error UI
}
window . scrollTo ( 0 , 0 );
}
State Management
The StateService class provides centralized data and progress management.
State Structure
class StateService {
coursesData = null ; // Bootstrap catalog
progress = {}; // Class completion status
listeners = []; // Progress subscribers
_courseDetailLoads = new Map (); // In-flight detail requests
initWarnings = []; // Non-fatal bootstrap errors
}
Progress Tracking
From js/services/state.js:236-256:
getClassKey ( catIdx , routeIdx , courseIdx , modIdx , classIdx ) {
return ` ${ catIdx } | ${ routeIdx } | ${ courseIdx } | ${ modIdx } | ${ classIdx } ` ;
}
markClassComplete ( classKey ) {
this . progress [ classKey ] = {
status: 'complete' ,
completedAt: new Date (). toISOString ()
};
this . saveProgress ();
}
markClassInProgress ( classKey , time = 0 ) {
const current = this . progress [ classKey ];
if ( ! current || current . status !== 'complete' ) {
this . progress [ classKey ] = {
status: 'in_progress' ,
lastWatched: new Date (). toISOString (),
watchTime: time
};
this . saveProgress ();
}
}
Progress Persistence
From js/services/state.js:95-112:
queueServerSync () {
if ( this . _syncTimer ) {
clearTimeout ( this . _syncTimer );
}
this . _syncTimer = setTimeout (() => {
this . _syncTimer = null ;
this . saveServerProgress ();
}, 400 ); // Debounced sync (400ms)
}
async saveServerProgress () {
try {
await ApiService . saveProgress ( this . progress );
} catch ( e ) {
console . warn ( 'Error saving server progress:' , e ?. code || e );
}
}
Progress is saved locally first , then synced to the server after a 400ms debounce. This ensures UI responsiveness even if the server is slow or offline.
On-Demand Course Details
From js/services/state.js:173-223:
async ensureCourseDetail ( catIdx , routeIdx , courseIdx = 0 ) {
const course = this . getCourse ( catIdx , routeIdx , courseIdx );
const route = this . getRoute ( catIdx , routeIdx );
if ( ! course || ! route ) return null ;
if ( this . isCourseDetailLoaded ( course )) return course ;
const key = ` ${ catIdx } | ${ routeIdx } | ${ courseIdx } ` ;
if ( this . _courseDetailLoads . has ( key )) {
return this . _courseDetailLoads . get ( key ); // Dedup concurrent requests
}
const loadPromise = ( async () => {
const payload = await ApiService . getCourseDetail ( catIdx , routeIdx , courseIdx , 2 );
const detail = payload ?. course ;
if ( ! detail ) throw new Error ( 'course_detail_invalid_payload' );
// Merge detail into in-memory cache
if ( route . isCourse ) {
cat . routes [ routeIdx ] = detail ;
} else {
courses [ courseIdx ] = detail ;
cat . routes [ routeIdx ]. courses = courses ;
}
return this . getCourse ( catIdx , routeIdx , courseIdx );
})();
this . _courseDetailLoads . set ( key , loadPromise );
try {
return await loadPromise ;
} finally {
this . _courseDetailLoads . delete ( key );
}
}
Course details are loaded lazily when users navigate to a course page. The bootstrap endpoint only includes course summaries (module counts, class counts) to minimize initial load time.
API Service
Error Handling
From js/services/api.js:6-28:
static _buildError ( code , cause = null ) {
const error = new Error ( code );
error . code = code ;
if ( cause ) error . cause = cause ;
return error ;
}
static async _fetchWithTimeout ( url , options = {}, timeoutMs = DEFAULT_TIMEOUT_MS ) {
const controller = new AbortController ();
const timer = window . setTimeout (() => controller . abort (), timeoutMs );
try {
return await fetch ( url , {
... options ,
signal: controller . signal ,
});
} catch ( error ) {
if ( error ?. name === 'AbortError' ) {
throw this . _buildError ( 'request_timeout' , error );
}
throw error ;
} finally {
window . clearTimeout ( timer );
}
}
Retry Logic
From js/services/api.js:71-96:
static async getBootstrap ( retries = 8 ) {
let lastError = null ;
for ( let i = 0 ; i < retries ; i ++ ) {
try {
const response = await this . _fetchWithTimeout (
` ${ API_URL } /api/bootstrap` ,
{ cache: 'no-store' },
10000
);
if ( ! response . ok ) throw this . _buildError ( `bootstrap_http_ ${ response . status } ` );
const data = await response . json ();
if ( data ?. categories ?. length > 0 ) {
console . log ( '🚀 Bootstrap loaded:' , data . stats );
return data ;
}
lastError = this . _buildError ( 'bootstrap_empty' );
await this . _wait ( RETRY_DELAY_MS ); // 1200ms delay
} catch ( error ) {
lastError = error ?. code ? error : this . _buildError ( 'bootstrap_network' , error );
if ( i === retries - 1 ) break ;
await this . _wait ( RETRY_DELAY_MS );
}
}
throw lastError || this . _buildError ( 'bootstrap_unavailable' );
}
View Components
View Lifecycle
Each view class follows this pattern:
class ExampleView {
constructor ( params ) {
// Initialize with route params
this . catIdx = parseInt ( params . catIdx );
}
async render () {
// Fetch required data
const course = await state . ensureCourseDetail ( ... );
// Return HTML string
return `<div>...</div>` ;
}
mounted () {
// Post-render: attach event listeners, start video, etc.
this . attachEventListeners ();
}
destroy () {
// Cleanup: remove listeners, stop timers, etc.
clearInterval ( this . timer );
}
}
Player View Architecture
The player view (js/views/player.js) is the most complex component:
export default class PlayerView {
constructor ( params ) {
this . catIdx = parseInt ( params . catIdx );
this . routeIdx = parseInt ( params . routeIdx );
this . courseIdx = parseInt ( params . courseIdx );
this . modIdx = parseInt ( params . modIdx );
this . classIdx = parseInt ( params . classIdx );
this . _videoEl = null ;
this . _isCompatibilityModeActive = false ;
this . _syncIssueResyncHits = 0 ;
// ... extensive state for A/V sync detection
}
}
Key Features:
Uses requestVideoFrameCallback to detect audio/video desync issues and prompt users to switch to VLC or FFmpeg compatibility mode.
Video element issues Range requests for efficient seeking. The backend proxies these to Google Drive with proper Range headers.
Space: Play/pause
Left/Right: Seek ±10s
M: Mute/unmute
F: Fullscreen
Marks class as complete when video ends, saves progress every few seconds during playback.
Component System
Navbar Component
From js/components/navbar.js:
export const Navbar = {
render () {
return `
<nav class="navbar">
<a href="#home" class="nav-link">🏠 Inicio</a>
<a href="#explore" class="nav-link">🔍 Explorar</a>
<a href="#learning" class="nav-link">📚 Aprendiendo</a>
</nav>
` ;
},
updateActive () {
document . querySelectorAll ( '.nav-link' ). forEach ( link => {
const href = link . getAttribute ( 'href' );
const isActive = window . location . hash . startsWith ( href );
link . classList . toggle ( 'active' , isActive );
});
},
init () {
window . addEventListener ( 'hashchange' , () => this . updateActive ());
}
};
Lazy Loading Course details loaded only when viewing a course page, reducing initial bundle size
Debounced Sync Progress synced to server after 400ms idle period, avoiding excessive writes
Request Deduplication Concurrent course detail requests for same course return shared promise
LocalStorage First Progress reads/writes hit localStorage immediately, server sync is async
Error Handling Strategy
Structured Error Codes
All errors include a code property for precise debugging:
// Examples:
'bootstrap_http_500' // Server returned 500
'bootstrap_empty' // Response had no categories
'bootstrap_network' // Network failure
'course_detail_not_found' // 404 from API
'request_timeout' // AbortController timeout
User-Facing Errors
From js/app_v2.js:44-61:
try {
await state . init ();
} catch ( error ) {
const code = error ?. code || 'bootstrap_unavailable' ;
const endpoint = inferEndpointFromErrorCode ( code );
app . innerHTML = `
<div class="loading">
<div style="font-size: 3rem">⚠️</div>
<p>Error al conectar con el servidor</p>
<p style="font-size: 0.85rem">
Código: <code> ${ code } </code>
• Endpoint: <code> ${ endpoint } </code>
</p>
<p>Verifica diagnóstico en: <code>/api/cache-meta</code></p>
</div>
` ;
return ;
}
Next Steps
Backend Architecture Learn about Python server implementation
Google Drive Integration Understand Drive API streaming