Skip to main content

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

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());
    }
};

Performance Optimizations

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

Build docs developers (and LLMs) love