feat(pages): add pages-v2 react variant

This commit is contained in:
daydreamer-json
2026-03-24 05:38:45 +09:00
parent 66814631d2
commit 255d633310
251 changed files with 7596 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import ky from 'ky';
import { BASE_URL, gameTargets, launcherTargets, launcherWebApiLang } from './utils/constants.js';
const apiCache = new Map<string, Promise<any>>();
export function fetchJson<T>(url: string): Promise<T> {
if (!apiCache.has(url)) {
const promise = ky
.get(url)
.json<T>()
.catch((err) => {
apiCache.delete(url);
throw err;
});
apiCache.set(url, promise);
}
return apiCache.get(url) as Promise<T>;
}
export async function preloadData() {
const promises: Promise<any>[] = [];
promises.push(fetchJson(`${BASE_URL}/mirror_file_list.json`));
const launcherWebApiFolderNames = ['announcement', 'banner', 'main_bg_image', 'sidebar', 'single_ent'];
for (const target of gameTargets) {
promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`));
promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`));
for (const apiName of launcherWebApiFolderNames) {
for (const lang of launcherWebApiLang[target.region]) {
promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/web/${target.dirName}/${apiName}/${lang}/all.json`));
}
}
}
const resTargets = [
{ region: 'os', channel: 6 },
{ region: 'cn', channel: 1 },
];
const platforms = ['Windows', 'Android', 'iOS', 'PlayStation'];
for (const target of resTargets) {
for (const platform of platforms) {
promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`));
}
}
for (const region of launcherTargets) {
for (const app of region.apps) {
promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`));
promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`));
}
}
await Promise.all(promises);
}

View File

@@ -0,0 +1,16 @@
import 'bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
document.addEventListener('DOMContentLoaded', () => {
const setTheme = (theme: string) => {
document.documentElement.setAttribute('data-bs-theme', theme);
};
const getPreferredTheme = (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
setTheme(getPreferredTheme());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
setTheme(getPreferredTheme());
});
});

View File

@@ -0,0 +1,78 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../utils/constants.js';
import math from '../utils/math.js';
import { generateDownloadLinks } from '../utils/ui.js';
export async function renderGamePackages(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-game-${target.dirName}`;
// Reverse order to show latest first
const list = [...data].reverse();
for (let i = 0; i < list.length; i++) {
const e = list[i];
if (!e) continue;
const version = e.rsp.version;
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const packedSize = math.arrayTotal(e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size)));
const unpackedSize = parseInt(e.rsp.pkg.total_size) - packedSize;
let rows = '';
const fileName = (f: any) => new URL(f.url).pathname.split('/').pop() ?? '';
for (const f of e.rsp.pkg.packs) {
rows += `<tr>
<td>${fileName(f)}</td>
<td><code>${f.md5}</code></td>
<td class="text-end">${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(f.url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `game-${target.dirName}-${i}`;
const isExpanded = false;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
<div class="d-flex w-100 justify-content-between me-3">
<span class="fw-bold">${version}</span>
<span class="text-muted small align-bottom">${dateStr}</span>
</div>
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<table class="table table-sm table-borderless w-auto mb-2">
<tr><td>Unpacked Size</td><td class="text-end fw-bold">${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}</td></tr>
<tr><td>Packed Size</td><td class="text-end fw-bold">${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}</td></tr>
</table>
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead><tr><th>File</th><th>MD5 Checksum</th><th class="text-end">Size</th><th class="text-center">DL</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
}
section.appendChild(accordion);
container.appendChild(section);
} catch (err) {
// Ignore 404 or errors
}
}
}

View File

@@ -0,0 +1,57 @@
import { fetchJson, preloadData } from './api.js';
import { renderGamePackages } from './renderers/gamePackages.js';
import { renderLaunchers } from './renderers/launchers.js';
import { renderOverview } from './renderers/overview.js';
import { renderPatches } from './renderers/patches.js';
import { renderResources } from './renderers/resources.js';
import { renderWebPretty } from './renderers/webPretty.js';
import type { MirrorFileEntry } from './types.js';
import { BASE_URL } from './utils/constants.js';
document.addEventListener('DOMContentLoaded', () => {
main();
});
let mirrorFileDb: MirrorFileEntry[] = [];
async function main() {
const contentDiv = document.getElementById('content');
if (!contentDiv) return;
await preloadData();
try {
mirrorFileDb = await fetchJson<MirrorFileEntry[]>(`${BASE_URL}/mirror_file_list.json`);
} catch (e) {
console.warn('Failed to fetch mirror list', e);
}
const tabsHtml = `
<ul class="nav nav-tabs" id="mainTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-overview" type="button">Overview</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-game" type="button">Game Packages</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-patch" type="button">Patches</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-resources" type="button">Resources</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-launcher" type="button">Launcher</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-web-pretty" type="button">Web</button></li>
</ul>
<div class="tab-content p-3 border border-top-0 rounded-bottom" id="mainTabsContent">
<div class="tab-pane fade show active" id="tab-overview" role="tabpanel"></div>
<div class="tab-pane fade" id="tab-game" role="tabpanel"></div>
<div class="tab-pane fade" id="tab-patch" role="tabpanel"></div>
<div class="tab-pane fade" id="tab-resources" role="tabpanel"></div>
<div class="tab-pane fade" id="tab-launcher" role="tabpanel"></div>
<div class="tab-pane fade" id="tab-web-pretty" role="tabpanel"></div>
</div>
`;
contentDiv.innerHTML = tabsHtml;
await Promise.all([
renderOverview(document.getElementById('tab-overview')!, mirrorFileDb),
renderGamePackages(document.getElementById('tab-game')!, mirrorFileDb),
renderPatches(document.getElementById('tab-patch')!, mirrorFileDb),
renderResources(document.getElementById('tab-resources')!),
renderLaunchers(document.getElementById('tab-launcher')!, mirrorFileDb),
renderWebPretty(document.getElementById('tab-web-pretty')!),
]);
}

View File

@@ -0,0 +1,137 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, launcherTargets } from '../utils/constants.js';
import math from '../utils/math.js';
import { generateDownloadLinks } from '../utils/ui.js';
export async function renderLaunchers(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
for (const region of launcherTargets) {
for (const app of region.apps) {
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${region.id.toUpperCase()} ${app}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-launcher-${region.id}-${app}`;
let itemIndex = 0;
// Zip
try {
const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`;
const dataZip = await fetchJson<StoredData<any>[]>(urlZip);
let rows = '';
for (const e of [...dataZip].reverse()) {
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const fileName = new URL(e.rsp.zip_package_url).pathname.split('/').pop() ?? '';
const unpacked = parseInt(e.rsp.total_size) - parseInt(e.rsp.package_size);
rows += `<tr>
<td>${dateStr}</td>
<td>${e.rsp.version}</td>
<td>${fileName}</td>
<td><code>${e.rsp.md5}</code></td>
<td class="text-end">${math.formatFileSize(unpacked, FILE_SIZE_OPTS)}</td>
<td class="text-end">${math.formatFileSize(parseInt(e.rsp.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(e.rsp.zip_package_url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `launcher-zip-${region.id}-${app}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
Launcher Packages (zip)
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead>
<tr>
<th>Date</th>
<th>Version</th>
<th>File</th>
<th>MD5 Checksum</th>
<th class="text-end">Unpacked</th>
<th class="text-end">Packed</th>
<th class="text-center">DL</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
} catch (e) {}
// Exe
try {
const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`;
const dataExe = await fetchJson<StoredData<any>[]>(urlExe);
let rows = '';
for (const e of [...dataExe].reverse()) {
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const fileName = new URL(e.rsp.exe_url).pathname.split('/').pop() ?? '';
rows += `<tr>
<td>${dateStr}</td>
<td>${e.rsp.version}</td>
<td>${fileName}</td>
<td class="text-end">${math.formatFileSize(parseInt(e.rsp.exe_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(e.rsp.exe_url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `launcher-exe-${region.id}-${app}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
Launcher Packages (Installer)
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead>
<tr>
<th>Date</th>
<th>Version</th>
<th>File</th>
<th class="text-end">Size</th>
<th class="text-center">DL</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
} catch (e) {}
if (accordion.childElementCount > 0) {
section.appendChild(accordion);
container.appendChild(section);
}
}
}
}

View File

@@ -0,0 +1,255 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets, launcherTargets } from '../utils/constants.js';
import math from '../utils/math.js';
export async function renderOverview(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
const mirrorOrigSet = new Set<string>();
for (const m of mirrorFileDb) {
try {
const u = new URL(m.orig);
u.search = '';
mirrorOrigSet.add(u.toString());
} catch {}
}
const countedUrls = new Set<string>();
let totalMirrorSize = 0;
const checkAndAddSize = (url: string, size: number) => {
if (!url || isNaN(size)) return;
try {
const u = new URL(url);
u.search = '';
const cleanUrl = u.toString();
if (countedUrls.has(cleanUrl)) return;
if (mirrorOrigSet.has(cleanUrl)) {
totalMirrorSize += size;
countedUrls.add(cleanUrl);
}
} catch {}
};
const section = document.createElement('div');
const sectionIn = document.createElement('div');
section.className = 'card mb-3';
sectionIn.className = 'card-body';
const [globalPkg, chinaPkg] = await Promise.all([
(async () => {
const url = `${BASE_URL}/akEndfield/launcher/game/6/all.json`;
const dat = await fetchJson<StoredData<any>[]>(url);
const latest = dat.at(-1);
if (!latest) return { version: '---', date: '---' };
return {
version: latest.rsp.version,
date: DateTime.fromISO(latest.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
};
})(),
(async () => {
const url = `${BASE_URL}/akEndfield/launcher/game/1/all.json`;
const dat = await fetchJson<StoredData<any>[]>(url);
const latest = dat.at(-1);
if (!latest) return { version: '---', date: '---' };
return {
version: latest.rsp.version,
date: DateTime.fromISO(latest.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
};
})(),
]);
sectionIn.innerHTML = `
<h3 class="card-title mb-4">Latest Game Packages</h3>
<div class="row text-center mb-4">
<div class="col-md-6 mb-md-0 mb-3">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">${globalPkg.version}</span><br />
<span class="small opacity-75" style="line-height: 1.5;">${globalPkg.date}</span><br />
Latest Version (Global)
</p>
</div>
<div class="col-md-6">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">${chinaPkg.version}</span><br />
<span class="small opacity-75" style="line-height: 1.5;">${chinaPkg.date}</span><br />
Latest Version (China)
</p>
</div>
</div>
`;
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-responsive';
const table = document.createElement('table');
table.className = 'table table-striped table-bordered table-sm align-middle text-nowrap';
table.innerHTML = `
<thead>
<tr>
<th>Region</th>
<th>Channel</th>
<th>Version</th>
<th class="text-end">Packed</th>
<th class="text-end">Unpacked</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody')!;
tableWrapper.appendChild(table);
sectionIn.appendChild(tableWrapper);
section.appendChild(sectionIn);
container.appendChild(section);
// 1. Game Packages
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (!data || data.length === 0) continue;
const latest = data[data.length - 1];
if (!latest) continue;
const version = latest.rsp.version;
const packedSize = math.arrayTotal(latest.rsp.pkg.packs.map((f: any) => parseInt(f.package_size)));
const totalSize = parseInt(latest.rsp.pkg.total_size);
const unpackedSize = totalSize - packedSize;
const row = document.createElement('tr');
row.innerHTML = `
<td>${target.region === 'cn' ? 'China' : 'Global'}</td>
<td>${target.name}</td>
<td>${version}</td>
<td class="text-end">${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}</td>
<td class="text-end">${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}</td>
`;
tbody.appendChild(row);
for (const entry of data) {
if (entry.rsp.pkg && entry.rsp.pkg.packs) {
for (const pack of entry.rsp.pkg.packs) {
checkAndAddSize(pack.url, parseInt(pack.package_size));
}
}
}
} catch (e) {
console.warn('Overview: Failed to fetch game data', target.name, e);
}
}
// 2. Patches
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
for (const entry of data) {
if (!entry.rsp.patch) continue;
if (entry.rsp.patch.url) {
checkAndAddSize(entry.rsp.patch.url, parseInt(entry.rsp.patch.package_size));
}
if (entry.rsp.patch.patches) {
for (const p of entry.rsp.patch.patches) {
checkAndAddSize(p.url, parseInt(p.package_size));
}
}
}
} catch (e) {}
}
// 4. Launchers
for (const region of launcherTargets) {
for (const app of region.apps) {
try {
const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`;
const dataZip = await fetchJson<StoredData<any>[]>(urlZip);
for (const e of dataZip) {
checkAndAddSize(e.rsp.zip_package_url, parseInt(e.rsp.package_size));
}
} catch (e) {}
try {
const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`;
const dataExe = await fetchJson<StoredData<any>[]>(urlExe);
for (const e of dataExe) {
checkAndAddSize(e.rsp.exe_url, parseInt(e.rsp.exe_size));
}
} catch (e) {}
}
}
// 3. Latest Game Resources (Global)
{
const resPlatforms = ['Windows', 'Android', 'iOS', 'PlayStation'];
const resData = await Promise.all(
resPlatforms.map(async (p) => {
try {
const url = `${BASE_URL}/akEndfield/launcher/game_resources/6/${p}/all.json`;
const dat = await fetchJson<StoredData<any>[]>(url);
return dat.at(-1);
} catch {
return undefined;
}
}),
);
const resSection = document.createElement('div');
resSection.className = 'card mb-3';
const resSectionIn = document.createElement('div');
resSectionIn.className = 'card-body';
resSectionIn.innerHTML = `
<h3 class="card-title mb-4">Latest Game Resources</h3>
<div class="row text-center">
${resPlatforms
.map((p, i) => {
const item = resData[i];
if (!item) {
return `
<div class="col-md-3 mb-3 mb-md-0">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">---</span><br />
${p}
</p>
</div>
`;
}
const version = (() => {
const initialRes = item.rsp.resources.find((e: any) => e.name === 'initial');
const mainRes = item.rsp.resources.find((e: any) => e.name === 'main');
if (!initialRes || !mainRes) return '---';
if (initialRes.version === mainRes.version) return mainRes.version;
return item.rsp.res_version;
})();
const dateStr = DateTime.fromISO(item.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
return `
<div class="col-md-3 mb-3 mb-md-0">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">${version}</span><br />
<span class="small opacity-75" style="line-height: 1.5;">${dateStr}</span><br />
${p}
</p>
</div>
`;
})
.join('')}
</div>
`;
resSection.appendChild(resSectionIn);
container.appendChild(resSection);
}
const mirrorSection = document.createElement('div');
mirrorSection.className = 'card';
mirrorSection.innerHTML = `
<div class="card-body">
<h3 class="card-title">Mirror Statistics</h3>
<p class="card-text text-center lh-1">
<span class="fw-bold fs-1">${math.formatFileSize(totalMirrorSize, { ...FILE_SIZE_OPTS, unit: 'G' })}</span><br />
uploaded to mirror
</p>
</div>
`;
container.appendChild(mirrorSection);
}

View File

@@ -0,0 +1,89 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../utils/constants.js';
import math from '../utils/math.js';
import { generateDownloadLinks } from '../utils/ui.js';
export async function renderPatches(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (data.length === 0) continue;
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-patch-${target.dirName}`;
let itemIndex = 0;
for (const e of [...data].reverse()) {
if (!e.rsp.patch) continue;
const version = e.rsp.version;
const reqVersion = e.rsp.request_version;
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const packedSize = math.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size)));
const unpackedSize = parseInt(e.rsp.patch.total_size) - packedSize;
let rows = '';
const fileName = (url: string) => new URL(url).pathname.split('/').pop() ?? '';
if (e.rsp.patch.url) {
rows += `<tr>
<td>${fileName(e.rsp.patch.url)}</td>
<td><code>${e.rsp.patch.md5}</code></td>
<td class="text-end">${math.formatFileSize(parseInt(e.rsp.patch.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(e.rsp.patch.url, mirrorFileDb)}</td>
</tr>`;
}
for (const f of e.rsp.patch.patches) {
rows += `<tr>
<td>${fileName(f.url)}</td>
<td><code>${f.md5}</code></td>
<td class="text-end">${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(f.url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `patch-${target.dirName}-${itemIndex}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
<div class="d-flex w-100 justify-content-between me-3">
<span class="fw-bold">${reqVersion}${version}</span>
<span class="text-muted small">${dateStr}</span>
</div>
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<table class="table table-sm table-borderless w-auto mb-2">
<tr><td>Unpacked Size</td><td class="text-end fw-bold">${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}</td></tr>
<tr><td>Packed Size</td><td class="text-end fw-bold">${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}</td></tr>
</table>
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead><tr><th>File</th><th>MD5 Checksum</th><th class="text-end">Size</th><th class="text-center">DL</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
}
section.appendChild(accordion);
container.appendChild(section);
} catch (err) {
// Ignore
}
}
}

View File

@@ -0,0 +1,78 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../utils/constants.js';
import math from '../utils/math.js';
import { generateDownloadLinks } from '../utils/ui.js';
export async function renderGamePackages(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-game-${target.dirName}`;
// Reverse order to show latest first
const list = [...data].reverse();
for (let i = 0; i < list.length; i++) {
const e = list[i];
if (!e) continue;
const version = e.rsp.version;
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const packedSize = math.arrayTotal(e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size)));
const unpackedSize = parseInt(e.rsp.pkg.total_size) - packedSize;
let rows = '';
const fileName = (f: any) => new URL(f.url).pathname.split('/').pop() ?? '';
for (const f of e.rsp.pkg.packs) {
rows += `<tr>
<td>${fileName(f)}</td>
<td><code>${f.md5}</code></td>
<td class="text-end">${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(f.url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `game-${target.dirName}-${i}`;
const isExpanded = false;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
<div class="d-flex w-100 justify-content-between me-3">
<span class="fw-bold">${version}</span>
<span class="text-muted small align-bottom">${dateStr}</span>
</div>
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<table class="table table-sm table-borderless w-auto mb-2">
<tr><td>Unpacked Size</td><td class="text-end fw-bold">${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}</td></tr>
<tr><td>Packed Size</td><td class="text-end fw-bold">${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}</td></tr>
</table>
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead><tr><th>File</th><th>MD5 Checksum</th><th class="text-end">Size</th><th class="text-center">DL</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
}
section.appendChild(accordion);
container.appendChild(section);
} catch (err) {
// Ignore 404 or errors
}
}
}

View File

@@ -0,0 +1,137 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, launcherTargets } from '../utils/constants.js';
import math from '../utils/math.js';
import { generateDownloadLinks } from '../utils/ui.js';
export async function renderLaunchers(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
for (const region of launcherTargets) {
for (const app of region.apps) {
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${region.id.toUpperCase()} ${app}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-launcher-${region.id}-${app}`;
let itemIndex = 0;
// Zip
try {
const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`;
const dataZip = await fetchJson<StoredData<any>[]>(urlZip);
let rows = '';
for (const e of [...dataZip].reverse()) {
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const fileName = new URL(e.rsp.zip_package_url).pathname.split('/').pop() ?? '';
const unpacked = parseInt(e.rsp.total_size) - parseInt(e.rsp.package_size);
rows += `<tr>
<td>${dateStr}</td>
<td>${e.rsp.version}</td>
<td>${fileName}</td>
<td><code>${e.rsp.md5}</code></td>
<td class="text-end">${math.formatFileSize(unpacked, FILE_SIZE_OPTS)}</td>
<td class="text-end">${math.formatFileSize(parseInt(e.rsp.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(e.rsp.zip_package_url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `launcher-zip-${region.id}-${app}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
Launcher Packages (zip)
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead>
<tr>
<th>Date</th>
<th>Version</th>
<th>File</th>
<th>MD5 Checksum</th>
<th class="text-end">Unpacked</th>
<th class="text-end">Packed</th>
<th class="text-center">DL</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
} catch (e) {}
// Exe
try {
const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`;
const dataExe = await fetchJson<StoredData<any>[]>(urlExe);
let rows = '';
for (const e of [...dataExe].reverse()) {
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const fileName = new URL(e.rsp.exe_url).pathname.split('/').pop() ?? '';
rows += `<tr>
<td>${dateStr}</td>
<td>${e.rsp.version}</td>
<td>${fileName}</td>
<td class="text-end">${math.formatFileSize(parseInt(e.rsp.exe_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(e.rsp.exe_url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `launcher-exe-${region.id}-${app}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
Launcher Packages (Installer)
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead>
<tr>
<th>Date</th>
<th>Version</th>
<th>File</th>
<th class="text-end">Size</th>
<th class="text-center">DL</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
} catch (e) {}
if (accordion.childElementCount > 0) {
section.appendChild(accordion);
container.appendChild(section);
}
}
}
}

View File

@@ -0,0 +1,255 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets, launcherTargets } from '../utils/constants.js';
import math from '../utils/math.js';
export async function renderOverview(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
const mirrorOrigSet = new Set<string>();
for (const m of mirrorFileDb) {
try {
const u = new URL(m.orig);
u.search = '';
mirrorOrigSet.add(u.toString());
} catch {}
}
const countedUrls = new Set<string>();
let totalMirrorSize = 0;
const checkAndAddSize = (url: string, size: number) => {
if (!url || isNaN(size)) return;
try {
const u = new URL(url);
u.search = '';
const cleanUrl = u.toString();
if (countedUrls.has(cleanUrl)) return;
if (mirrorOrigSet.has(cleanUrl)) {
totalMirrorSize += size;
countedUrls.add(cleanUrl);
}
} catch {}
};
const section = document.createElement('div');
const sectionIn = document.createElement('div');
section.className = 'card mb-3';
sectionIn.className = 'card-body';
const [globalPkg, chinaPkg] = await Promise.all([
(async () => {
const url = `${BASE_URL}/akEndfield/launcher/game/6/all.json`;
const dat = await fetchJson<StoredData<any>[]>(url);
const latest = dat.at(-1);
if (!latest) return { version: '---', date: '---' };
return {
version: latest.rsp.version,
date: DateTime.fromISO(latest.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
};
})(),
(async () => {
const url = `${BASE_URL}/akEndfield/launcher/game/1/all.json`;
const dat = await fetchJson<StoredData<any>[]>(url);
const latest = dat.at(-1);
if (!latest) return { version: '---', date: '---' };
return {
version: latest.rsp.version,
date: DateTime.fromISO(latest.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
};
})(),
]);
sectionIn.innerHTML = `
<h3 class="card-title mb-4">Latest Game Packages</h3>
<div class="row text-center mb-4">
<div class="col-md-6 mb-md-0 mb-3">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">${globalPkg.version}</span><br />
<span class="small opacity-75" style="line-height: 1.5;">${globalPkg.date}</span><br />
Latest Version (Global)
</p>
</div>
<div class="col-md-6">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">${chinaPkg.version}</span><br />
<span class="small opacity-75" style="line-height: 1.5;">${chinaPkg.date}</span><br />
Latest Version (China)
</p>
</div>
</div>
`;
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-responsive';
const table = document.createElement('table');
table.className = 'table table-striped table-bordered table-sm align-middle text-nowrap';
table.innerHTML = `
<thead>
<tr>
<th>Region</th>
<th>Channel</th>
<th>Version</th>
<th class="text-end">Packed</th>
<th class="text-end">Unpacked</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody')!;
tableWrapper.appendChild(table);
sectionIn.appendChild(tableWrapper);
section.appendChild(sectionIn);
container.appendChild(section);
// 1. Game Packages
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (!data || data.length === 0) continue;
const latest = data[data.length - 1];
if (!latest) continue;
const version = latest.rsp.version;
const packedSize = math.arrayTotal(latest.rsp.pkg.packs.map((f: any) => parseInt(f.package_size)));
const totalSize = parseInt(latest.rsp.pkg.total_size);
const unpackedSize = totalSize - packedSize;
const row = document.createElement('tr');
row.innerHTML = `
<td>${target.region === 'cn' ? 'China' : 'Global'}</td>
<td>${target.name}</td>
<td>${version}</td>
<td class="text-end">${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}</td>
<td class="text-end">${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}</td>
`;
tbody.appendChild(row);
for (const entry of data) {
if (entry.rsp.pkg && entry.rsp.pkg.packs) {
for (const pack of entry.rsp.pkg.packs) {
checkAndAddSize(pack.url, parseInt(pack.package_size));
}
}
}
} catch (e) {
console.warn('Overview: Failed to fetch game data', target.name, e);
}
}
// 2. Patches
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
for (const entry of data) {
if (!entry.rsp.patch) continue;
if (entry.rsp.patch.url) {
checkAndAddSize(entry.rsp.patch.url, parseInt(entry.rsp.patch.package_size));
}
if (entry.rsp.patch.patches) {
for (const p of entry.rsp.patch.patches) {
checkAndAddSize(p.url, parseInt(p.package_size));
}
}
}
} catch (e) {}
}
// 4. Launchers
for (const region of launcherTargets) {
for (const app of region.apps) {
try {
const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`;
const dataZip = await fetchJson<StoredData<any>[]>(urlZip);
for (const e of dataZip) {
checkAndAddSize(e.rsp.zip_package_url, parseInt(e.rsp.package_size));
}
} catch (e) {}
try {
const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`;
const dataExe = await fetchJson<StoredData<any>[]>(urlExe);
for (const e of dataExe) {
checkAndAddSize(e.rsp.exe_url, parseInt(e.rsp.exe_size));
}
} catch (e) {}
}
}
// 3. Latest Game Resources (Global)
{
const resPlatforms = ['Windows', 'Android', 'iOS', 'PlayStation'];
const resData = await Promise.all(
resPlatforms.map(async (p) => {
try {
const url = `${BASE_URL}/akEndfield/launcher/game_resources/6/${p}/all.json`;
const dat = await fetchJson<StoredData<any>[]>(url);
return dat.at(-1);
} catch {
return undefined;
}
}),
);
const resSection = document.createElement('div');
resSection.className = 'card mb-3';
const resSectionIn = document.createElement('div');
resSectionIn.className = 'card-body';
resSectionIn.innerHTML = `
<h3 class="card-title mb-4">Latest Game Resources</h3>
<div class="row text-center">
${resPlatforms
.map((p, i) => {
const item = resData[i];
if (!item) {
return `
<div class="col-md-3 mb-3 mb-md-0">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">---</span><br />
${p}
</p>
</div>
`;
}
const version = (() => {
const initialRes = item.rsp.resources.find((e: any) => e.name === 'initial');
const mainRes = item.rsp.resources.find((e: any) => e.name === 'main');
if (!initialRes || !mainRes) return '---';
if (initialRes.version === mainRes.version) return mainRes.version;
return item.rsp.res_version;
})();
const dateStr = DateTime.fromISO(item.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
return `
<div class="col-md-3 mb-3 mb-md-0">
<p class="lh-1 mb-0">
<span class="fw-bold fs-1">${version}</span><br />
<span class="small opacity-75" style="line-height: 1.5;">${dateStr}</span><br />
${p}
</p>
</div>
`;
})
.join('')}
</div>
`;
resSection.appendChild(resSectionIn);
container.appendChild(resSection);
}
const mirrorSection = document.createElement('div');
mirrorSection.className = 'card';
mirrorSection.innerHTML = `
<div class="card-body">
<h3 class="card-title">Mirror Statistics</h3>
<p class="card-text text-center lh-1">
<span class="fw-bold fs-1">${math.formatFileSize(totalMirrorSize, { ...FILE_SIZE_OPTS, unit: 'G' })}</span><br />
uploaded to mirror
</p>
</div>
`;
container.appendChild(mirrorSection);
}

View File

@@ -0,0 +1,89 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { MirrorFileEntry, StoredData } from '../types.js';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../utils/constants.js';
import math from '../utils/math.js';
import { generateDownloadLinks } from '../utils/ui.js';
export async function renderPatches(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) {
for (const target of gameTargets) {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (data.length === 0) continue;
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-patch-${target.dirName}`;
let itemIndex = 0;
for (const e of [...data].reverse()) {
if (!e.rsp.patch) continue;
const version = e.rsp.version;
const reqVersion = e.rsp.request_version;
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const packedSize = math.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size)));
const unpackedSize = parseInt(e.rsp.patch.total_size) - packedSize;
let rows = '';
const fileName = (url: string) => new URL(url).pathname.split('/').pop() ?? '';
if (e.rsp.patch.url) {
rows += `<tr>
<td>${fileName(e.rsp.patch.url)}</td>
<td><code>${e.rsp.patch.md5}</code></td>
<td class="text-end">${math.formatFileSize(parseInt(e.rsp.patch.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(e.rsp.patch.url, mirrorFileDb)}</td>
</tr>`;
}
for (const f of e.rsp.patch.patches) {
rows += `<tr>
<td>${fileName(f.url)}</td>
<td><code>${f.md5}</code></td>
<td class="text-end">${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)}</td>
<td class="text-center">${generateDownloadLinks(f.url, mirrorFileDb)}</td>
</tr>`;
}
const itemId = `patch-${target.dirName}-${itemIndex}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
<div class="d-flex w-100 justify-content-between me-3">
<span class="fw-bold">${reqVersion}${version}</span>
<span class="text-muted small">${dateStr}</span>
</div>
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<table class="table table-sm table-borderless w-auto mb-2">
<tr><td>Unpacked Size</td><td class="text-end fw-bold">${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}</td></tr>
<tr><td>Packed Size</td><td class="text-end fw-bold">${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}</td></tr>
</table>
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead><tr><th>File</th><th>MD5 Checksum</th><th class="text-end">Size</th><th class="text-center">DL</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
}
section.appendChild(accordion);
container.appendChild(section);
} catch (err) {
// Ignore
}
}
}

View File

@@ -0,0 +1,119 @@
import { DateTime } from 'luxon';
import * as semver from 'semver';
import { fetchJson } from '../api.js';
import type { StoredData } from '../types.js';
import { BASE_URL } from '../utils/constants.js';
export async function renderResources(container: HTMLElement) {
const platforms = ['Windows', 'Android', 'iOS', 'PlayStation'];
const targets = [
{ region: 'os', channel: 6 },
{ region: 'cn', channel: 1 },
];
for (const target of targets) {
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-res-${target.region}-${target.channel}`;
let itemIndex = 0;
for (const platform of platforms) {
const url = `${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
// Group by res_version
const resVersionMap = new Map<string, { rsp: StoredData<any>; versions: Set<string> }>();
for (const e of data) {
const resVer = e.rsp.res_version;
if (!resVersionMap.has(resVer)) {
resVersionMap.set(resVer, { rsp: e, versions: new Set() });
}
resVersionMap.get(resVer)!.versions.add(e.req.version);
}
const resVersionSet = Array.from(resVersionMap.values()).map((d) => ({
resVersion: d.rsp.rsp.res_version,
rsp: d.rsp,
versions: Array.from(d.versions).sort(semver.rcompare),
}));
const sortedSet = resVersionSet.reverse();
let rows = '';
for (let i = 0; i < sortedSet.length; i++) {
const item = sortedSet[i]!;
const nextItem = sortedSet[i + 1];
// Newest first
const currentDate = DateTime.fromISO(item.rsp.updatedAt);
const dateStr = currentDate.toFormat('yyyy/MM/dd HH:mm:ss');
const intervalStr = (() => {
if (nextItem) {
const nextDate = DateTime.fromISO(nextItem.rsp.updatedAt);
const diff = currentDate.diff(nextDate);
return diff.toFormat('dd:hh:mm:ss');
}
return '-';
})();
const initialRes = item.rsp.rsp.resources.find((e: any) => e.name === 'initial');
const mainRes = item.rsp.rsp.resources.find((e: any) => e.name === 'main');
const isKick = JSON.parse(item.rsp.rsp.configs).kick_flag === true;
rows += `<tr>
<td style="font-feature-settings: 'tnum'">${dateStr}</td>
<td style="font-feature-settings: 'tnum'">${intervalStr}</td>
<td><a href="${initialRes.path}" target="_blank">${initialRes.version}</a></td>
<td><a href="${mainRes.path}" target="_blank">${mainRes.version}</a></td>
<td class="text-center">${isKick ? '✅' : ''}</td>
<td>${item.versions.join(', ')}</td>
</tr>`;
}
const itemId = `res-${target.region}-${target.channel}-${platform}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
${platform}
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead>
<tr>
<th>Date</th>
<th>Interval</th>
<th>Initial</th>
<th>Main</th>
<th>Kick</th>
<th>Game version</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
} catch (err) {
// Ignore
}
}
if (accordion.childElementCount > 0) {
section.appendChild(accordion);
container.appendChild(section);
}
}
}

View File

@@ -0,0 +1,157 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { StoredData } from '../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../utils/constants.js';
const apiTypes = ['announcement', 'banner', 'main_bg_image', 'sidebar', 'single_ent'];
export async function renderWeb(container: HTMLElement) {
for (const target of gameTargets) {
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}</h3>`;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
// Language Selector
const langSelectGroup = document.createElement('div');
langSelectGroup.className = 'input-group mb-3';
langSelectGroup.innerHTML = '<span class="input-group-text">Language</span>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) {
option.selected = true;
}
langSelect.appendChild(option);
});
langSelectGroup.appendChild(langSelect);
if (langs.length <= 1) {
langSelectGroup.style.display = 'none';
}
section.appendChild(langSelectGroup);
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-web-${target.dirName}`;
const renderApiList = async (lang: string) => {
accordion.innerHTML = '<div class="text-muted p-2">Loading...</div>';
const results = await Promise.all(
apiTypes.map(async (apiType) => {
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/${apiType}/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (!data || data.length === 0) return null;
return { apiType, list: [...data].reverse() };
} catch (e) {
console.warn(`Failed to load ${url}`, e);
return null;
}
}),
);
accordion.innerHTML = '';
const validResults = results.filter((r): r is NonNullable<typeof r> => r !== null);
if (validResults.length === 0) {
accordion.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
validResults.forEach(({ apiType, list }, idx) => {
const itemId = `web-${target.dirName}-${lang}-${apiType}`;
const item = document.createElement('div');
item.className = 'accordion-item';
// Header
const header = document.createElement('h2');
header.className = 'accordion-header';
header.id = `heading-${itemId}`;
header.innerHTML = `
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="false" aria-controls="collapse-${itemId}">
${apiType}
</button>
`;
item.appendChild(header);
// Body
const collapse = document.createElement('div');
collapse.id = `collapse-${itemId}`;
collapse.className = 'accordion-collapse collapse';
collapse.setAttribute('aria-labelledby', `heading-${itemId}`);
collapse.setAttribute('data-bs-parent', `#${accordion.id}`);
const body = document.createElement('div');
body.className = 'accordion-body';
// Select for UpdatedAt
const selectGroup = document.createElement('div');
selectGroup.className = 'input-group mb-3';
selectGroup.innerHTML = `<span class="input-group-text">History</span>`;
const select = document.createElement('select');
select.className = 'form-select';
select.ariaLabel = 'Select version';
list.forEach((entry, idx) => {
const dateStr = DateTime.fromISO(entry.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${dateStr}`;
select.appendChild(option);
});
selectGroup.appendChild(select);
body.appendChild(selectGroup);
// Content Area
const contentArea = document.createElement('pre');
contentArea.className = 'p-3 border rounded overflow-auto';
contentArea.style.maxHeight = '500px';
contentArea.style.fontSize = '0.875rem';
const updateContent = (index: number) => {
const entry = list[index];
if (entry) {
contentArea.textContent = JSON.stringify(entry.rsp, null, 2);
}
};
// Initial render for this item
updateContent(0);
select.addEventListener('change', (e) => {
const val = parseInt((e.target as HTMLSelectElement).value, 10);
updateContent(val);
});
body.appendChild(contentArea);
collapse.appendChild(body);
item.appendChild(collapse);
accordion.appendChild(item);
});
};
langSelect.addEventListener('change', (e) => {
renderApiList((e.target as HTMLSelectElement).value);
});
section.appendChild(accordion);
container.appendChild(section);
// Initial load
if (defaultLang) {
renderApiList(defaultLang);
}
}
}

View File

@@ -0,0 +1,14 @@
import { renderAnnouncement } from './webPretty/announcement.js';
import { renderBanner } from './webPretty/banner.js';
import { renderMainBgImage } from './webPretty/mainBgImage.js';
import { renderSidebar } from './webPretty/sidebar.js';
import { renderSingleEnt } from './webPretty/singleEnt.js';
export async function renderWebPretty(container: HTMLElement) {
container.innerHTML = '';
await renderAnnouncement(container);
await renderBanner(container);
await renderMainBgImage(container);
await renderSingleEnt(container);
await renderSidebar(container);
}

View File

@@ -0,0 +1,179 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebAnnouncement, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderAnnouncement(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseAnnouncement');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Announcement</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseAnnouncement';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading announcements...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/announcement/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebAnnouncement>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
const tabsMap = new Map<
string,
{ tabName: string; announcements: Map<string, LauncherWebAnnouncement['tabs'][0]['announcements'][0]> }
>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.tabs) continue;
for (const tab of entry.rsp.tabs) {
if (!tabsMap.has(tab.tab_id)) {
tabsMap.set(tab.tab_id, { tabName: tab.tabName, announcements: new Map() });
}
const targetTab = tabsMap.get(tab.tab_id)!;
for (const ann of tab.announcements) {
if (!targetTab.announcements.has(ann.id)) {
targetTab.announcements.set(ann.id, ann);
}
}
}
}
contentDiv.innerHTML = '';
if (tabsMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No announcements found.</div>';
return;
}
for (const [_tabId, tabData] of tabsMap) {
const card = document.createElement('div');
card.className = 'card mb-4 shadow-sm';
const cardHeader = document.createElement('div');
cardHeader.className = 'card-header bg-secondary text-white fw-bold py-1';
cardHeader.textContent = tabData.tabName;
card.appendChild(cardHeader);
const listGroup = document.createElement('ul');
listGroup.className = 'list-group list-group-flush';
const sortedAnnouncements = Array.from(tabData.announcements.values()).sort(
(a, b) => parseInt(b.start_ts, 10) - parseInt(a.start_ts, 10),
);
for (const ann of sortedAnnouncements) {
const item = document.createElement('li');
item.className = 'list-group-item py-2';
const date = DateTime.fromMillis(parseInt(ann.start_ts, 10)).toFormat('yyyy/MM/dd HH:mm');
item.innerHTML = `
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="text-muted small" style="min-width: 120px;">${date}</span>
<span class="flex-grow-1 fw-bold">${ann.content}</span>
<div class="d-flex align-items-center gap-2">
${ann.need_token ? '<span class="badge bg-warning text-dark px-1 py-0" style="font-size: 0.7rem;">Auth</span>' : ''}
${ann.jump_url ? `<a href="${ann.jump_url}" target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;">Link</a>` : ''}
<span class="text-muted border-start ps-2" style="font-size: 0.7rem;">ID:${ann.id}</span>
</div>
</div>
`;
listGroup.appendChild(item);
}
card.appendChild(listGroup);
contentDiv.appendChild(card);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,175 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebBanner, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderBanner(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseBanner');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Banner</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseBanner';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading banners...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/banner/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebBanner>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect unique banners by ID from the entire history
const bannerMap = new Map<string, { banner: LauncherWebBanner['banners'][0]; firstSeen: string }>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.banners) continue;
for (const banner of entry.rsp.banners) {
if (!bannerMap.has(banner.id)) {
bannerMap.set(banner.id, { banner, firstSeen: entry.updatedAt });
}
}
}
contentDiv.innerHTML = '';
if (bannerMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No banners found.</div>';
return;
}
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-3 row-cols-lg-4 g-3';
contentDiv.appendChild(row);
for (const [id, { banner, firstSeen }] of bannerMap) {
const col = document.createElement('div');
col.className = 'col';
const dateStr = DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm');
const mirrorUrl = getMirrorUrl(banner.url);
const linkUrl = banner.jump_url || mirrorUrl;
col.innerHTML = `
<a href="${linkUrl}" target="_blank" class="text-decoration-none text-reset">
<div class="card h-100 shadow-sm border-0">
<div class="position-relative">
<img src="${mirrorUrl}" class="card-img-top rounded" alt="Banner Image" style="object-fit: cover; aspect-ratio: 16 / 9;">
<div class="position-absolute top-0 end-0 p-1">
${banner.need_token ? '<span class="badge bg-warning text-dark" style="font-size: 0.6rem;">Auth</span>' : ''}
</div>
</div>
<div class="card-body py-1 px-1">
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
<span class="text-muted text-truncate me-1">ID: ${id}</span>
<span class="text-muted flex-shrink-0">${dateStr}</span>
</div>
</div>
</div>
</a>
`;
row.appendChild(col);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,174 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebMainBgImage, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderMainBgImage(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseMainBgImage');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Main Background Image</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseMainBgImage';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading background images...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/main_bg_image/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebMainBgImage>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect unique images by MD5 from the entire history
const imageMap = new Map<string, { image: LauncherWebMainBgImage['main_bg_image']; firstSeen: string }>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.main_bg_image) continue;
const img = entry.rsp.main_bg_image;
if (!imageMap.has(img.md5)) {
imageMap.set(img.md5, { image: img, firstSeen: entry.updatedAt });
}
}
contentDiv.innerHTML = '';
if (imageMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No images found.</div>';
return;
}
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3';
contentDiv.appendChild(row);
for (const [md5, { image, firstSeen }] of imageMap) {
const col = document.createElement('div');
col.className = 'col';
const dateStr = DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm');
const mirrorUrl = getMirrorUrl(image.url);
const linkUrl = image.video_url ? getMirrorUrl(image.video_url) : mirrorUrl;
col.innerHTML = `
<a href="${linkUrl}" target="_blank" class="text-decoration-none text-reset">
<div class="card h-100 shadow-sm border-0">
<div class="position-relative">
<img src="${mirrorUrl}" class="card-img-top rounded" alt="Background Image" style="object-fit: cover; aspect-ratio: 16 / 9;">
<div class="position-absolute top-0 end-0 p-1">
${image.video_url ? '<span class="badge bg-primary" style="font-size: 0.6rem;">Video</span>' : ''}
</div>
</div>
<div class="card-body py-1 px-1">
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
<span class="text-muted text-truncate font-monospace me-1">${md5}</span>
<span class="text-muted flex-shrink-0">${dateStr}</span>
</div>
</div>
</div>
</a>
`;
row.appendChild(col);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,195 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebSidebar, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderSidebar(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseSidebar');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Sidebar</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseSidebar';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading sidebar data...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/sidebar/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebSidebar>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect the latest sidebar configuration
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
// We only show the latest version as sidebars are usually state-dependent
const latest = sortedData[0];
if (!latest || !latest.rsp || !latest.rsp.sidebars) {
contentDiv.innerHTML = '<div class="text-muted p-2">No active sidebars.</div>';
return;
}
contentDiv.innerHTML = '';
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3';
contentDiv.appendChild(row);
for (const item of latest.rsp.sidebars) {
const col = document.createElement('div');
col.className = 'col';
const card = document.createElement('div');
card.className = 'card h-100 shadow-sm';
let innerHtml = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="card-title mb-0">${item.media}</h5>
${item.need_token ? '<span class="badge bg-warning text-dark lh-1 py-1">Auth</span>' : ''}
</div>
`;
if (item.pic) {
innerHtml += `
<div class="mb-3">
<img src="${getMirrorUrl(item.pic.url)}" class="img-fluid rounded" alt="${item.pic.description}">
<p class="text-muted small mt-1 mb-0">${item.pic.description}</p>
</div>
`;
}
if (item.jump_url) {
innerHtml += `
<a href="${item.jump_url}" target="_blank" class="btn btn-sm btn-outline-primary mb-2 w-100">Open Link</a>
`;
}
if (item.sidebar_labels && item.sidebar_labels.length > 0) {
innerHtml += '<div class="list-group list-group-flush border-top mt-2">';
for (const label of item.sidebar_labels) {
innerHtml += `
<a href="${label.jump_url}" target="_blank" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2">
<span style="font-size: 0.9rem;">${label.content}</span>
<div class="d-flex gap-1">
${label.need_token ? '<span class="badge bg-warning text-dark lh-1 py-1">Auth</span>' : ''}
<i class="bi bi-box-arrow-up-right small text-muted"></i>
</div>
</a>
`;
}
innerHtml += '</div>';
}
innerHtml += '</div>';
card.innerHTML = innerHtml;
col.appendChild(card);
row.appendChild(col);
}
const infoDiv = document.createElement('div');
infoDiv.className = 'text-muted small mt-3 text-end';
infoDiv.textContent = `Last updated: ${DateTime.fromISO(latest.updatedAt).toFormat('yyyy/MM/dd HH:mm')}`;
contentDiv.appendChild(infoDiv);
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,205 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebSingleEnt, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderSingleEnt(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseSingleEnt');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Single Ent.</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseSingleEnt';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
if (!url) return '';
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading single entry data...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/single_ent/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebSingleEnt>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect unique visuals by MD5 from the entire history
const entMap = new Map<string, { ent: LauncherWebSingleEnt['single_ent']; firstSeen: string }>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.single_ent) continue;
const ent = entry.rsp.single_ent;
const key = ent.version_md5 || ent.version_url;
if (!entMap.has(key)) {
entMap.set(key, { ent, firstSeen: entry.updatedAt });
}
}
contentDiv.innerHTML = '';
if (entMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-2 g-4';
contentDiv.appendChild(row);
for (const [_key, { ent, firstSeen }] of entMap) {
const col = document.createElement('div');
col.className = 'col';
const card = document.createElement('div');
card.className = 'card h-100 shadow-sm';
let innerHtml = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">First seen: ${DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm')}</span>
${ent.need_token ? '<span class="badge bg-warning text-dark">Auth</span>' : ''}
</div>
<div class="mb-3">
<label class="form-label small fw-bold">Version Image</label>
<a href="${getMirrorUrl(ent.version_url)}" target="_blank">
<img src="${getMirrorUrl(ent.version_url)}" class="img-fluid rounded border" alt="Version Image">
</a>
<p class="text-muted font-monospace mt-1" style="font-size: 0.7rem; word-break: break-all;">MD5: ${ent.version_md5}</p>
</div>
`;
if (ent.button_url) {
innerHtml += `
<div class="mb-3">
<label class="form-label small fw-bold">Action Button</label>
<div class="d-flex gap-2">
<div class="flex-grow-1">
<img src="${getMirrorUrl(ent.button_url)}" class="img-fluid rounded border bg-light" alt="Button" style="max-height: 60px;">
<p class="text-muted small mt-1 mb-0">Normal</p>
</div>
${
ent.button_hover_url
? `
<div class="flex-grow-1">
<img src="${getMirrorUrl(ent.button_hover_url)}" class="img-fluid rounded border bg-light" alt="Button Hover" style="max-height: 60px;">
<p class="text-muted small mt-1 mb-0">Hover</p>
</div>
`
: ''
}
</div>
</div>
`;
}
if (ent.jump_url) {
innerHtml += `
<a href="${ent.jump_url}" target="_blank" class="btn btn-sm btn-outline-primary w-100">Jump URL</a>
`;
}
innerHtml += '</div>';
card.innerHTML = innerHtml;
col.appendChild(card);
row.appendChild(col);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,119 @@
import { DateTime } from 'luxon';
import * as semver from 'semver';
import { fetchJson } from '../api.js';
import type { StoredData } from '../types.js';
import { BASE_URL } from '../utils/constants.js';
export async function renderResources(container: HTMLElement) {
const platforms = ['Windows', 'Android', 'iOS', 'PlayStation'];
const targets = [
{ region: 'os', channel: 6 },
{ region: 'cn', channel: 1 },
];
for (const target of targets) {
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}</h3>`;
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-res-${target.region}-${target.channel}`;
let itemIndex = 0;
for (const platform of platforms) {
const url = `${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
// Group by res_version
const resVersionMap = new Map<string, { rsp: StoredData<any>; versions: Set<string> }>();
for (const e of data) {
const resVer = e.rsp.res_version;
if (!resVersionMap.has(resVer)) {
resVersionMap.set(resVer, { rsp: e, versions: new Set() });
}
resVersionMap.get(resVer)!.versions.add(e.req.version);
}
const resVersionSet = Array.from(resVersionMap.values()).map((d) => ({
resVersion: d.rsp.rsp.res_version,
rsp: d.rsp,
versions: Array.from(d.versions).sort(semver.rcompare),
}));
const sortedSet = resVersionSet.reverse();
let rows = '';
for (let i = 0; i < sortedSet.length; i++) {
const item = sortedSet[i]!;
const nextItem = sortedSet[i + 1];
// Newest first
const currentDate = DateTime.fromISO(item.rsp.updatedAt);
const dateStr = currentDate.toFormat('yyyy/MM/dd HH:mm:ss');
const intervalStr = (() => {
if (nextItem) {
const nextDate = DateTime.fromISO(nextItem.rsp.updatedAt);
const diff = currentDate.diff(nextDate);
return diff.toFormat('dd:hh:mm:ss');
}
return '-';
})();
const initialRes = item.rsp.rsp.resources.find((e: any) => e.name === 'initial');
const mainRes = item.rsp.rsp.resources.find((e: any) => e.name === 'main');
const isKick = JSON.parse(item.rsp.rsp.configs).kick_flag === true;
rows += `<tr>
<td style="font-feature-settings: 'tnum'">${dateStr}</td>
<td style="font-feature-settings: 'tnum'">${intervalStr}</td>
<td><a href="${initialRes.path}" target="_blank">${initialRes.version}</a></td>
<td><a href="${mainRes.path}" target="_blank">${mainRes.version}</a></td>
<td class="text-center">${isKick ? '✅' : ''}</td>
<td>${item.versions.join(', ')}</td>
</tr>`;
}
const itemId = `res-${target.region}-${target.channel}-${platform}`;
const isExpanded = false;
itemIndex++;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="heading-${itemId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="${isExpanded}" aria-controls="collapse-${itemId}">
${platform}
</button>
</h2>
<div id="collapse-${itemId}" class="accordion-collapse collapse ${isExpanded ? 'show' : ''}" aria-labelledby="heading-${itemId}" data-bs-parent="#${accordion.id}">
<div class="accordion-body">
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle text-nowrap">
<thead>
<tr>
<th>Date</th>
<th>Interval</th>
<th>Initial</th>
<th>Main</th>
<th>Kick</th>
<th>Game version</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
</div>
`;
accordion.appendChild(item);
} catch (err) {
// Ignore
}
}
if (accordion.childElementCount > 0) {
section.appendChild(accordion);
container.appendChild(section);
}
}
}

View File

@@ -0,0 +1,72 @@
export interface MirrorFileEntry {
orig: string;
mirror: string;
origStatus: boolean;
}
export interface StoredData<T> {
req: any;
rsp: T;
updatedAt: string;
}
export interface LauncherWebAnnouncement {
data_version: string;
tabs: {
tabName: string;
announcements: {
content: string;
jump_url: string;
start_ts: string;
id: string;
need_token: boolean;
}[];
tab_id: string;
}[];
}
export interface LauncherWebBanner {
data_version: string;
banners: {
url: string;
md5: string;
jump_url: string;
id: string;
need_token: boolean;
}[];
}
export interface LauncherWebMainBgImage {
data_version: string;
main_bg_image: {
url: string;
md5: string;
video_url: string;
};
}
export interface LauncherWebSingleEnt {
single_ent: {
version_url: string;
version_md5: string;
jump_url: string;
button_url: string;
button_md5: string;
button_hover_url: string;
button_hover_md5: string;
need_token: boolean;
};
}
export interface LauncherWebSidebar {
data_version: string;
sidebars: {
display_type: 'DisplayType_RESERVE';
media: string;
pic: { url: string; md5: string; description: string } | null;
sidebar_labels: { content: string; jump_url: string; need_token: boolean }[];
grid_info: null;
jump_url: string;
need_token: boolean;
}[];
}

View File

@@ -0,0 +1,44 @@
export const BASE_URL =
'https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output';
export const FILE_SIZE_OPTS = {
decimals: 2,
decimalPadding: true,
useBinaryUnit: true,
useBitUnit: false,
unitVisible: true,
unit: null,
};
export const gameTargets = [
{ name: 'Official', region: 'os' as const, dirName: '6', channel: 6 },
{ name: 'Epic', region: 'os' as const, dirName: '801', channel: 6 },
{ name: 'Google Play', region: 'os' as const, dirName: '802', channel: 6 },
{ name: 'Official', region: 'cn' as const, dirName: '1', channel: 1 },
{ name: 'Bilibili', region: 'cn' as const, dirName: '2', channel: 2 },
];
export const launcherTargets = [
{ id: 'os', apps: ['EndField', 'Official'], channel: 6 },
{ id: 'cn', apps: ['EndField', 'Arknights', 'Official'], channel: 1 },
];
export const launcherWebApiLang = {
os: [
'de-de',
'en-us',
'es-mx',
'fr-fr',
'id-id',
'it-it',
'ja-jp',
'ko-kr',
'pt-br',
'ru-ru',
'th-th',
'vi-vn',
'zh-cn',
'zh-tw',
] as const,
cn: ['zh-cn'] as const,
};

View File

@@ -0,0 +1,12 @@
import { DateTime } from 'luxon';
export default {
write(message: string) {
const debugLogElement = document.querySelector('#debug-log code');
if (!debugLogElement) return;
const prettyMessage = `${DateTime.now().toFormat('HH:mm:ss.SSS')} > ${message}`;
const divEl = document.createElement('div');
divEl.textContent = prettyMessage;
debugLogElement.appendChild(divEl);
},
};

View File

@@ -0,0 +1,163 @@
import logger from './logger.js';
export default {
arrayMax(array: Array<number>) {
return array.reduce((a, b) => Math.max(a, b));
},
arrayMin(array: Array<number>) {
return array.reduce((a, b) => Math.min(a, b));
},
arrayTotal(array: Array<number>) {
return array.reduce((acc, f) => acc + f, 0);
},
arrayAvg(array: Array<number>) {
return this.arrayTotal(array) / array.length;
},
rounder(method: 'floor' | 'ceil' | 'round', num: number, n: number) {
const pow = Math.pow(10, n);
let result: number;
switch (method) {
case 'floor':
result = Math.floor(num * pow) / pow;
break;
case 'ceil':
result = Math.ceil(num * pow) / pow;
break;
case 'round':
result = Math.round(num * pow) / pow;
break;
}
return {
orig: result,
padded: result.toFixed(n),
};
},
formatFileSize(
bytes: number,
options: {
decimals: number;
decimalPadding: boolean;
useBinaryUnit: boolean;
useBitUnit: boolean;
unitVisible: boolean;
unit: 'B' | 'K' | 'M' | 'G' | 'T' | 'P' | 'E' | 'Z' | 'Y' | null;
},
) {
const k = options.useBinaryUnit ? 1024 : 1000;
const dm = options.decimals < 0 ? 0 : options.decimals;
const baseUnits = ['B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
const binaryUnitSuffix = options.useBitUnit ? 'ib' : 'iB';
const siUnitSuffix = options.useBitUnit ? 'b' : 'B';
const getUnitString = (i: number) => {
if (i === 0) return options.useBitUnit ? 'b' : 'B';
return baseUnits[i] + (options.useBinaryUnit ? binaryUnitSuffix : siUnitSuffix);
};
let value = bytes < 0 ? 0 : Math.floor(bytes);
if (options.useBitUnit) {
value *= 8;
}
let i: number;
if (options.unit !== null) {
i = baseUnits.indexOf(options.unit);
if (i === -1) throw new Error(`Invalid unit: ${options.unit}`);
} else {
if (value === 0) {
i = 0;
} else {
i = Math.floor(Math.log(value) / Math.log(k));
i = Math.max(0, Math.min(baseUnits.length - 1, i)); // clamp
}
}
const resultValue = value / Math.pow(k, i);
let formattedValue: string;
if (options.decimalPadding) {
formattedValue = resultValue.toFixed(dm);
} else {
formattedValue = resultValue.toFixed(dm).replace(/\.?0+$/, '');
}
return formattedValue + (options.unitVisible ? ' ' + getUnitString(i) : '');
},
secureRandomFloatInRange(min: number, max: number): number {
if (min > max) [min, max] = [max, min];
const crypto = globalThis.crypto;
if (!crypto) {
throw new Error('Cryptographically secure random float number gen is not available');
}
const randomValues = new Uint32Array(2);
crypto.getRandomValues(randomValues);
const highBits = randomValues[1]! & 0x1fffff; // 0x1FFFFF = 2^21 - 1
const lowBits = randomValues[0];
const combined = highBits * 0x100000000 + lowBits!; // 0x100000000 = 2^32
const randomFraction = combined / 0x20000000000000; // 0x20000000000000 = 2^53
return randomFraction * (max - min) + min;
},
secureRandomIntInRange(min: number, max: number, writeLog: boolean = false): number {
if (min === max) {
writeLog ? logger.write(`randomInt: Range=${min}-${max}, Output=${min}`) : undefined;
return min;
}
if (min > max) [min, max] = [max, min];
const crypto = globalThis.crypto;
if (!crypto) {
throw new Error('Cryptographically secure random int number gen is not available');
}
// convert to integer anyway
const minInt = Math.ceil(min);
const maxInt = Math.floor(max);
// safe integer check
if (!Number.isSafeInteger(minInt) || !Number.isSafeInteger(maxInt)) {
throw new Error('Range boundaries must be within safe integer limits');
}
// valid range check
if (minInt > maxInt) {
throw new Error('Invalid range after integer conversion: min > max');
}
const range = maxInt - minInt + 1;
if (range <= 0 || range > Number.MAX_SAFE_INTEGER) {
throw new Error(`Range size must be between 1 and ${Number.MAX_SAFE_INTEGER} inclusive`);
}
// 53-bit random num gen
const MAX_53 = BigInt(1) << BigInt(53); // 2^53
const rangeBigInt = BigInt(range);
const maxAcceptable = MAX_53 - (MAX_53 % rangeBigInt);
// generate
const randomBuffer = new Uint32Array(2);
while (true) {
crypto.getRandomValues(randomBuffer);
const highBits = randomBuffer[1]! & 0x1fffff; // use lower 21-bit only
const lowBits = randomBuffer[0];
const combined = BigInt(highBits) * BigInt(0x100000000) + BigInt(lowBits!); // 0x100000000 = 2^32
// accept condition: combined < maxAcceptable
if (combined < maxAcceptable) {
const offset = Number(combined % rangeBigInt); // 0 to range-1
writeLog
? logger.write(
`randomInt: Range=${min}-${max}, Raw=0x${new Uint8Array(randomBuffer).toHex()}, Output=${minInt + offset}`,
)
: undefined;
return minInt + offset;
}
}
},
};

View File

@@ -0,0 +1,16 @@
import type { MirrorFileEntry } from '../types.js';
export function generateDownloadLinks(url: string, mirrorFileDb: MirrorFileEntry[]) {
const cleanUrl = new URL(url);
cleanUrl.search = '';
const mirrorEntry = mirrorFileDb.find((g) => g.orig.includes(cleanUrl.toString()));
const links: string[] = [];
if (!mirrorEntry || mirrorEntry.origStatus === true) {
links.push(`<a href="${url}" target="_blank">Orig</a>`);
}
if (mirrorEntry) {
links.push(`<a href="${mirrorEntry.mirror}" target="_blank">Mirror</a>`);
}
return links.join(' / ');
}

157
pages-v2/src/legacy/web.ts Normal file
View File

@@ -0,0 +1,157 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../api.js';
import type { StoredData } from '../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../utils/constants.js';
const apiTypes = ['announcement', 'banner', 'main_bg_image', 'sidebar', 'single_ent'];
export async function renderWeb(container: HTMLElement) {
for (const target of gameTargets) {
const section = document.createElement('div');
section.className = 'mb-5';
section.innerHTML = `<h3 class="mb-3">${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}</h3>`;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
// Language Selector
const langSelectGroup = document.createElement('div');
langSelectGroup.className = 'input-group mb-3';
langSelectGroup.innerHTML = '<span class="input-group-text">Language</span>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) {
option.selected = true;
}
langSelect.appendChild(option);
});
langSelectGroup.appendChild(langSelect);
if (langs.length <= 1) {
langSelectGroup.style.display = 'none';
}
section.appendChild(langSelectGroup);
const accordion = document.createElement('div');
accordion.className = 'accordion';
accordion.id = `accordion-web-${target.dirName}`;
const renderApiList = async (lang: string) => {
accordion.innerHTML = '<div class="text-muted p-2">Loading...</div>';
const results = await Promise.all(
apiTypes.map(async (apiType) => {
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/${apiType}/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (!data || data.length === 0) return null;
return { apiType, list: [...data].reverse() };
} catch (e) {
console.warn(`Failed to load ${url}`, e);
return null;
}
}),
);
accordion.innerHTML = '';
const validResults = results.filter((r): r is NonNullable<typeof r> => r !== null);
if (validResults.length === 0) {
accordion.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
validResults.forEach(({ apiType, list }, idx) => {
const itemId = `web-${target.dirName}-${lang}-${apiType}`;
const item = document.createElement('div');
item.className = 'accordion-item';
// Header
const header = document.createElement('h2');
header.className = 'accordion-header';
header.id = `heading-${itemId}`;
header.innerHTML = `
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${itemId}" aria-expanded="false" aria-controls="collapse-${itemId}">
${apiType}
</button>
`;
item.appendChild(header);
// Body
const collapse = document.createElement('div');
collapse.id = `collapse-${itemId}`;
collapse.className = 'accordion-collapse collapse';
collapse.setAttribute('aria-labelledby', `heading-${itemId}`);
collapse.setAttribute('data-bs-parent', `#${accordion.id}`);
const body = document.createElement('div');
body.className = 'accordion-body';
// Select for UpdatedAt
const selectGroup = document.createElement('div');
selectGroup.className = 'input-group mb-3';
selectGroup.innerHTML = `<span class="input-group-text">History</span>`;
const select = document.createElement('select');
select.className = 'form-select';
select.ariaLabel = 'Select version';
list.forEach((entry, idx) => {
const dateStr = DateTime.fromISO(entry.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${dateStr}`;
select.appendChild(option);
});
selectGroup.appendChild(select);
body.appendChild(selectGroup);
// Content Area
const contentArea = document.createElement('pre');
contentArea.className = 'p-3 border rounded overflow-auto';
contentArea.style.maxHeight = '500px';
contentArea.style.fontSize = '0.875rem';
const updateContent = (index: number) => {
const entry = list[index];
if (entry) {
contentArea.textContent = JSON.stringify(entry.rsp, null, 2);
}
};
// Initial render for this item
updateContent(0);
select.addEventListener('change', (e) => {
const val = parseInt((e.target as HTMLSelectElement).value, 10);
updateContent(val);
});
body.appendChild(contentArea);
collapse.appendChild(body);
item.appendChild(collapse);
accordion.appendChild(item);
});
};
langSelect.addEventListener('change', (e) => {
renderApiList((e.target as HTMLSelectElement).value);
});
section.appendChild(accordion);
container.appendChild(section);
// Initial load
if (defaultLang) {
renderApiList(defaultLang);
}
}
}

View File

@@ -0,0 +1,14 @@
import { renderAnnouncement } from './webPretty/announcement.js';
import { renderBanner } from './webPretty/banner.js';
import { renderMainBgImage } from './webPretty/mainBgImage.js';
import { renderSidebar } from './webPretty/sidebar.js';
import { renderSingleEnt } from './webPretty/singleEnt.js';
export async function renderWebPretty(container: HTMLElement) {
container.innerHTML = '';
await renderAnnouncement(container);
await renderBanner(container);
await renderMainBgImage(container);
await renderSingleEnt(container);
await renderSidebar(container);
}

View File

@@ -0,0 +1,179 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebAnnouncement, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderAnnouncement(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseAnnouncement');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Announcement</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseAnnouncement';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading announcements...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/announcement/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebAnnouncement>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
const tabsMap = new Map<
string,
{ tabName: string; announcements: Map<string, LauncherWebAnnouncement['tabs'][0]['announcements'][0]> }
>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.tabs) continue;
for (const tab of entry.rsp.tabs) {
if (!tabsMap.has(tab.tab_id)) {
tabsMap.set(tab.tab_id, { tabName: tab.tabName, announcements: new Map() });
}
const targetTab = tabsMap.get(tab.tab_id)!;
for (const ann of tab.announcements) {
if (!targetTab.announcements.has(ann.id)) {
targetTab.announcements.set(ann.id, ann);
}
}
}
}
contentDiv.innerHTML = '';
if (tabsMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No announcements found.</div>';
return;
}
for (const [_tabId, tabData] of tabsMap) {
const card = document.createElement('div');
card.className = 'card mb-4 shadow-sm';
const cardHeader = document.createElement('div');
cardHeader.className = 'card-header bg-secondary text-white fw-bold py-1';
cardHeader.textContent = tabData.tabName;
card.appendChild(cardHeader);
const listGroup = document.createElement('ul');
listGroup.className = 'list-group list-group-flush';
const sortedAnnouncements = Array.from(tabData.announcements.values()).sort(
(a, b) => parseInt(b.start_ts, 10) - parseInt(a.start_ts, 10),
);
for (const ann of sortedAnnouncements) {
const item = document.createElement('li');
item.className = 'list-group-item py-2';
const date = DateTime.fromMillis(parseInt(ann.start_ts, 10)).toFormat('yyyy/MM/dd HH:mm');
item.innerHTML = `
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="text-muted small" style="min-width: 120px;">${date}</span>
<span class="flex-grow-1 fw-bold">${ann.content}</span>
<div class="d-flex align-items-center gap-2">
${ann.need_token ? '<span class="badge bg-warning text-dark px-1 py-0" style="font-size: 0.7rem;">Auth</span>' : ''}
${ann.jump_url ? `<a href="${ann.jump_url}" target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;">Link</a>` : ''}
<span class="text-muted border-start ps-2" style="font-size: 0.7rem;">ID:${ann.id}</span>
</div>
</div>
`;
listGroup.appendChild(item);
}
card.appendChild(listGroup);
contentDiv.appendChild(card);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,175 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebBanner, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderBanner(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseBanner');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Banner</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseBanner';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading banners...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/banner/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebBanner>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect unique banners by ID from the entire history
const bannerMap = new Map<string, { banner: LauncherWebBanner['banners'][0]; firstSeen: string }>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.banners) continue;
for (const banner of entry.rsp.banners) {
if (!bannerMap.has(banner.id)) {
bannerMap.set(banner.id, { banner, firstSeen: entry.updatedAt });
}
}
}
contentDiv.innerHTML = '';
if (bannerMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No banners found.</div>';
return;
}
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-3 row-cols-lg-4 g-3';
contentDiv.appendChild(row);
for (const [id, { banner, firstSeen }] of bannerMap) {
const col = document.createElement('div');
col.className = 'col';
const dateStr = DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm');
const mirrorUrl = getMirrorUrl(banner.url);
const linkUrl = banner.jump_url || mirrorUrl;
col.innerHTML = `
<a href="${linkUrl}" target="_blank" class="text-decoration-none text-reset">
<div class="card h-100 shadow-sm border-0">
<div class="position-relative">
<img src="${mirrorUrl}" class="card-img-top rounded" alt="Banner Image" style="object-fit: cover; aspect-ratio: 16 / 9;">
<div class="position-absolute top-0 end-0 p-1">
${banner.need_token ? '<span class="badge bg-warning text-dark" style="font-size: 0.6rem;">Auth</span>' : ''}
</div>
</div>
<div class="card-body py-1 px-1">
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
<span class="text-muted text-truncate me-1">ID: ${id}</span>
<span class="text-muted flex-shrink-0">${dateStr}</span>
</div>
</div>
</div>
</a>
`;
row.appendChild(col);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,174 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebMainBgImage, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderMainBgImage(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseMainBgImage');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Main Background Image</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseMainBgImage';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading background images...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/main_bg_image/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebMainBgImage>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect unique images by MD5 from the entire history
const imageMap = new Map<string, { image: LauncherWebMainBgImage['main_bg_image']; firstSeen: string }>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.main_bg_image) continue;
const img = entry.rsp.main_bg_image;
if (!imageMap.has(img.md5)) {
imageMap.set(img.md5, { image: img, firstSeen: entry.updatedAt });
}
}
contentDiv.innerHTML = '';
if (imageMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No images found.</div>';
return;
}
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3';
contentDiv.appendChild(row);
for (const [md5, { image, firstSeen }] of imageMap) {
const col = document.createElement('div');
col.className = 'col';
const dateStr = DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm');
const mirrorUrl = getMirrorUrl(image.url);
const linkUrl = image.video_url ? getMirrorUrl(image.video_url) : mirrorUrl;
col.innerHTML = `
<a href="${linkUrl}" target="_blank" class="text-decoration-none text-reset">
<div class="card h-100 shadow-sm border-0">
<div class="position-relative">
<img src="${mirrorUrl}" class="card-img-top rounded" alt="Background Image" style="object-fit: cover; aspect-ratio: 16 / 9;">
<div class="position-absolute top-0 end-0 p-1">
${image.video_url ? '<span class="badge bg-primary" style="font-size: 0.6rem;">Video</span>' : ''}
</div>
</div>
<div class="card-body py-1 px-1">
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
<span class="text-muted text-truncate font-monospace me-1">${md5}</span>
<span class="text-muted flex-shrink-0">${dateStr}</span>
</div>
</div>
</div>
</a>
`;
row.appendChild(col);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,195 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebSidebar, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderSidebar(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseSidebar');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Sidebar</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseSidebar';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading sidebar data...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/sidebar/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebSidebar>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect the latest sidebar configuration
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
// We only show the latest version as sidebars are usually state-dependent
const latest = sortedData[0];
if (!latest || !latest.rsp || !latest.rsp.sidebars) {
contentDiv.innerHTML = '<div class="text-muted p-2">No active sidebars.</div>';
return;
}
contentDiv.innerHTML = '';
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3';
contentDiv.appendChild(row);
for (const item of latest.rsp.sidebars) {
const col = document.createElement('div');
col.className = 'col';
const card = document.createElement('div');
card.className = 'card h-100 shadow-sm';
let innerHtml = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="card-title mb-0">${item.media}</h5>
${item.need_token ? '<span class="badge bg-warning text-dark lh-1 py-1">Auth</span>' : ''}
</div>
`;
if (item.pic) {
innerHtml += `
<div class="mb-3">
<img src="${getMirrorUrl(item.pic.url)}" class="img-fluid rounded" alt="${item.pic.description}">
<p class="text-muted small mt-1 mb-0">${item.pic.description}</p>
</div>
`;
}
if (item.jump_url) {
innerHtml += `
<a href="${item.jump_url}" target="_blank" class="btn btn-sm btn-outline-primary mb-2 w-100">Open Link</a>
`;
}
if (item.sidebar_labels && item.sidebar_labels.length > 0) {
innerHtml += '<div class="list-group list-group-flush border-top mt-2">';
for (const label of item.sidebar_labels) {
innerHtml += `
<a href="${label.jump_url}" target="_blank" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2">
<span style="font-size: 0.9rem;">${label.content}</span>
<div class="d-flex gap-1">
${label.need_token ? '<span class="badge bg-warning text-dark lh-1 py-1">Auth</span>' : ''}
<i class="bi bi-box-arrow-up-right small text-muted"></i>
</div>
</a>
`;
}
innerHtml += '</div>';
}
innerHtml += '</div>';
card.innerHTML = innerHtml;
col.appendChild(card);
row.appendChild(col);
}
const infoDiv = document.createElement('div');
infoDiv.className = 'text-muted small mt-3 text-end';
infoDiv.textContent = `Last updated: ${DateTime.fromISO(latest.updatedAt).toFormat('yyyy/MM/dd HH:mm')}`;
contentDiv.appendChild(infoDiv);
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}

View File

@@ -0,0 +1,205 @@
import { DateTime } from 'luxon';
import { fetchJson } from '../../api.js';
import type { LauncherWebSingleEnt, StoredData } from '../../types.js';
import { BASE_URL, gameTargets, launcherWebApiLang } from '../../utils/constants.js';
export async function renderSingleEnt(container: HTMLElement) {
const outerCard = document.createElement('div');
outerCard.className = 'card mb-3';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.style.cursor = 'pointer';
header.setAttribute('data-bs-toggle', 'collapse');
header.setAttribute('data-bs-target', '#collapseSingleEnt');
header.setAttribute('role', 'button');
header.innerHTML = '<h3 class="h4 mb-0">Single Ent.</h3><i class="bi bi-chevron-down"></i>';
outerCard.appendChild(header);
const collapseDiv = document.createElement('div');
collapseDiv.id = 'collapseSingleEnt';
collapseDiv.className = 'collapse';
outerCard.appendChild(collapseDiv);
const outerCardBody = document.createElement('div');
outerCardBody.className = 'card-body';
collapseDiv.appendChild(outerCardBody);
// --- UI Controls ---
const controls = document.createElement('div');
controls.className = 'row g-3 mb-4';
const targetCol = document.createElement('div');
targetCol.className = 'col-md-6';
targetCol.innerHTML = '<label class="form-label fw-bold">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-select';
gameTargets.forEach((target, idx) => {
const option = document.createElement('option');
option.value = idx.toString();
option.textContent = `${target.region === 'cn' ? 'China' : 'Global'} - ${target.name}`;
targetSelect.appendChild(option);
});
targetCol.appendChild(targetSelect);
const langCol = document.createElement('div');
langCol.className = 'col-md-6';
langCol.innerHTML = '<label class="form-label fw-bold">Language</label>';
const langSelect = document.createElement('select');
langSelect.className = 'form-select';
langCol.appendChild(langSelect);
controls.appendChild(targetCol);
controls.appendChild(langCol);
outerCardBody.appendChild(controls);
const contentDiv = document.createElement('div');
outerCardBody.appendChild(contentDiv);
// --- Logic ---
const updateLanguages = () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
langSelect.innerHTML = '';
langs.forEach((lang) => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
if (lang === defaultLang) option.selected = true;
langSelect.appendChild(option);
});
langCol.style.display = langs.length <= 1 ? 'none' : 'block';
};
const getMirrorUrl = (url: string) => {
if (!url) return '';
try {
const u = new URL(url);
return `https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output/raw/${u.hostname}${u.pathname}`;
} catch {
return url;
}
};
const renderContent = async () => {
const targetIdx = parseInt(targetSelect.value, 10);
const target = gameTargets[targetIdx]!;
const lang = langSelect.value;
if (!lang) {
contentDiv.innerHTML = '<div class="text-muted p-2">No language selected.</div>';
return;
}
contentDiv.innerHTML = '<div class="text-muted p-2">Loading single entry data...</div>';
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/single_ent/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebSingleEnt>[]>(url);
if (!data || data.length === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
// Collect unique visuals by MD5 from the entire history
const entMap = new Map<string, { ent: LauncherWebSingleEnt['single_ent']; firstSeen: string }>();
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
for (const entry of sortedData) {
if (!entry.rsp || !entry.rsp.single_ent) continue;
const ent = entry.rsp.single_ent;
const key = ent.version_md5 || ent.version_url;
if (!entMap.has(key)) {
entMap.set(key, { ent, firstSeen: entry.updatedAt });
}
}
contentDiv.innerHTML = '';
if (entMap.size === 0) {
contentDiv.innerHTML = '<div class="text-muted p-2">No data found.</div>';
return;
}
const row = document.createElement('div');
row.className = 'row row-cols-1 row-cols-md-2 g-4';
contentDiv.appendChild(row);
for (const [_key, { ent, firstSeen }] of entMap) {
const col = document.createElement('div');
col.className = 'col';
const card = document.createElement('div');
card.className = 'card h-100 shadow-sm';
let innerHtml = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">First seen: ${DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm')}</span>
${ent.need_token ? '<span class="badge bg-warning text-dark">Auth</span>' : ''}
</div>
<div class="mb-3">
<label class="form-label small fw-bold">Version Image</label>
<a href="${getMirrorUrl(ent.version_url)}" target="_blank">
<img src="${getMirrorUrl(ent.version_url)}" class="img-fluid rounded border" alt="Version Image">
</a>
<p class="text-muted font-monospace mt-1" style="font-size: 0.7rem; word-break: break-all;">MD5: ${ent.version_md5}</p>
</div>
`;
if (ent.button_url) {
innerHtml += `
<div class="mb-3">
<label class="form-label small fw-bold">Action Button</label>
<div class="d-flex gap-2">
<div class="flex-grow-1">
<img src="${getMirrorUrl(ent.button_url)}" class="img-fluid rounded border bg-light" alt="Button" style="max-height: 60px;">
<p class="text-muted small mt-1 mb-0">Normal</p>
</div>
${
ent.button_hover_url
? `
<div class="flex-grow-1">
<img src="${getMirrorUrl(ent.button_hover_url)}" class="img-fluid rounded border bg-light" alt="Button Hover" style="max-height: 60px;">
<p class="text-muted small mt-1 mb-0">Hover</p>
</div>
`
: ''
}
</div>
</div>
`;
}
if (ent.jump_url) {
innerHtml += `
<a href="${ent.jump_url}" target="_blank" class="btn btn-sm btn-outline-primary w-100">Jump URL</a>
`;
}
innerHtml += '</div>';
card.innerHTML = innerHtml;
col.appendChild(card);
row.appendChild(col);
}
} catch (e) {
console.warn(`Failed to load ${url}`, e);
contentDiv.innerHTML = '<div class="text-danger p-2">Failed to load data.</div>';
}
};
targetSelect.addEventListener('change', () => {
updateLanguages();
renderContent();
});
langSelect.addEventListener('change', renderContent);
updateLanguages();
renderContent();
container.appendChild(outerCard);
}