refactor(pages): enhance web rendering components and type safety

This commit is contained in:
daydreamer-json
2026-03-08 07:39:12 +09:00
parent 9283e7e6ee
commit bf90af047b
9 changed files with 1009 additions and 7 deletions

View File

@@ -4,7 +4,7 @@ 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 { renderWeb } from './renderers/web.js';
import { renderWebPretty } from './renderers/webPretty.js';
import type { MirrorFileEntry } from './types.js';
import { BASE_URL } from './utils/constants.js';
@@ -33,7 +33,7 @@ async function main() {
<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" type="button">Web (Raw)</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>
@@ -41,7 +41,7 @@ async function main() {
<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" role="tabpanel"></div>
<div class="tab-pane fade" id="tab-web-pretty" role="tabpanel"></div>
</div>
`;
contentDiv.innerHTML = tabsHtml;
@@ -52,6 +52,6 @@ async function main() {
renderPatches(document.getElementById('tab-patch')!, mirrorFileDb),
renderResources(document.getElementById('tab-resources')!),
renderLaunchers(document.getElementById('tab-launcher')!, mirrorFileDb),
renderWeb(document.getElementById('tab-web')!),
renderWebPretty(document.getElementById('tab-web-pretty')!),
]);
}

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

@@ -9,3 +9,64 @@ export interface StoredData<T> {
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

@@ -300,7 +300,6 @@ async function fetchAndSaveLatestGamePatches(gameTargets: GameTarget[]) {
async function fetchAndSaveLatestGameResources(gameTargets: GameTarget[]) {
logger.debug('Fetching latestGameRes ...');
const platforms = ['Windows', 'Android', 'iOS', 'PlayStation'] as const;
const subChns = appConfig.network.api.akEndfield.subChannel;
const filteredTargets = gameTargets.filter(
(t) => t.channel !== appConfig.network.api.akEndfield.channel.cnWinRelBilibili,
@@ -698,9 +697,9 @@ async function mainCmdHandler() {
await fetchAndSaveLatestGames(gameTargets);
await fetchAndSaveLatestGamePatches(gameTargets);
await fetchAndSaveLatestGameResources(gameTargets);
await fetchAndSaveAllGameResRawData(gameTargets);
await fetchAndSaveLatestLauncher(launcherTargets);
await fetchAndSaveLatestWebApis(gameTargets);
await fetchAndSaveLatestLauncher(launcherTargets);
await fetchAndSaveAllGameResRawData(gameTargets);
await checkMirrorFileDbStatus();
await processMirrorQueue();