Initial Argus web catalog
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
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("'", "'");
|
||||
}
|
||||
Reference in New Issue
Block a user