releases/site/site.js

218 lines
8.7 KiB
JavaScript

// MEDIABROWSER Official Site Script
// 1) IntersectionObserver Scroll Reveal
// 2) Mobile Navigation Menu Controller
// 3) Hero UI Simulator Micro-Interactions
// 4) Dynamic Releases Renderer (releases.json)
// ── 1. Scroll Reveal Controller ──────────────────────────────
(function initReveal() {
const items = document.querySelectorAll(".reveal");
if (!items.length) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) {
return;
}
document.documentElement.classList.add("js");
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("in");
io.unobserve(e.target);
}
}
}, { threshold: 0.12, rootMargin: "0px 0px -6% 0px" });
items.forEach((el) => io.observe(el));
})();
// ── 2. Mobile Nav Drawer Controller ──────────────────────────
(function initMobileNav() {
const toggleBtn = document.getElementById("nav-toggle");
const overlay = document.getElementById("mobile-menu");
if (!toggleBtn || !overlay) return;
function toggleMenu() {
const isActive = overlay.classList.toggle("is-active");
toggleBtn.setAttribute("aria-expanded", String(isActive));
document.body.style.overflow = isActive ? "hidden" : "";
}
toggleBtn.addEventListener("click", toggleMenu);
overlay.querySelectorAll(".mobile-menu-link").forEach((link) => {
link.addEventListener("click", () => {
overlay.classList.remove("is-active");
toggleBtn.setAttribute("aria-expanded", "false");
document.body.style.overflow = "";
});
});
})();
// ── 3. Hero UI Simulator Micro-Animation ─────────────────────
(function initHeroSimulator() {
const progressEl = document.getElementById("sim-progress");
const percentEl = document.getElementById("sim-percent");
const speedEl = document.getElementById("sim-speed");
if (!progressEl || !percentEl || !speedEl) return;
let current = 72;
let direction = 1;
setInterval(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (direction === 1) {
current += 0.8;
if (current >= 96) direction = -1;
} else {
current = 64;
direction = 1;
}
const rounded = Math.min(99, Math.floor(current));
progressEl.style.width = rounded + "%";
percentEl.textContent = rounded + "%";
const baseSpeed = 18.2 + (Math.random() * 2.4 - 1.2);
speedEl.textContent = baseSpeed.toFixed(1) + " MB/s";
}, 1200);
})();
// ── 4. Releases Renderer ─────────────────────────────────────
const PLATFORMS = [
{ id: "windows", label: "Windows", icon: "windows-logo", sub: "x64 및 ARM64 네이티브 설치자 (exe)",
match: (n) => /setup.*\.exe$/i.test(n) || /win/i.test(n) },
{ id: "macos", label: "macOS 프리뷰", icon: "apple-logo", sub: "Apple Silicon & Intel tar.gz 프리뷰 빌드",
match: (n) => /osx|darwin|mac/i.test(n) },
{ id: "linux", label: "Linux 프리뷰", icon: "linux-logo", sub: "Ubuntu, Fedora, Arch tar.gz 프리뷰 빌드",
match: (n) => /linux/i.test(n) },
{ id: "android", label: "Android", icon: "android-logo", sub: "스마트폰 로컬 스트리밍 컴패니언 APK",
match: (n) => /\.apk$/i.test(n) },
];
function detectPreferredAsset(name) {
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return /setup-x64\.exe$/i.test(name);
if (/Android/i.test(ua)) return /\.apk$/i.test(name);
if (/Mac/i.test(ua)) return /osx-arm64/i.test(name);
if (/Linux/i.test(ua)) return /linux-x64/i.test(name);
return false;
}
function fmtSize(bytes) {
if (!bytes) return "";
const mb = bytes / 1048576;
return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(1) + " MB";
}
function fmtDate(iso) {
if (!iso) return "";
const d = new Date(iso);
return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일`;
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
function assetRow(a, recommended) {
return `<div class="dl-asset">
<div>
<div class="dl-asset__name">${esc(a.name)}</div>
<div class="dl-asset__size text-mono">${fmtSize(a.size)} ${recommended ? '<span style="color:var(--accent);font-weight:700">· 현재 기기에 권장</span>' : ''}</div>
</div>
<a class="btn btn--primary btn--sm ${recommended ? "dl-reco" : ""}" href="${esc(a.url)}" title="${recommended ? "현재 기기에 권장" : ""}">
<span>다운로드</span>
<span class="btn__icon-badge"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg></span>
</a>
</div>`;
}
function groupsHtml(assets) {
const used = new Set();
const groups = PLATFORMS.map((p) => {
const list = assets.filter((a) => !used.has(a.name) && p.match(a.name));
list.forEach((a) => used.add(a.name));
if (!list.length) return "";
return `<div class="dl-group" id="${p.id}">
<div class="dl-group__head">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#${p.icon}"/></svg>
<span>${p.label}</span>
</div>
<div class="dl-group__sub">${p.sub}</div>
${list.map((a) => assetRow(a, detectPreferredAsset(a.name))).join("")}
</div>`;
}).filter(Boolean);
const rest = assets.filter((a) => !used.has(a.name));
if (rest.length) {
groups.push(`<div class="dl-group">
<div class="dl-group__head">기타 아카이브</div>
<div class="dl-group__sub">추가 바이너리 및 체크섬 파일</div>
${rest.map((a) => assetRow(a, false)).join("")}
</div>`);
}
return `<div class="dl-groups">${groups.join("")}</div>`;
}
function releaseCard(r, featured) {
const pill = r.prerelease ? `<span class="rel-pill rel-pill--pre">프리뷰 빌드</span>` : `<span class="rel-pill">공식 안정판</span>`;
const meta = `<div class="rel-featured__meta">
<span class="rel-featured__tag">${esc(r.name || r.tag)}</span>
${pill}
<span class="rel-featured__date text-mono">${fmtDate(r.published_at)}</span>
</div>
${r.notes ? `<p class="rel-featured__notes">${esc(r.notes)}</p>` : ""}
${groupsHtml(r.assets)}`;
if (featured) {
return `<article class="bezel-shell"><div class="bezel-inner rel-featured">${meta}</div></article>`;
}
return `<div class="bezel-shell"><div class="bezel-inner"><details>
<summary>
<span class="rel-featured__tag" style="font-size:1.15rem">${esc(r.name || r.tag)}</span>
${pill}
<span class="rel-featured__date text-mono">${fmtDate(r.published_at)}</span>
<svg class="icon faq-chevron" style="margin-left:8px"><use href="assets/icons.svg#caret-down"/></svg>
</summary>
<div class="rel-old__body">
${r.notes ? `<p class="rel-featured__notes">${esc(r.notes)}</p>` : ""}
${groupsHtml(r.assets)}
</div>
</details></div></div>`;
}
function emptyState() {
return `<div class="bezel-shell rel-empty">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#rocket-launch"/></svg>
<h2>첫 공개 릴리스를 준비하고 있습니다</h2>
<p style="color:var(--text-secondary);max-width:54ch">빌드 파이프라인이 정상 작동 중이며 곧 공식 설치자가 배포됩니다. 먼저 <a href="guide.html" style="color:var(--accent);font-weight:700">사용 안내</a>를 둘러보세요.</p>
</div>`;
}
(async function initReleases() {
const root = document.getElementById("release-root");
if (!root) return;
try {
const res = await fetch("releases.json", { cache: "no-cache" });
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const releases = (data.releases || []);
if (!releases.length) {
root.innerHTML = emptyState();
return;
}
const [latest, ...older] = releases;
let html = releaseCard(latest, true);
if (older.length) {
html += `<div class="rel-old"><h2 style="font-size:1.35rem;margin-bottom:14px">이전 버전 기록</h2>
${older.map((r) => releaseCard(r, false)).join("")}</div>`;
}
root.innerHTML = html;
} catch (err) {
root.innerHTML = `<div class="bezel-shell rel-empty">
<h2>릴리스 목록을 불러오지 못했습니다</h2>
<p style="color:var(--text-secondary)">네트워크 상태를 확인하고 새로고침해 주세요. 급하신 경우 <a href="https://git.chanpaca.net/Video-Downloader/releases" style="color:var(--accent);font-weight:700" target="_blank" rel="noopener">릴리스 저장소</a>에서 직접 다운로드하실 수 있습니다.</p>
</div>`;
}
})();