Ravindra BagaleCourses & study guides

16. Live Project: Building a Reels App with EC2, S3 and RDS

16.9 The Reel UI: HTML, CSS and JavaScript

Reel cha "magic" CSS madhe aahe: scroll-snap-type: y mandatory aani pratyek reel height: 100dvh – swipe kela ki ek purna reel snap hoto. JavaScript IntersectionObserver ne je reel 60% disat aahe te play karto aani baki pause.

public/index.php

<?php
// public/index.php – the reel page shell; posts are loaded by assets/reels.js from feed.php
require __DIR__ . '/../src/bootstrap.php';
$user = require_login();
?>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Mitrano Reels</title>
  <link rel="stylesheet" href="/assets/reels.css">
</head>
<body>
  <header class="topbar">
    <span class="brand">Mitrano Reels</span>
    <span class="who">@<?= e($user['username']) ?></span>
    <a class="btn" href="/upload.php">+ Post</a>
    <form method="post" action="/logout.php">
      <input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
      <button class="btn ghost" type="submit">Logout</button>
    </form>
  </header>
  <main id="feed" class="feed" data-csrf="<?= e(csrf_token()) ?>" aria-live="polite"></main>
  <script src="/assets/reels.js" defer></script>
</body>
</html>

public/assets/reels.css

/* public/assets/reels.css – full-screen vertical reels with CSS scroll-snap */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #000; color: #fff;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }

.topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex;
  align-items: center; gap: 10px; padding: 10px 14px;
  background: linear-gradient(#000c, #0000); }
.brand { font-weight: 700; flex: 1; }
.who { opacity: .8; font-size: 14px; }
.btn { background: #0E7C86; color: #fff; border: 0; border-radius: 18px;
  padding: 6px 14px; font-size: 14px; text-decoration: none; cursor: pointer; }
.btn.ghost { background: #fff2; }

.feed { height: 100dvh; overflow-y: scroll; scroll-snap-type: y mandatory;
  scrollbar-width: none; }
.feed::-webkit-scrollbar { display: none; }

.reel { position: relative; height: 100dvh; width: 100%; max-width: 480px;
  margin: 0 auto; scroll-snap-align: start; scroll-snap-stop: always;
  overflow: hidden; background: #111; }
.reel video { width: 100%; height: 100%; object-fit: cover; display: block; }
.reel .text { height: 100%; display: flex; align-items: center; justify-content: center;
  padding: 32px; font-size: 26px; line-height: 1.35; text-align: center;
  white-space: pre-wrap; overflow-wrap: anywhere; }

.meta { position: absolute; left: 14px; right: 70px; bottom: 24px;
  text-shadow: 0 1px 3px #000; }
.meta .user { font-weight: 700; margin-bottom: 4px; }
.meta .cap { font-size: 14px; overflow-wrap: anywhere; }

.actions { position: absolute; right: 12px; bottom: 90px; display: flex;
  flex-direction: column; gap: 18px; align-items: center; }
.actions button { background: #0006; border: 0; color: #fff; width: 48px; height: 48px;
  border-radius: 50%; font-size: 22px; cursor: pointer; }
.actions .count { font-size: 13px; margin-top: -12px; }
.like.on { color: #ff3b5c; }

.status { height: 100dvh; display: flex; align-items: center; justify-content: center;
  scroll-snap-align: start; opacity: .7; }

/* simple pages: login, register, upload */
body.page { background: #0b1d26; overflow: auto; padding: 24px 12px; }
.box { max-width: 420px; margin: 0 auto; padding: 24px; background: #122b36;
  border-radius: 12px; }
.box h1 { margin-bottom: 16px; } .box h2 { margin: 18px 0 8px; font-size: 18px; }
.box label { display: block; margin: 10px 0; font-size: 14px; }
.box input, .box textarea, .box select { width: 100%; margin-top: 4px; padding: 8px;
  border-radius: 6px; border: 1px solid #2c5566; background: #0b1d26; color: #fff; }
.box button { margin-top: 8px; background: #0E7C86; color: #fff; border: 0;
  padding: 9px 18px; border-radius: 6px; cursor: pointer; }
.box a { color: #7fd6dd; } .box p { margin-top: 12px; }
.err { background: #5c1a1a; padding: 8px 10px; border-radius: 6px; }

public/assets/reels.js

// public/assets/reels.js – loads feed.php, builds reels safely, autoplays the visible one
(() => {
  const feed = document.getElementById('feed');
  const csrf = feed.dataset.csrf;
  let nextBefore = null, loading = false, finished = false;

  // play the reel that is at least 60% visible, pause all others
  const player = new IntersectionObserver(entries => {
    for (const en of entries) {
      const v = en.target.querySelector('video');
      if (!v) continue;
      if (en.isIntersecting) { v.play().catch(() => {}); } else { v.pause(); }
    }
  }, { threshold: 0.6 });

  // when the sentinel (near the end) becomes visible, load the next page
  const sentinel = document.createElement('div');
  sentinel.className = 'status';
  sentinel.textContent = 'Loading...';
  const more = new IntersectionObserver(en => { if (en[0].isIntersecting) load(); },
                                        { root: feed, rootMargin: '200% 0px' });

  function el(tag, cls, text) {
    const n = document.createElement(tag);
    if (cls) n.className = cls;
    if (text !== undefined) n.textContent = text;   // textContent, never innerHTML: no XSS
    return n;
  }

  function buildReel(p) {
    const reel = el('section', 'reel');
    reel.dataset.id = p.id;
    if (p.type === 'video') {
      const v = el('video');
      v.src = p.video_url;                            // presigned S3 URL, valid for minutes
      v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'metadata';
      v.addEventListener('click', () => { v.muted = !v.muted; });   // tap to unmute
      reel.append(v);
    } else {
      const t = el('div', 'text', p.body_text);
      if (/^#[0-9A-Fa-f]{6}$/.test(p.bg_color)) t.style.background = p.bg_color;
      reel.append(t);
    }
    const meta = el('div', 'meta');
    meta.append(el('div', 'user', '@' + p.username), el('div', 'cap', p.caption || ''));
    const actions = el('div', 'actions');
    const like = el('button', 'like' + (p.liked ? ' on' : ''), '\u2665');
    like.setAttribute('aria-label', 'Like');
    const count = el('div', 'count', String(p.likes));
    like.addEventListener('click', () => toggleLike(p.id, like, count));
    actions.append(like, count);
    reel.append(meta, actions);
    return reel;
  }

  async function toggleLike(id, btn, count) {
    const body = new URLSearchParams({ post_id: id });
    const res = await fetch('/like.php', { method: 'POST', body,
      headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
    if (!res.ok) return;
    const data = await res.json();
    btn.classList.toggle('on', data.liked);
    count.textContent = String(data.likes);
  }

  async function load() {
    if (loading || finished) return;
    loading = true;
    const firstPage = nextBefore === null;
    try {
      const url = '/feed.php' + (nextBefore ? '?before=' + encodeURIComponent(nextBefore) : '');
      const res = await fetch(url, { credentials: 'same-origin' });
      if (res.status === 401) { location.href = '/login.php'; return; }
      const data = await res.json();
      for (const p of data.posts) {
        const r = buildReel(p);
        feed.insertBefore(r, sentinel);
        player.observe(r);
      }
      if (firstPage) feed.scrollTo(0, 0);          // keep the first reel snapped on screen
      nextBefore = data.next_before;
      if (!nextBefore) {
        finished = true;
        more.disconnect();
        sentinel.textContent = feed.querySelector('.reel')
          ? 'You are all caught up!' : 'No posts yet - add the first one!';
      }
    } catch (err) {
      sentinel.textContent = 'Could not load the feed. Refresh to retry.';
    } finally {
      loading = false;
    }
  }

  feed.append(sentinel);
  more.observe(sentinel);
  load();
})();
UI technique Why
scroll-​snap-​type: y mandatory + scroll-​snap-​align: start One reel per swipe, like mobile apps
100dvh (dynamic viewport height) Correct height on mobile when the address bar hides
muted + playsInline + play().catch() Browsers allow autoplay only for muted videos; tap to unmute
IntersectionObserver (60% visible) Only the visible reel plays – saves data and battery
Sentinel + ?before= Infinite scroll, loads the next 5 posts before you reach the end
textContent, never innerHTML User captions cannot inject HTML or scripts

Why this matters for security

A caption like <img src=x onerror=alert(1)> is harmless here because textContent treats it as plain text. The Content-Security-Policy header (next section) is a second layer: even if a bug let HTML in, inline scripts would not run and media could load only from your own site and your bucket.

Ravindra Bagale's Tip

Autoplay chalat nahi mhanun students code badalat rahtat – pan browsers sound sobat autoplay block kartat. Video muted theva aani user tap kela ki unmute kara. Aani iPhone var playsInline visarla tar video full-screen madhe ughadto. He don gunthe lakshat theva.

Lab

Open the app on your phone, swipe through at least six posts (so a second page loads), tap to unmute, and like a post. In DevTools → Network, watch feed.php?before=... appear as you scroll.