Files
Argus/wwwroot/site.js
T
Курнат Андрей 5f80c4bf6a Initial Argus web catalog
2026-06-20 19:55:11 +03:00

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}