Generate.js Full Version

Siap. Ini saya bikinkan generate.js full version yang sudah digabung semua, biar kamu tinggal replace dan pakai.

Fitur yang sudah masuk:

  • baca file dari folder media
  • support:
    • mp4
    • m3u8
    • image (jpg, jpeg, png, webp)
  • kalau ada R2_PUBLIC_BASE_URLsrc dan thumb pakai URL R2
  • kalau tidak ada → fallback ke media/...
  • auto ambil:
    • size
    • duration untuk mp4
  • auto parse filename jadi:
    • category
    • categorySlug
    • title
    • slug
    • tags
  • image yang dipakai sebagai thumbnail video/stream tidak dimasukkan lagi sebagai item gallery terpisah

Format nama file yang didukung

Format yang disarankan:

Category Name__Judul Video__tag1-tag2-tag3.mp4

Contoh:

Social Media__cewek manis dance__viral-fyp.mp4
Social Media__cewek manis dance__viral-fyp.jpg
TikTok__kucing lucu tidur__funny-hewan.mp4
Poster__banner promosi.webp

Kalau file tidak pakai format lengkap, script tetap jalan.
Dia akan fallback ke:

  • category: "Uncategorized"
  • title: nama file

Replace seluruh isi generate.js dengan ini

import "dotenv/config";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";

const publicBase = process.env.R2_PUBLIC_BASE_URL || "";
const mediaDir = "./media";
const output = "./data.json";

const files = fs.readdirSync(mediaDir);
const data = [];

function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/\s+/g, "-")
    .replace(/[^a-z0-9\-]/g, "")
    .replace(/\-+/g, "-");
}

function makeMediaUrl(fileName) {
  return publicBase ? `${publicBase}/${fileName}` : `media/${fileName}`;
}

function getFileSize(filePath) {
  try {
    return fs.statSync(filePath).size;
  } catch {
    return null;
  }
}

function getVideoDuration(filePath) {
  try {
    const result = execSync(
      `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`
    )
      .toString()
      .trim();

    return Math.round(Number(result));
  } catch {
    return null;
  }
}

function parseFileMeta(fileName) {
  const ext = path.extname(fileName);
  const base = path.basename(fileName, ext);

  const parts = base.split("__");

  let category = "Uncategorized";
  let title = base;
  let tags = [];

  if (parts.length >= 2) {
    category = parts[0].trim() || "Uncategorized";
    title = parts[1].trim() || base;
  } else if (parts.length === 1) {
    title = base;
  }

  if (parts.length >= 3) {
    tags = parts[2]
      .split("-")
      .map(tag => tag.trim())
      .filter(Boolean);
  }

  return {
    category,
    categorySlug: slugify(category),
    title,
    slug: slugify(title),
    tags
  };
}

function findMatchingThumb(name) {
  return files.find(f => {
    const thumbExt = path.extname(f).toLowerCase();
    const thumbName = path.basename(f, thumbExt);

    return (
      thumbName === name &&
      [".jpg", ".jpeg", ".png", ".webp"].includes(thumbExt)
    );
  });
}

files.forEach(file => {
  const ext = path.extname(file).toLowerCase();
  const name = path.basename(file, ext);
  const fullPath = path.join(mediaDir, file);
  const meta = parseFileMeta(file);

  // MP4 VIDEO
  if (ext === ".mp4") {
    const thumb = findMatchingThumb(name);

    data.push({
      title: meta.title,
      category: meta.category,
      categorySlug: meta.categorySlug,
      tags: meta.tags,
      slug: meta.slug,
      type: "mp4",
      kind: "video",
      src: makeMediaUrl(file),
      thumb: thumb
        ? makeMediaUrl(thumb)
        : "https://dummyimage.com/300x200/000/fff&text=No+Thumb",
      duration: getVideoDuration(fullPath),
      size: getFileSize(fullPath)
    });
  }

  // HLS / M3U8 STREAM
  else if (ext === ".m3u8") {
    const thumb = findMatchingThumb(name);

    data.push({
      title: meta.title,
      category: meta.category,
      categorySlug: meta.categorySlug,
      tags: meta.tags,
      slug: meta.slug,
      type: "m3u8",
      kind: "stream",
      src: makeMediaUrl(file),
      thumb: thumb
        ? makeMediaUrl(thumb)
        : "https://dummyimage.com/300x200/000/fff&text=Stream",
      size: getFileSize(fullPath)
    });
  }

  // IMAGE
  else if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
    const isUsedAsThumb = files.some(f => {
      const mediaExt = path.extname(f).toLowerCase();
      const mediaName = path.basename(f, mediaExt);

      return mediaName === name && [".mp4", ".m3u8"].includes(mediaExt);
    });

    if (!isUsedAsThumb) {
      data.push({
        title: meta.title,
        category: meta.category,
        categorySlug: meta.categorySlug,
        tags: meta.tags,
        slug: meta.slug,
        type: "image",
        kind: "image",
        src: makeMediaUrl(file),
        thumb: makeMediaUrl(file),
        size: getFileSize(fullPath)
      });
    }
  }
});

// optional: sort by title biar stabil
data.sort((a, b) => a.title.localeCompare(b.title));

fs.writeFileSync(output, JSON.stringify(data, null, 2));
console.log("data.json generated with full metadata!");

Contoh hasil

Kalau kamu punya file:

Social Media__cewek manis dance__viral-fyp.mp4
Social Media__cewek manis dance__viral-fyp.jpg

maka hasil data.json jadi kira-kira:

{
  "title": "cewek manis dance",
  "category": "Social Media",
  "categorySlug": "social-media",
  "tags": ["viral", "fyp"],
  "slug": "cewek-manis-dance",
  "type": "mp4",
  "kind": "video",
  "src": "https://cdn.yadisini.com/Social Media__cewek manis dance__viral-fyp.mp4",
  "thumb": "https://cdn.yadisini.com/Social Media__cewek manis dance__viral-fyp.jpg",
  "duration": 11,
  "size": 2544791
}

Cara pakai

Jalankan:

node generate.js

atau kalau sudah pakai script npm:

npm run generate

Catatan penting

Untuk pairing thumbnail, nama file thumbnail harus persis sama dengan nama video, hanya beda ekstensi.

Contoh benar:

Social Media__cewek manis dance__viral-fyp.mp4
Social Media__cewek manis dance__viral-fyp.jpg

Contoh tidak akan kepair otomatis:

Social Media__cewek manis dance__viral-fyp.mp4
thumb-cewek.jpg

Setelah ini langkah terbaik

Setelah generate.js full ini jalan, next paling cocok adalah:

auto category filter dari data.json, jadi tombol kategori seperti Social Media, TikTok, Reels muncul otomatis tanpa hardcode.

Kalau mau, next saya bikinkan app.js full version yang support:

  • filter type
  • filter category otomatis
  • badge
  • duration
  • tetap support player kamu sekarang.

Gas. Berikutnya yang paling pas memang:

Auto category filter dari data.json

Jadi nanti filter tidak hardcoded lagi.
Kalau ada category:

  • Social Media
  • TikTok
  • Reels

maka tombolnya muncul otomatis.

Ini jauh lebih rapi daripada edit HTML manual tiap kali.


Target step ini

Kita bikin:

  • filter type tetap ada:
    • All
    • Video
    • Stream
    • Image
  • category dari data.json dibuat otomatis
  • tetap support:
    • badge
    • duration
    • player
    • HLS
    • multi-source kalau nanti dipakai

1. Update index.html

Ganti bagian filter lama:

<div id="filters" class="filters">
  <button class="filter-btn active" data-filter="all">All</button>
  <button class="filter-btn" data-filter="video">Video</button>
  <button class="filter-btn" data-filter="stream">Stream</button>
  <button class="filter-btn" data-filter="image">Image</button>
</div>

menjadi ini:

<div id="typeFilters" class="filters"></div>
<div id="categoryFilters" class="filters"></div>

<div id="gallery"></div>

2. Replace seluruh app.js

Pakai versi full ini:

const gallery = document.getElementById("gallery");
const modal = document.getElementById("playerModal");
const player = document.getElementById("player");
const typeFilters = document.getElementById("typeFilters");
const categoryFilters = document.getElementById("categoryFilters");

let currentHls = null;
let allItems = [];
let currentTypeFilter = "all";
let currentCategoryFilter = "all";

function formatDuration(seconds) {
  if (!seconds || isNaN(seconds)) return "";

  const hrs = Math.floor(seconds / 3600);
  const mins = Math.floor((seconds % 3600) / 60);
  const secs = Math.floor(seconds % 60);

  if (hrs > 0) {
    return `${hrs}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
  }

  return `${mins}:${String(secs).padStart(2, "0")}`;
}

function getBadgeText(item) {
  if (item.kind === "video") return "VIDEO";
  if (item.kind === "stream") return "STREAM";
  if (item.kind === "image") return "IMAGE";
  return item.type ? item.type.toUpperCase() : "MEDIA";
}

function destroyHls() {
  if (currentHls) {
    currentHls.destroy();
    currentHls = null;
  }
}

function renderGallery(items) {
  gallery.innerHTML = "";

  if (!items.length) {
    gallery.innerHTML = `<p>Tidak ada item untuk filter ini.</p>`;
    return;
  }

  items.forEach(item => {
    const div = document.createElement("div");
    div.className = "card";

    const durationText =
      item.kind === "video" && item.duration
        ? `<span class="card-duration">${formatDuration(item.duration)}</span>`
        : "";

    const categoryText = item.category
      ? `<span class="card-category">${item.category}</span>`
      : "";

    div.innerHTML = `
      <div class="thumb-wrap">
        <img src="${item.thumb}" alt="${item.title}">
        <span class="card-badge">${getBadgeText(item)}</span>
        ${durationText}
      </div>
      <div class="card-body">
        <p class="card-title">${item.title}</p>
        ${categoryText}
      </div>
    `;

    div.onclick = () => openPlayer(item);
    gallery.appendChild(div);
  });
}

function buildTypeFilters() {
  const types = [
    { label: "All", value: "all" },
    { label: "Video", value: "video" },
    { label: "Stream", value: "stream" },
    { label: "Image", value: "image" }
  ];

  typeFilters.innerHTML = "";

  types.forEach(type => {
    const btn = document.createElement("button");
    btn.className = `filter-btn ${currentTypeFilter === type.value ? "active" : ""}`;
    btn.textContent = type.label;
    btn.dataset.filter = type.value;

    btn.addEventListener("click", () => {
      currentTypeFilter = type.value;
      buildTypeFilters();
      applyFilters();
    });

    typeFilters.appendChild(btn);
  });
}

function buildCategoryFilters(items) {
  const categoriesMap = new Map();

  items.forEach(item => {
    const slug = item.categorySlug || "uncategorized";
    const label = item.category || "Uncategorized";

    if (!categoriesMap.has(slug)) {
      categoriesMap.set(slug, label);
    }
  });

  categoryFilters.innerHTML = "";

  const allBtn = document.createElement("button");
  allBtn.className = `filter-btn ${currentCategoryFilter === "all" ? "active" : ""}`;
  allBtn.textContent = "All Categories";
  allBtn.dataset.filter = "all";

  allBtn.addEventListener("click", () => {
    currentCategoryFilter = "all";
    buildCategoryFilters(allItems);
    applyFilters();
  });

  categoryFilters.appendChild(allBtn);

  Array.from(categoriesMap.entries())
    .sort((a, b) => a[1].localeCompare(b[1]))
    .forEach(([slug, label]) => {
      const btn = document.createElement("button");
      btn.className = `filter-btn ${currentCategoryFilter === slug ? "active" : ""}`;
      btn.textContent = label;
      btn.dataset.filter = slug;

      btn.addEventListener("click", () => {
        currentCategoryFilter = slug;
        buildCategoryFilters(allItems);
        applyFilters();
      });

      categoryFilters.appendChild(btn);
    });
}

function applyFilters() {
  let filtered = [...allItems];

  if (currentTypeFilter !== "all") {
    filtered = filtered.filter(item => item.kind === currentTypeFilter);
  }

  if (currentCategoryFilter !== "all") {
    filtered = filtered.filter(
      item => (item.categorySlug || "uncategorized") === currentCategoryFilter
    );
  }

  renderGallery(filtered);
}

function openPlayer(item) {
  modal.classList.remove("hidden");
  destroyHls();
  player.innerHTML = "";

  if (item.sources && Array.isArray(item.sources) && item.sources.length > 0) {
    renderMultiSource(item);
    return;
  }

  renderSingle(item);
}

function renderSingle(item) {
  if (item.type === "mp4") {
    player.innerHTML = `
      <div class="video-wrapper">
        <video controls autoplay playsinline>
          <source src="${item.src}" type="video/mp4">
        </video>
      </div>
    `;
  } else if (item.type === "image") {
    player.innerHTML = `
      <div class="image-wrapper">
        <img src="${item.src}" alt="${item.title}">
      </div>
    `;
  } else if (item.type === "iframe") {
    player.innerHTML = `
      <div class="iframe-wrapper">
        <iframe
          src="${item.src}"
          allowfullscreen
          loading="lazy"
          referrerpolicy="strict-origin-when-cross-origin">
        </iframe>
      </div>
    `;
  } else if (item.type === "m3u8") {
    player.innerHTML = `
      <div class="video-wrapper">
        <video id="hlsPlayer" controls autoplay playsinline></video>
      </div>
    `;

    const video = document.getElementById("hlsPlayer");

    if (window.Hls && Hls.isSupported()) {
      currentHls = new Hls();
      currentHls.loadSource(item.src);
      currentHls.attachMedia(video);
    } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = item.src;
    } else {
      player.innerHTML = `<p>Browser tidak mendukung HLS.</p>`;
    }
  } else {
    player.innerHTML = `<p>Tipe media tidak dikenali.</p>`;
  }
}

function renderMultiSource(item) {
  player.innerHTML = `
    <div class="source-switcher" id="sourceButtons"></div>
    <div id="videoContainer"></div>
  `;

  const btnContainer = document.getElementById("sourceButtons");
  const container = document.getElementById("videoContainer");

  item.sources.forEach((source, index) => {
    const btn = document.createElement("button");
    btn.className = "source-btn";
    btn.textContent = source.label || `Source ${index + 1}`;

    btn.addEventListener("click", () => {
      document.querySelectorAll(".source-btn").forEach(b => b.classList.remove("active"));
      btn.classList.add("active");
      loadSource(source, container, item.title);
    });

    btnContainer.appendChild(btn);

    if (index === 0) {
      btn.classList.add("active");
      loadSource(source, container, item.title);
    }
  });
}

function loadSource(source, container, title = "") {
  destroyHls();
  container.innerHTML = "";

  if (source.type === "mp4") {
    container.innerHTML = `
      <div class="video-wrapper">
        <video controls autoplay playsinline>
          <source src="${source.src}" type="video/mp4">
        </video>
      </div>
    `;
  } else if (source.type === "iframe") {
    container.innerHTML = `
      <div class="iframe-wrapper">
        <iframe
          src="${source.src}"
          allowfullscreen
          loading="lazy"
          referrerpolicy="strict-origin-when-cross-origin">
        </iframe>
      </div>
    `;
  } else if (source.type === "m3u8") {
    container.innerHTML = `
      <div class="video-wrapper">
        <video id="hlsPlayer" controls autoplay playsinline></video>
      </div>
    `;

    const video = document.getElementById("hlsPlayer");

    if (window.Hls && Hls.isSupported()) {
      currentHls = new Hls();
      currentHls.loadSource(source.src);
      currentHls.attachMedia(video);
    } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = source.src;
    } else {
      container.innerHTML = `<p>Browser tidak mendukung HLS.</p>`;
    }
  } else if (source.type === "image") {
    container.innerHTML = `
      <div class="image-wrapper">
        <img src="${source.src}" alt="${title}">
      </div>
    `;
  } else {
    container.innerHTML = `<p>Source tidak dikenali.</p>`;
  }
}

function closePlayer() {
  modal.classList.add("hidden");
  destroyHls();
  player.innerHTML = "";
}

fetch("data.json")
  .then(res => res.json())
  .then(data => {
    allItems = data;
    buildTypeFilters();
    buildCategoryFilters(allItems);
    applyFilters();
  })
  .catch(err => {
    console.error("Gagal load data.json:", err);
    gallery.innerHTML = `<p>Gagal memuat data gallery.</p>`;
  });

3. Tambahkan CSS ini ke style.css

Tambahkan yang belum ada saja:

.card-category {
  display: inline-block;
  margin-top: 6px;
  font-size: 12px;
  color: #aaa;
}

#typeFilters,
#categoryFilters {
  margin-bottom: 14px;
}

Kalau CSS filter sebelumnya sudah ada, biarkan saja.


Hasilnya

Sekarang website kamu akan:

  • bikin tombol type otomatis
  • bikin tombol category otomatis dari data.json
  • tampilkan category di card
  • tetap support player sekarang

Contoh

Kalau data.json berisi category:

  • Social Media
  • TikTok
  • Reels

maka tombol filter category akan muncul sendiri.

Kamu tidak perlu edit HTML setiap kali nambah kategori baru.


Next terbaik setelah ini

Setelah category filter jalan, langkah paling kuat berikutnya adalah:

auto detect orientation video

jadi nanti item bisa punya:

  • orientation: "portrait"
  • orientation: "landscape"

Ini sangat cocok buat konten Instagram/TikTok kamu.

Kalau mau, next saya bikin generate.js upgrade orientation + resolution.