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_URL→srcdanthumbpakai URL R2 - kalau tidak ada → fallback ke
media/... - auto ambil:
sizedurationuntuk mp4
- auto parse filename jadi:
categorycategorySlugtitleslugtags
- 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.jsondibuat 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.