237 lines
7.9 KiB
JavaScript
237 lines
7.9 KiB
JavaScript
const fallbackIcon = "/assets/argus-icon.png?v=argus-panoptes-3d-20260508";
|
|
const appIcons = new Map([
|
|
["aletheia-kotlin", "/assets/aletheia-icon.svg?v=aletheia-launcher-20260508"]
|
|
]);
|
|
|
|
const state = {
|
|
apps: [],
|
|
selectedSlug: null
|
|
};
|
|
|
|
const catalogGrid = document.getElementById("catalog-grid");
|
|
const recentReleases = document.getElementById("recent-releases");
|
|
const appIcon = document.getElementById("app-icon");
|
|
const appName = document.getElementById("app-name");
|
|
const appDescription = document.getElementById("app-description");
|
|
const releaseVersion = document.getElementById("release-version");
|
|
const detailMeta = document.getElementById("detail-meta");
|
|
const releaseList = document.getElementById("release-list");
|
|
const manifestLink = document.getElementById("manifest-link");
|
|
const primaryDownload = document.getElementById("primary-download");
|
|
const refreshButton = document.getElementById("refresh-button");
|
|
|
|
refreshButton.addEventListener("click", () => loadAll());
|
|
window.addEventListener("hashchange", handleHashChange);
|
|
|
|
loadAll();
|
|
|
|
async function loadAll() {
|
|
await Promise.all([loadCatalog(), loadRecentReleases()]);
|
|
handleHashChange();
|
|
}
|
|
|
|
async function loadCatalog() {
|
|
const response = await fetch("/api/apps");
|
|
const apps = await response.json();
|
|
state.apps = Array.isArray(apps) ? apps : [];
|
|
renderCatalog();
|
|
}
|
|
|
|
async function loadRecentReleases() {
|
|
const response = await fetch("/api/releases/recent");
|
|
const items = await response.json();
|
|
|
|
if (!Array.isArray(items) || !items.length) {
|
|
recentReleases.innerHTML = '<div class="empty-state">Пока нет опубликованных релизов.</div>';
|
|
return;
|
|
}
|
|
|
|
recentReleases.innerHTML = items.map(item => `
|
|
<article class="timeline-item">
|
|
<header>
|
|
<h3>${escapeHtml(item.appName)}</h3>
|
|
<span class="pill">${escapeHtml(item.release.version)}</span>
|
|
</header>
|
|
<p>${escapeHtml(item.release.platform)} - ${escapeHtml(item.release.packageKind)} - ${formatDate(item.release.publishedAt)}</p>
|
|
<div class="release-links">
|
|
<a href="#${encodeURIComponent(item.slug)}">Открыть карточку</a>
|
|
<a href="${item.release.downloadPath}">Скачать</a>
|
|
</div>
|
|
</article>
|
|
`).join("");
|
|
}
|
|
|
|
function renderCatalog() {
|
|
if (!state.apps.length) {
|
|
catalogGrid.innerHTML = '<div class="empty-state">Пока нет опубликованных приложений.</div>';
|
|
renderEmptyDetail();
|
|
return;
|
|
}
|
|
|
|
catalogGrid.innerHTML = state.apps.map(app => {
|
|
const isActive = app.slug === state.selectedSlug;
|
|
const latest = app.latestRelease;
|
|
return `
|
|
<article class="app-card ${isActive ? "active" : ""}" data-slug="${escapeHtml(app.slug)}">
|
|
${renderAppIcon("app-card-icon", app, 72)}
|
|
<div>
|
|
<h3>${escapeHtml(app.name)}</h3>
|
|
<p>${escapeHtml(app.summary)}</p>
|
|
<div class="rating-line">
|
|
<span>${latest ? escapeHtml(latest.version) : "нет релиза"}</span>
|
|
<span>${latest ? escapeHtml(latest.packageKind) : "пакет"}</span>
|
|
<span>${app.releaseCount} версия</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join("");
|
|
|
|
for (const card of catalogGrid.querySelectorAll(".app-card")) {
|
|
card.addEventListener("click", () => {
|
|
window.location.hash = card.dataset.slug;
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleHashChange() {
|
|
const slugFromHash = decodeURIComponent(window.location.hash.replace(/^#/, ""));
|
|
const hashApp = state.apps.find(app => app.slug === slugFromHash);
|
|
const nextSlug = hashApp?.slug ?? state.apps[0]?.slug ?? null;
|
|
state.selectedSlug = nextSlug;
|
|
|
|
if (!nextSlug) {
|
|
renderEmptyDetail();
|
|
return;
|
|
}
|
|
|
|
renderCatalog();
|
|
|
|
if (slugFromHash && !hashApp) {
|
|
window.history.replaceState(null, "", `#${encodeURIComponent(nextSlug)}`);
|
|
}
|
|
|
|
loadAppDetail(nextSlug);
|
|
}
|
|
|
|
async function loadAppDetail(slug) {
|
|
const response = await fetch(`/api/apps/${encodeURIComponent(slug)}`);
|
|
if (!response.ok) {
|
|
renderEmptyDetail("Карточка приложения не загрузилась.");
|
|
return;
|
|
}
|
|
|
|
const app = await response.json();
|
|
const latest = app.releases[0] ?? null;
|
|
appIcon.src = getAppIconSrc(app);
|
|
appName.textContent = app.name;
|
|
appDescription.textContent = app.description || app.summary;
|
|
manifestLink.href = `/api/apps/${encodeURIComponent(app.slug)}/manifest`;
|
|
|
|
if (latest) {
|
|
releaseVersion.textContent = latest.version;
|
|
primaryDownload.href = latest.downloadPath;
|
|
primaryDownload.removeAttribute("aria-disabled");
|
|
} else {
|
|
releaseVersion.textContent = "-";
|
|
primaryDownload.href = "#";
|
|
primaryDownload.setAttribute("aria-disabled", "true");
|
|
}
|
|
|
|
detailMeta.innerHTML = `
|
|
${renderMetaBlock("Обновлено", formatDate(app.updatedAt))}
|
|
${renderMetaBlock("Платформа", latest ? latest.platform : "Не опубликовано")}
|
|
${renderMetaBlock("Тип", latest ? latest.packageKind : "-")}
|
|
${renderMetaBlock("Размер", latest ? formatBytes(latest.packageSizeBytes) : "-")}
|
|
`;
|
|
|
|
if (!app.releases.length) {
|
|
releaseList.innerHTML = '<div class="empty-state">Для этого приложения ещё нет опубликованного файла.</div>';
|
|
return;
|
|
}
|
|
|
|
releaseList.innerHTML = app.releases.slice(0, 1).map(release => `
|
|
<article class="release-item">
|
|
<header>
|
|
<h3>Файл ${escapeHtml(release.version)}</h3>
|
|
<span class="pill">${escapeHtml(release.channel)}</span>
|
|
</header>
|
|
<p>${formatDate(release.publishedAt)} - ${formatBytes(release.packageSizeBytes)} - ${escapeHtml(release.originalFileName)}</p>
|
|
${release.notes ? `<p class="release-notes">${escapeHtml(release.notes)}</p>` : ""}
|
|
<div class="pill-row">
|
|
<span class="pill">${escapeHtml(release.platform)}</span>
|
|
<span class="pill">${escapeHtml(release.packageKind)}</span>
|
|
</div>
|
|
<div class="release-links">
|
|
<a href="${release.downloadPath}">Скачать файл</a>
|
|
<a href="/api/apps/${encodeURIComponent(app.slug)}/manifest?platform=${encodeURIComponent(release.platform)}&channel=${encodeURIComponent(release.channel)}" target="_blank" rel="noreferrer">Открыть manifest</a>
|
|
</div>
|
|
</article>
|
|
`).join("");
|
|
}
|
|
|
|
function renderEmptyDetail(message = "Данные приложения появятся после загрузки API.") {
|
|
appIcon.src = fallbackIcon;
|
|
appName.textContent = "Argus";
|
|
appDescription.textContent = message;
|
|
releaseVersion.textContent = "-";
|
|
detailMeta.innerHTML = "";
|
|
releaseList.innerHTML = "";
|
|
manifestLink.href = "#";
|
|
primaryDownload.href = "#";
|
|
primaryDownload.setAttribute("aria-disabled", "true");
|
|
}
|
|
|
|
function renderMetaBlock(label, value) {
|
|
return `
|
|
<div class="meta-block">
|
|
<span class="meta-label">${escapeHtml(label)}</span>
|
|
<div class="meta-value">${escapeHtml(value)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAppIcon(className, app, size) {
|
|
return `<img class="${escapeHtml(className)}" src="${escapeHtml(getAppIconSrc(app))}" alt="" width="${size}" height="${size}">`;
|
|
}
|
|
|
|
function getAppIconSrc(app) {
|
|
return appIcons.get(app?.slug) ?? fallbackIcon;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
return new Intl.DateTimeFormat("ru-RU", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
return "0 Б";
|
|
}
|
|
|
|
const units = ["Б", "КБ", "МБ", "ГБ"];
|
|
let index = 0;
|
|
let value = bytes;
|
|
|
|
while (value >= 1024 && index < units.length - 1) {
|
|
value /= 1024;
|
|
index += 1;
|
|
}
|
|
|
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|