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 = '
Пока нет опубликованных релизов.
';
return;
}
recentReleases.innerHTML = items.map(item => `
${escapeHtml(item.appName)}
${escapeHtml(item.release.version)}
${escapeHtml(item.release.platform)} - ${escapeHtml(item.release.packageKind)} - ${formatDate(item.release.publishedAt)}
`).join("");
}
function renderCatalog() {
if (!state.apps.length) {
catalogGrid.innerHTML = 'Пока нет опубликованных приложений.
';
renderEmptyDetail();
return;
}
catalogGrid.innerHTML = state.apps.map(app => {
const isActive = app.slug === state.selectedSlug;
const latest = app.latestRelease;
return `
${renderAppIcon("app-card-icon", app, 72)}
${escapeHtml(app.name)}
${escapeHtml(app.summary)}
${latest ? escapeHtml(latest.version) : "нет релиза"}
${latest ? escapeHtml(latest.packageKind) : "пакет"}
${app.releaseCount} версия
`;
}).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 = 'Для этого приложения ещё нет опубликованного файла.
';
return;
}
releaseList.innerHTML = app.releases.slice(0, 1).map(release => `
Файл ${escapeHtml(release.version)}
${escapeHtml(release.channel)}
${formatDate(release.publishedAt)} - ${formatBytes(release.packageSizeBytes)} - ${escapeHtml(release.originalFileName)}
${release.notes ? `${escapeHtml(release.notes)}
` : ""}
${escapeHtml(release.platform)}
${escapeHtml(release.packageKind)}
`).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 `
`;
}
function renderAppIcon(className, app, size) {
return `
`;
}
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("'", "'");
}