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,45 @@
import endminImg from '../../assets/img/endmin_thumbsup_640px.webp';
export default function AboutTab() {
return (
<div className='about-content'>
<h2>About this website</h2>
<p>
This site functions as a simplified display page for data from the <strong>ak-endfield-api-archive</strong>{' '}
repository.
</p>
<p>
The{' '}
<a href='https://github.com/daydreamer-json/ak-endfield-api-archive' target='_blank' rel='noreferrer'>
<strong>ak-endfield-api-archive</strong>
</a>{' '}
repository automatically records various API data changes for Arknights: Endfield. It also archives not only API
changes but also some packages and raw binary data. This is useful for certain users interested in analyzing and
researching game data.
</p>
<h3>Disclaimer</h3>
<p>
This project has no affiliation with Hypergryph (GRYPHLINE) and was created solely for{' '}
<strong>private use, educational, and research purposes.</strong>
<br />
Copyright for the archived API data and binary data belongs to their respective copyright holders.
</p>
<p>
I assume no responsibility whatsoever. <strong>PLEASE USE IT AT YOUR OWN RISK.</strong>
</p>
<h3>Thanks</h3>
<table className='table table-sm table-borderless w-auto mb-2 bg-transparent'>
<tbody>
<tr className='bg-transparent'>
<td className='fw-bold bg-transparent'>Vivi029</td>
<td className='bg-transparent'>Added Windows Google Play Games channel</td>
</tr>
</tbody>
</table>
<img id='endmin-thumbsup' src={endminImg} alt='Endmin Thumbs Up' />
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import type { MirrorFileEntry, StoredData } from '../../types';
import { fetchJson } from '../../utils/api';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../../utils/constants';
import math from '../../utils/math';
import { generateDownloadLinks } from '../../utils/ui';
interface Props {
mirrorFileDb: MirrorFileEntry[];
}
interface GamePackageData {
targetName: string;
region: 'os' | 'cn';
dirName: string;
versions: Array<{
version: string;
dateStr: string;
packedSizeStr: string;
unpackedSizeStr: string;
packs: Array<{
fileName: string;
md5: string;
sizeStr: string;
url: string;
}>;
}>;
}
export default function GamePackagesTab({ mirrorFileDb }: Props) {
const [packages, setPackages] = useState<GamePackageData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
const promises = gameTargets.map(async (target) => {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
const list = [...data].reverse();
const versions = list
.map((e) => {
if (!e) return null;
const version = e.rsp.version;
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
const packSizes = e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size));
const packedSize = math.arrayTotal(packSizes);
const totalSize = parseInt(e.rsp.pkg.total_size);
const unpackedSize = totalSize - packedSize;
const packs = e.rsp.pkg.packs.map((f: any) => ({
fileName: new URL(f.url).pathname.split('/').pop() ?? '',
md5: f.md5,
sizeStr: math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS),
url: f.url,
}));
return {
version,
dateStr,
packedSizeStr: math.formatFileSize(packedSize, FILE_SIZE_OPTS),
unpackedSizeStr: math.formatFileSize(unpackedSize, FILE_SIZE_OPTS),
packs,
};
})
.filter((v): v is NonNullable<typeof v> => v !== null);
return {
targetName: target.name,
region: target.region,
dirName: target.dirName,
versions,
};
} catch (err) {
return null;
}
});
const results = await Promise.all(promises);
const validResults = results.filter((r): r is GamePackageData => r !== null);
const sortedResults = gameTargets
.map((t) => validResults.find((r) => r.dirName === t.dirName))
.filter((r): r is GamePackageData => r !== undefined && r !== null);
setPackages(sortedResults);
setLoading(false);
};
fetchData();
}, []);
if (loading) {
return (
<div className='text-center p-5'>
<div className='spinner-border' role='status'></div>
</div>
);
}
return (
<div>
{packages.map((pkg) => (
<div key={pkg.dirName} className='mb-5'>
<h3 className='mb-3'>
{pkg.region === 'cn' ? 'China' : 'Global'}, {pkg.targetName}
</h3>
<div className='accordion' id={`accordion-game-${pkg.dirName}`}>
{pkg.versions.map((ver, idx) => {
const itemId = `game-${pkg.dirName}-${idx}`;
return (
<div className='accordion-item' key={itemId}>
<h2 className='accordion-header' id={`heading-${itemId}`}>
<button
className='accordion-button collapsed'
type='button'
data-bs-toggle='collapse'
data-bs-target={`#collapse-${itemId}`}
aria-expanded='false'
aria-controls={`collapse-${itemId}`}
>
<div className='d-flex w-100 justify-content-between me-3'>
<span className='fw-bold'>{ver.version}</span>
<span className='text-muted small align-bottom'>{ver.dateStr}</span>
</div>
</button>
</h2>
<div
id={`collapse-${itemId}`}
className='accordion-collapse collapse'
aria-labelledby={`heading-${itemId}`}
data-bs-parent={`#accordion-game-${pkg.dirName}`}
>
<div className='accordion-body'>
<table className='table table-sm table-transparent table-borderless w-auto mb-2'>
<tbody>
<tr>
<td>Unpacked Size</td>
<td className='text-end fw-bold'>{ver.unpackedSizeStr}</td>
</tr>
<tr>
<td>Packed Size</td>
<td className='text-end fw-bold'>{ver.packedSizeStr}</td>
</tr>
</tbody>
</table>
<div className='table-responsive'>
<table className='table table-striped table-bordered table-sm align-middle text-nowrap'>
<thead>
<tr>
<th>File</th>
<th>MD5 Checksum</th>
<th className='text-end'>Size</th>
<th className='text-center'>DL</th>
</tr>
</thead>
<tbody>
{ver.packs.map((pack, pIdx) => (
<tr key={pIdx}>
<td>{pack.fileName}</td>
<td>
<code>{pack.md5}</code>
</td>
<td className='text-end'>{pack.sizeStr}</td>
<td
className='text-center'
dangerouslySetInnerHTML={{ __html: generateDownloadLinks(pack.url, mirrorFileDb) }}
></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import type { MirrorFileEntry, StoredData } from '../../types';
import { fetchJson } from '../../utils/api';
import { BASE_URL, FILE_SIZE_OPTS, launcherTargets } from '../../utils/constants';
import math from '../../utils/math';
import { generateDownloadLinks } from '../../utils/ui';
interface Props {
mirrorFileDb: MirrorFileEntry[];
}
interface LauncherData {
regionId: string;
app: string;
zips: Array<{
dateStr: string;
version: string;
fileName: string;
md5: string;
unpackedStr: string;
packedStr: string;
url: string;
}>;
exes: Array<{
dateStr: string;
version: string;
fileName: string;
sizeStr: string;
url: string;
}>;
}
export default function LauncherTab({ mirrorFileDb }: Props) {
const [data, setData] = useState<LauncherData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
const results: LauncherData[] = [];
for (const region of launcherTargets) {
for (const app of region.apps) {
const itemData: LauncherData = {
regionId: region.id,
app,
zips: [],
exes: [],
};
// Zip
try {
const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`;
const dataZip = await fetchJson<StoredData<any>[]>(urlZip);
itemData.zips = [...dataZip].reverse().map((e) => {
const fileName = new URL(e.rsp.zip_package_url).pathname.split('/').pop() ?? '';
const unpacked = parseInt(e.rsp.total_size) - parseInt(e.rsp.package_size);
return {
dateStr: DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
version: e.rsp.version,
fileName,
md5: e.rsp.md5,
unpackedStr: math.formatFileSize(unpacked, FILE_SIZE_OPTS),
packedStr: math.formatFileSize(parseInt(e.rsp.package_size), FILE_SIZE_OPTS),
url: e.rsp.zip_package_url,
};
});
} catch (e) {}
// Exe
try {
const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`;
const dataExe = await fetchJson<StoredData<any>[]>(urlExe);
itemData.exes = [...dataExe].reverse().map((e) => {
const fileName = new URL(e.rsp.exe_url).pathname.split('/').pop() ?? '';
return {
dateStr: DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
version: e.rsp.version,
fileName,
sizeStr: math.formatFileSize(parseInt(e.rsp.exe_size), FILE_SIZE_OPTS),
url: e.rsp.exe_url,
};
});
} catch (e) {}
if (itemData.zips.length > 0 || itemData.exes.length > 0) {
results.push(itemData);
}
}
}
setData(results);
setLoading(false);
};
fetchData();
}, []);
if (loading) {
return (
<div className='text-center p-5'>
<div className='spinner-border' role='status'></div>
</div>
);
}
return (
<div>
{data.map((item) => (
<div key={`${item.regionId}-${item.app}`} className='mb-5'>
<h3 className='mb-3'>
{item.regionId.toUpperCase()} {item.app}
</h3>
<div className='accordion' id={`accordion-launcher-${item.regionId}-${item.app}`}>
{item.zips.length > 0 && (
<div className='accordion-item'>
<h2 className='accordion-header' id={`heading-zip-${item.regionId}-${item.app}`}>
<button
className='accordion-button collapsed'
type='button'
data-bs-toggle='collapse'
data-bs-target={`#collapse-zip-${item.regionId}-${item.app}`}
aria-expanded='false'
aria-controls={`collapse-zip-${item.regionId}-${item.app}`}
>
Launcher Packages (zip)
</button>
</h2>
<div
id={`collapse-zip-${item.regionId}-${item.app}`}
className='accordion-collapse collapse'
data-bs-parent={`#accordion-launcher-${item.regionId}-${item.app}`}
>
<div className='accordion-body'>
<div className='table-responsive'>
<table className='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 className='text-end'>Unpacked</th>
<th className='text-end'>Packed</th>
<th className='text-center'>DL</th>
</tr>
</thead>
<tbody>
{item.zips.map((z, idx) => (
<tr key={idx}>
<td>{z.dateStr}</td>
<td>{z.version}</td>
<td>{z.fileName}</td>
<td>
<code>{z.md5}</code>
</td>
<td className='text-end'>{z.unpackedStr}</td>
<td className='text-end'>{z.packedStr}</td>
<td
className='text-center'
dangerouslySetInnerHTML={{ __html: generateDownloadLinks(z.url, mirrorFileDb) }}
></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{item.exes.length > 0 && (
<div className='accordion-item'>
<h2 className='accordion-header' id={`heading-exe-${item.regionId}-${item.app}`}>
<button
className='accordion-button collapsed'
type='button'
data-bs-toggle='collapse'
data-bs-target={`#collapse-exe-${item.regionId}-${item.app}`}
aria-expanded='false'
aria-controls={`collapse-exe-${item.regionId}-${item.app}`}
>
Launcher Packages (Installer)
</button>
</h2>
<div
id={`collapse-exe-${item.regionId}-${item.app}`}
className='accordion-collapse collapse'
data-bs-parent={`#accordion-launcher-${item.regionId}-${item.app}`}
>
<div className='accordion-body'>
<div className='table-responsive'>
<table className='table table-striped table-bordered table-sm align-middle text-nowrap'>
<thead>
<tr>
<th>Date</th>
<th>Version</th>
<th>File</th>
<th className='text-end'>Size</th>
<th className='text-center'>DL</th>
</tr>
</thead>
<tbody>
{item.exes.map((e, idx) => (
<tr key={idx}>
<td>{e.dateStr}</td>
<td>{e.version}</td>
<td>{e.fileName}</td>
<td className='text-end'>{e.sizeStr}</td>
<td
className='text-center'
dangerouslySetInnerHTML={{ __html: generateDownloadLinks(e.url, mirrorFileDb) }}
></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,307 @@
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import type { MirrorFileEntry, StoredData } from '../../types';
import { fetchJson } from '../../utils/api';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets, launcherTargets } from '../../utils/constants';
import math from '../../utils/math';
interface Props {
mirrorFileDb: MirrorFileEntry[];
}
interface GameVersionData {
version: string;
date: string;
}
interface OverviewTableData {
region: string;
channelName: string;
version: string;
packedSize: string;
unpackedSize: string;
}
interface ResourceVersionData {
platform: string;
version: string;
date: string;
}
export default function OverviewTab({ mirrorFileDb }: Props) {
const [globalPkg, setGlobalPkg] = useState<GameVersionData | null>(null);
const [chinaPkg, setChinaPkg] = useState<GameVersionData | null>(null);
const [tableData, setTableData] = useState<OverviewTableData[]>([]);
const [resourceData, setResourceData] = useState<ResourceVersionData[]>([]);
const [mirrorStats, setMirrorStats] = useState<string>('---');
useEffect(() => {
// 1. Latest Version Info
const fetchLatestVersion = async (url: string) => {
try {
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'),
};
} catch {
return { version: '---', date: '---' };
}
};
fetchLatestVersion(`${BASE_URL}/akEndfield/launcher/game/6/all.json`).then(setGlobalPkg);
fetchLatestVersion(`${BASE_URL}/akEndfield/launcher/game/1/all.json`).then(setChinaPkg);
// 2. Game Packages Table & Mirror Stats Calculation
const calculateStatsAndTable = async () => {
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 newTableData: OverviewTableData[] = [];
// 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;
newTableData.push({
region: target.region === 'cn' ? 'China' : 'Global',
channelName: target.name,
version: version,
packedSize: math.formatFileSize(packedSize, FILE_SIZE_OPTS),
unpackedSize: math.formatFileSize(unpackedSize, FILE_SIZE_OPTS),
});
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);
}
}
setTableData(newTableData);
// Patches (only for stats)
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 {}
}
// Launchers (only for stats)
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 {}
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 {}
}
}
setMirrorStats(math.formatFileSize(totalMirrorSize, { ...FILE_SIZE_OPTS, unit: 'G' }));
};
calculateStatsAndTable();
// 3. Latest Game Resources
const fetchResources = async () => {
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 newResourceData = resPlatforms.map((p, i) => {
const item = resData[i];
if (!item) {
return { platform: p, version: '---', date: '' };
}
const initialRes = item.rsp.resources.find((e: any) => e.name === 'initial');
const mainRes = item.rsp.resources.find((e: any) => e.name === 'main');
let version = '---';
if (initialRes && mainRes) {
version = initialRes.version === mainRes.version ? mainRes.version : item.rsp.res_version;
}
return {
platform: p,
version: version,
date: DateTime.fromISO(item.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'),
};
});
setResourceData(newResourceData);
};
fetchResources();
}, [mirrorFileDb]);
if (!globalPkg || !chinaPkg) {
return (
<div className='text-center'>
<div className='spinner-border' role='status'></div>
<p>Loading overview...</p>
</div>
);
}
return (
<div>
<div className='card mb-3'>
<div className='card-body'>
<h3 className='card-title mb-4'>Latest Game Packages</h3>
<div className='row text-center mb-4'>
<div className='col-md-6 mb-md-0 mb-3'>
<p className='lh-1 mb-0'>
<span className='fw-bold fs-1'>{globalPkg.version}</span>
<br />
<span className='small opacity-75' style={{ lineHeight: 1.5 }}>
{globalPkg.date}
</span>
<br />
Latest Version (Global)
</p>
</div>
<div className='col-md-6'>
<p className='lh-1 mb-0'>
<span className='fw-bold fs-1'>{chinaPkg.version}</span>
<br />
<span className='small opacity-75' style={{ lineHeight: 1.5 }}>
{chinaPkg.date}
</span>
<br />
Latest Version (China)
</p>
</div>
</div>
<div className='table-responsive'>
<table className='table table-striped table-bordered table-sm align-middle text-nowrap'>
<thead>
<tr>
<th>Region</th>
<th>Channel</th>
<th>Version</th>
<th className='text-end'>Packed</th>
<th className='text-end'>Unpacked</th>
</tr>
</thead>
<tbody>
{tableData.map((row, i) => (
<tr key={i}>
<td>{row.region}</td>
<td>{row.channelName}</td>
<td>{row.version}</td>
<td className='text-end'>{row.packedSize}</td>
<td className='text-end'>{row.unpackedSize}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className='card mb-3'>
<div className='card-body'>
<h3 className='card-title mb-4'>Latest Game Resources</h3>
<div className='row text-center'>
{resourceData.map((res, i) => (
<div key={i} className='col-12 col-md-6 col-lg-3 mb-3 mb-lg-0'>
<p className='lh-1 mb-0'>
<span className='fw-bold fs-1'>{res.version}</span>
<br />
{res.date && (
<>
<span className='small opacity-75' style={{ lineHeight: 1.5 }}>
{res.date}
</span>
<br />
</>
)}
{res.platform}
</p>
</div>
))}
</div>
</div>
</div>
<div className='card'>
<div className='card-body'>
<h3 className='card-title'>Mirror Statistics</h3>
<p className='card-text text-center lh-1'>
<span className='fw-bold fs-1'>{mirrorStats}</span>
<br />
uploaded to mirror
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import type { MirrorFileEntry, StoredData } from '../../types';
import { fetchJson } from '../../utils/api';
import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../../utils/constants';
import math from '../../utils/math';
import { generateDownloadLinks } from '../../utils/ui';
interface Props {
mirrorFileDb: MirrorFileEntry[];
}
interface PatchData {
targetName: string;
region: 'os' | 'cn';
dirName: string;
patches: Array<{
version: string;
reqVersion: string;
dateStr: string;
packedSizeStr: string;
unpackedSizeStr: string;
files: Array<{
fileName: string;
md5: string;
sizeStr: string;
url: string;
}>;
}>;
}
export default function PatchesTab({ mirrorFileDb }: Props) {
const [patchesData, setPatchesData] = useState<PatchData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
const promises = gameTargets.map(async (target) => {
const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`;
try {
const data = await fetchJson<StoredData<any>[]>(url);
if (data.length === 0) return null;
const patches = [...data]
.reverse()
.map((e) => {
if (!e.rsp.patch) return null;
const version = e.rsp.version;
const reqVersion = e.rsp.request_version;
const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss');
let packedSize = 0;
if (e.rsp.patch.patches) {
packedSize = math.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size)));
}
const totalSize = parseInt(e.rsp.patch.total_size);
const unpackedSize = totalSize - packedSize;
const files = [];
if (e.rsp.patch.url) {
files.push({
fileName: new URL(e.rsp.patch.url).pathname.split('/').pop() ?? '',
md5: e.rsp.patch.md5,
sizeStr: math.formatFileSize(parseInt(e.rsp.patch.package_size), FILE_SIZE_OPTS),
url: e.rsp.patch.url,
});
}
if (e.rsp.patch.patches) {
e.rsp.patch.patches.forEach((f: any) => {
files.push({
fileName: new URL(f.url).pathname.split('/').pop() ?? '',
md5: f.md5,
sizeStr: math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS),
url: f.url,
});
});
}
return {
version,
reqVersion,
dateStr,
packedSizeStr: math.formatFileSize(packedSize, FILE_SIZE_OPTS),
unpackedSizeStr: math.formatFileSize(unpackedSize, FILE_SIZE_OPTS),
files,
};
})
.filter((p): p is NonNullable<typeof p> => p !== null);
return {
targetName: target.name,
region: target.region,
dirName: target.dirName,
patches,
};
} catch (err) {
return null;
}
});
const results = await Promise.all(promises);
const validResults = results.filter((r): r is PatchData => r !== null);
const sortedResults = gameTargets
.map((t) => validResults.find((r) => r.dirName === t.dirName))
.filter((r): r is PatchData => r !== undefined && r !== null);
setPatchesData(sortedResults);
setLoading(false);
};
fetchData();
}, []);
if (loading) {
return (
<div className='text-center p-5'>
<div className='spinner-border' role='status'></div>
</div>
);
}
return (
<div>
{patchesData.map((pkg) => (
<div key={pkg.dirName} className='mb-5'>
<h3 className='mb-3'>
{pkg.region === 'cn' ? 'China' : 'Global'}, {pkg.targetName}
</h3>
<div className='accordion' id={`accordion-patch-${pkg.dirName}`}>
{pkg.patches.map((ver, idx) => {
const itemId = `patch-${pkg.dirName}-${idx}`;
return (
<div className='accordion-item' key={itemId}>
<h2 className='accordion-header' id={`heading-${itemId}`}>
<button
className='accordion-button collapsed'
type='button'
data-bs-toggle='collapse'
data-bs-target={`#collapse-${itemId}`}
aria-expanded='false'
aria-controls={`collapse-${itemId}`}
>
<div className='d-flex w-100 justify-content-between me-3'>
<span className='fw-bold'>
{ver.reqVersion} {ver.version}
</span>
<span className='text-muted small align-bottom'>{ver.dateStr}</span>
</div>
</button>
</h2>
<div
id={`collapse-${itemId}`}
className='accordion-collapse collapse'
aria-labelledby={`heading-${itemId}`}
data-bs-parent={`#accordion-patch-${pkg.dirName}`}
>
<div className='accordion-body'>
<table className='table table-sm table-borderless w-auto mb-2'>
<tbody>
<tr>
<td>Unpacked Size</td>
<td className='text-end fw-bold'>{ver.unpackedSizeStr}</td>
</tr>
<tr>
<td>Packed Size</td>
<td className='text-end fw-bold'>{ver.packedSizeStr}</td>
</tr>
</tbody>
</table>
<div className='table-responsive'>
<table className='table table-striped table-bordered table-sm align-middle text-nowrap'>
<thead>
<tr>
<th>File</th>
<th>MD5 Checksum</th>
<th className='text-end'>Size</th>
<th className='text-center'>DL</th>
</tr>
</thead>
<tbody>
{ver.files.map((file, pIdx) => (
<tr key={pIdx}>
<td>{file.fileName}</td>
<td>
<code>{file.md5}</code>
</td>
<td className='text-end'>{file.sizeStr}</td>
<td
className='text-center'
dangerouslySetInnerHTML={{ __html: generateDownloadLinks(file.url, mirrorFileDb) }}
></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,284 @@
import { Tooltip } from 'bootstrap';
import { DateTime } from 'luxon';
import { useEffect, useState } from 'react';
import semver from 'semver';
import type * as IApiEndfield from '../../../../src/types/api/akEndfield/Api';
import type { StoredData } from '../../types';
import { fetchJson } from '../../utils/api';
import { BASE_URL } from '../../utils/constants';
interface ResourceItem {
name?: string;
version: string;
path: string;
}
interface ResourceGroup {
resVersion: string;
versions: string[];
dateStr: string;
intervalStr: string;
initialRes: ResourceItem;
mainRes: ResourceItem;
isKick: boolean;
}
interface PlatformData {
platform: string;
groups: ResourceGroup[];
}
interface RegionData {
region: string;
channel: number;
platforms: PlatformData[];
}
const PLATFORMS = ['Windows', 'Android', 'iOS', 'PlayStation'];
const TARGETS = [
{ region: 'os', channel: 6, label: 'Global' },
{ region: 'cn', channel: 1, label: 'China' },
];
const DEFAULT_RESOURCE: ResourceItem = { version: '?', path: '#' };
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;
}
};
function useResourcesData() {
const [data, setData] = useState<RegionData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAndProcessData = async () => {
try {
const regionPromises = TARGETS.map(async (target) => {
const platformPromises = PLATFORMS.map(async (platform) => {
try {
const url = `${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`;
const rawData = await fetchJson<StoredData<IApiEndfield.LauncherLatestGameResources>[]>(url);
const groups = processRawData(rawData);
return groups.length > 0 ? { platform, groups } : null;
} catch (error) {
console.error(`Failed to fetch ${platform} for ${target.region}:`, error);
return null;
}
});
const platformsData = (await Promise.all(platformPromises)).filter((p): p is PlatformData => p !== null);
return platformsData.length > 0
? { region: target.region, channel: target.channel, platforms: platformsData }
: null;
});
const regionResults = (await Promise.all(regionPromises)).filter((r): r is RegionData => r !== null);
setData(regionResults);
} finally {
setLoading(false);
}
};
fetchAndProcessData();
}, []);
return { data, loading };
}
function processRawData(rawData: StoredData<IApiEndfield.LauncherLatestGameResources>[]): ResourceGroup[] {
const resVersionMap = new Map<string, { rsp: any; gameVer: Set<string>; updatedAt: string }>();
for (const e of rawData) {
const resVer = e.rsp.res_version;
if (!resVersionMap.has(resVer)) {
resVersionMap.set(resVer, { rsp: e.rsp, gameVer: new Set(), updatedAt: e.updatedAt });
}
if (e.req.version) {
resVersionMap.get(resVer)!.gameVer.add(e.req.version);
}
}
const resVersionList = Array.from(resVersionMap.values())
.map((d) => ({
resVersion: d.rsp.res_version,
rsp: d.rsp,
gameVers: Array.from(d.gameVer).sort((a, b) => semver.rcompare(a, b)),
updatedAt: d.updatedAt,
}))
.sort((a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis());
return resVersionList.map((item, i, arr) => {
const currentDate = DateTime.fromISO(item.updatedAt);
const nextItem = arr[i + 1];
let intervalStr = '-';
if (nextItem) {
const nextDate = DateTime.fromISO(nextItem.updatedAt);
intervalStr = currentDate.diff(nextDate, ['days', 'hours', 'minutes', 'seconds']).toFormat('dd:hh:mm:ss');
}
const initialRes = item.rsp.resources?.find((e: any) => e.name === 'initial') || DEFAULT_RESOURCE;
const mainRes = item.rsp.resources?.find((e: any) => e.name === 'main') || DEFAULT_RESOURCE;
const isKick = JSON.parse(item.rsp.configs || '{}').kick_flag === true;
return {
resVersion: item.resVersion,
versions: item.gameVers,
dateStr: currentDate.toFormat('yyyy/MM/dd HH:mm:ss'),
intervalStr,
initialRes,
mainRes,
isKick,
};
});
}
const ResourceLink = ({ basePath, file }: { basePath: string; file: string }) => {
const fullPath = `${basePath}/${file}`;
return (
<>
<a href={fullPath} target='_blank' rel='noreferrer'>
Orig
</a>{' '}
/{' '}
<a href={getMirrorUrl(fullPath)} target='_blank' rel='noreferrer'>
Mirror
</a>
</>
);
};
const ResourceTable = ({ groups }: { groups: ResourceGroup[] }) => (
<div className='table-responsive'>
<table className='table table-striped table-bordered table-sm align-middle text-nowrap'>
<thead>
<tr>
<th>Date</th>
<th>Interval</th>
<th>Version</th>
<th>Game version</th>
<th
data-bs-toggle='tooltip'
data-bs-trigger='hover focus'
data-bs-title='If the game server wants to force clients to apply a hotfix update immediately, they will all be kicked from the server at once.'
style={{ cursor: 'help', textDecoration: 'underline dotted' }}
tabIndex={0}
>
Kick
</th>
<th>Initial</th>
<th>Main</th>
<th>Patch</th>
</tr>
</thead>
<tbody>
{groups.map((group, idx) => (
<tr key={idx}>
<td style={{ fontFeatureSettings: '"tnum"' }}>{group.dateStr}</td>
<td style={{ fontFeatureSettings: '"tnum"' }}>{group.intervalStr}</td>
<td>{group.initialRes.version === group.mainRes.version ? group.mainRes.version : group.resVersion}</td>
<td>{group.versions.join(', ')}</td>
<td className='text-center'>{group.isKick ? '✅' : ''}</td>
<td>
<ResourceLink basePath={group.initialRes.path} file='index_initial.json' />
</td>
<td>
<ResourceLink basePath={group.mainRes.path} file='index_main.json' />
</td>
<td>
<ResourceLink basePath={group.mainRes.path} file='patch.json' />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
const PlatformAccordion = ({
region,
channel,
platformData,
}: {
region: string;
channel: number;
platformData: PlatformData;
}) => {
const itemId = `res-${region}-${channel}-${platformData.platform}`;
return (
<div className='accordion-item'>
<h2 className='accordion-header' id={`heading-${itemId}`}>
<button
className='accordion-button collapsed'
type='button'
data-bs-toggle='collapse'
data-bs-target={`#collapse-${itemId}`}
aria-expanded='false'
aria-controls={`collapse-${itemId}`}
>
{platformData.platform}
</button>
</h2>
<div
id={`collapse-${itemId}`}
className='accordion-collapse collapse'
aria-labelledby={`heading-${itemId}`}
data-bs-parent={`#accordion-res-${region}-${channel}`}
>
<div className='accordion-body'>
<ResourceTable groups={platformData.groups} />
</div>
</div>
</div>
);
};
export default function ResourcesTab() {
const { data, loading } = useResourcesData();
useEffect(() => {
if (!loading) {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = Array.from(tooltipTriggerList).map((el) => new Tooltip(el));
return () => {
for (const t of tooltipList) {
t.dispose();
}
};
}
}, [loading]);
if (loading) {
return (
<div className='text-center p-5'>
<div className='spinner-border' role='status'></div>
</div>
);
}
return (
<div>
{data.map((regionData) => (
<div key={`${regionData.region}-${regionData.channel}`} className='mb-5'>
<h3 className='mb-3'>{TARGETS.find((t) => t.region === regionData.region)?.label || regionData.region}</h3>
<div className='accordion' id={`accordion-res-${regionData.region}-${regionData.channel}`}>
{regionData.platforms.map((plat) => (
<PlatformAccordion
key={plat.platform}
region={regionData.region}
channel={regionData.channel}
platformData={plat}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { gameTargets, launcherWebApiLang } from '../../utils/constants';
import AnnouncementSection from './web/AnnouncementSection';
import BannerSection from './web/BannerSection';
import MainBgImageSection from './web/MainBgImageSection';
import SidebarSection from './web/SidebarSection';
import SingleEntSection from './web/SingleEntSection';
export default function WebTab() {
const [targetIdx, setTargetIdx] = useState(0);
const [lang, setLang] = useState('');
const target = gameTargets[targetIdx]!;
const langs = launcherWebApiLang[target.region] || [];
useEffect(() => {
const defaultLang = target.region === 'os' ? 'en-us' : 'zh-cn';
if (langs.includes(defaultLang as any)) {
setLang(defaultLang);
} else if (langs.length > 0) {
setLang(langs[0]);
}
}, [targetIdx, target.region, langs]);
return (
<div>
{/* <div className='card'>
<div className='card-body'> */}
<div className='row g-3 mb-3'>
<div className='col-md-6'>
<label className='form-label fw-bold'>Target</label>
<select className='form-select' value={targetIdx} onChange={(e) => setTargetIdx(parseInt(e.target.value))}>
{gameTargets.map((t, idx) => (
<option key={idx} value={idx}>
{t.region === 'cn' ? 'China' : 'Global'} - {t.name}
</option>
))}
</select>
</div>
{langs.length > 1 && (
<div className='col-md-6'>
<label className='form-label fw-bold'>Language</label>
<select className='form-select' value={lang} onChange={(e) => setLang(e.target.value)}>
{langs.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</div>
)}
{/* </div>
</div> */}
</div>
<AnnouncementSection target={target} lang={lang} />
<BannerSection target={target} lang={lang} />
<MainBgImageSection target={target} lang={lang} />
<SingleEntSection target={target} lang={lang} />
<SidebarSection target={target} lang={lang} />
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { DateTime } from 'luxon';
import { useEffect, useRef, useState } from 'react';
import type { LauncherWebAnnouncement, StoredData } from '../../../types';
import { fetchJson } from '../../../utils/api';
import { BASE_URL } from '../../../utils/constants';
interface Props {
target: { region: 'os' | 'cn'; dirName: string };
lang: string;
}
interface TabData {
tabName: string;
announcements: any[];
}
export default function AnnouncementSection({ target, lang }: Props) {
const [tabs, setTabs] = useState<Map<string, TabData>>(new Map());
const [loading, setLoading] = useState(false);
const [shouldLoad, setShouldLoad] = useState(true);
const collapseRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = collapseRef.current;
if (!el) return;
const handleShow = () => setShouldLoad(true);
el.addEventListener('show.bs.collapse', handleShow);
return () => el.removeEventListener('show.bs.collapse', handleShow);
}, []);
useEffect(() => {
const load = async () => {
if (!lang || !shouldLoad) return;
setLoading(true);
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/announcement/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebAnnouncement>[]>(url);
const newTabsMap = new Map<string, TabData>();
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 (!newTabsMap.has(tab.tab_id)) {
newTabsMap.set(tab.tab_id, { tabName: tab.tabName, announcements: [] });
}
const targetTab = newTabsMap.get(tab.tab_id)!;
for (const ann of tab.announcements) {
if (!targetTab.announcements.some((a: any) => a.id === ann.id)) {
targetTab.announcements.push(ann);
}
}
}
}
setTabs(newTabsMap);
} catch (e) {
setTabs(new Map());
} finally {
setLoading(false);
}
};
load();
}, [target, lang, shouldLoad]);
return (
<div className='card mb-3'>
<div
className='card-header d-flex justify-content-between align-items-center'
style={{ cursor: 'pointer' }}
data-bs-toggle='collapse'
data-bs-target='#collapseAnnouncement'
role='button'
>
<h3 className='h4 mb-0'>Announcement</h3>
<i className='bi bi-chevron-down'></i>
</div>
<div id='collapseAnnouncement' className='collapse show' ref={collapseRef}>
<div className='card-body'>
{loading ? (
<div className='text-muted p-2'>Loading announcements...</div>
) : tabs.size === 0 ? (
<div className='text-muted p-2'>No announcements found.</div>
) : (
Array.from(tabs.entries()).map(([tabId, tabData]) => (
<div key={tabId} className='card mb-4 shadow-sm'>
<div className='card-header text-white fw-bold py-1'>{tabData.tabName}</div>
<ul className='list-group list-group-flush'>
{tabData.announcements
.sort((a, b) => parseInt(b.start_ts) - parseInt(a.start_ts))
.map((ann) => (
<li key={ann.id} className='list-group-item py-2'>
<div className='d-flex flex-wrap align-items-center gap-2'>
<span className='text-muted small' style={{ minWidth: '120px' }}>
{DateTime.fromMillis(parseInt(ann.start_ts)).toFormat('yyyy/MM/dd HH:mm')}
</span>
<span className='flex-grow-1 fw-bold'>{ann.content}</span>
<div className='d-flex align-items-center gap-2'>
{ann.need_token && (
<span className='badge bg-warning text-dark px-1 py-0' style={{ fontSize: '0.7rem' }}>
Auth
</span>
)}
{ann.jump_url && (
<a
href={ann.jump_url}
target='_blank'
rel='noreferrer'
className='btn btn-sm btn-outline-secondary py-0 px-2'
style={{ fontSize: '0.75rem' }}
>
Link
</a>
)}
<span className='text-muted border-start ps-2' style={{ fontSize: '0.7rem' }}>
ID:{ann.id}
</span>
</div>
</div>
</li>
))}
</ul>
</div>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { DateTime } from 'luxon';
import { useEffect, useRef, useState } from 'react';
import type { LauncherWebBanner, StoredData } from '../../../types';
import { fetchJson } from '../../../utils/api';
import { BASE_URL } from '../../../utils/constants';
interface Props {
target: { region: 'os' | 'cn'; dirName: string };
lang: string;
}
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;
}
};
export default function BannerSection({ target, lang }: Props) {
const [bannerMap, setBannerMap] = useState<
Map<string, { banner: LauncherWebBanner['banners'][0]; firstSeen: string }>
>(new Map());
const [loading, setLoading] = useState(false);
const [shouldLoad, setShouldLoad] = useState(false);
const collapseRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = collapseRef.current;
if (!el) return;
const handleShow = () => setShouldLoad(true);
el.addEventListener('show.bs.collapse', handleShow);
return () => el.removeEventListener('show.bs.collapse', handleShow);
}, []);
useEffect(() => {
const load = async () => {
if (!lang || !shouldLoad) return;
setLoading(true);
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/banner/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebBanner>[]>(url);
const newBannerMap = 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 (!newBannerMap.has(banner.id)) {
newBannerMap.set(banner.id, { banner, firstSeen: entry.updatedAt });
}
}
}
setBannerMap(newBannerMap);
} catch (e) {
setBannerMap(new Map());
} finally {
setLoading(false);
}
};
load();
}, [target, lang, shouldLoad]);
return (
<div className='card mb-3'>
<div
className='card-header d-flex justify-content-between align-items-center'
style={{ cursor: 'pointer' }}
data-bs-toggle='collapse'
data-bs-target='#collapseBanner'
role='button'
>
<h3 className='h4 mb-0'>Banner</h3>
<i className='bi bi-chevron-down'></i>
</div>
<div id='collapseBanner' className='collapse' ref={collapseRef}>
<div className='card-body'>
{loading ? (
<div className='text-muted p-2'>Loading banners...</div>
) : bannerMap.size === 0 ? (
<div className='text-muted p-2'>No banners found.</div>
) : (
<div className='row row-cols-1 row-cols-md-3 row-cols-lg-4 g-3'>
{Array.from(bannerMap.entries()).map(([id, { banner, firstSeen }]) => {
const dateStr = DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm');
const mirrorUrl = getMirrorUrl(banner.url);
const linkUrl = banner.jump_url || mirrorUrl;
return (
<div key={id} className='col'>
<a href={linkUrl} target='_blank' rel='noreferrer' className='text-decoration-none text-reset'>
<div className='card h-100 shadow-sm border-0'>
<div className='position-relative'>
<img
src={mirrorUrl}
className='card-img-top rounded'
alt='Banner'
style={{ objectFit: 'cover', aspectRatio: '16 / 9' }}
loading='lazy'
/>
<div className='position-absolute top-0 end-0 p-1'>
{banner.need_token && (
<span className='badge bg-warning text-dark' style={{ fontSize: '0.6rem' }}>
Auth
</span>
)}
</div>
</div>
<div className='card-body py-1 px-1'>
<div
className='d-flex justify-content-between align-items-center'
style={{ fontSize: '0.7rem' }}
>
<span className='text-muted text-truncate me-1'>ID: {id}</span>
<span className='text-muted flex-shrink-0'>{dateStr}</span>
</div>
</div>
</div>
</a>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { DateTime } from 'luxon';
import { useEffect, useRef, useState } from 'react';
import type { LauncherWebMainBgImage, StoredData } from '../../../types';
import { fetchJson } from '../../../utils/api';
import { BASE_URL } from '../../../utils/constants';
interface Props {
target: { region: 'os' | 'cn'; dirName: string };
lang: string;
}
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;
}
};
export default function MainBgImageSection({ target, lang }: Props) {
const [imageMap, setImageMap] = useState<
Map<string, { image: LauncherWebMainBgImage['main_bg_image']; firstSeen: string }>
>(new Map());
const [loading, setLoading] = useState(false);
const [shouldLoad, setShouldLoad] = useState(false);
const collapseRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = collapseRef.current;
if (!el) return;
const handleShow = () => setShouldLoad(true);
el.addEventListener('show.bs.collapse', handleShow);
return () => el.removeEventListener('show.bs.collapse', handleShow);
}, []);
useEffect(() => {
const load = async () => {
if (!lang || !shouldLoad) return;
setLoading(true);
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/main_bg_image/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebMainBgImage>[]>(url);
const newImageMap = 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 (!newImageMap.has(img.md5)) {
newImageMap.set(img.md5, { image: img, firstSeen: entry.updatedAt });
}
}
setImageMap(newImageMap);
} catch (e) {
setImageMap(new Map());
} finally {
setLoading(false);
}
};
load();
}, [target, lang, shouldLoad]);
return (
<div className='card mb-3'>
<div
className='card-header d-flex justify-content-between align-items-center'
style={{ cursor: 'pointer' }}
data-bs-toggle='collapse'
data-bs-target='#collapseMainBgImage'
role='button'
>
<h3 className='h4 mb-0'>Main Background Image</h3>
<i className='bi bi-chevron-down'></i>
</div>
<div id='collapseMainBgImage' className='collapse' ref={collapseRef}>
<div className='card-body'>
{loading ? (
<div className='text-muted p-2'>Loading background images...</div>
) : imageMap.size === 0 ? (
<div className='text-muted p-2'>No images found.</div>
) : (
<div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3'>
{Array.from(imageMap.entries()).map(([md5, { image, firstSeen }]) => {
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;
return (
<div key={md5} className='col'>
<a href={linkUrl} target='_blank' rel='noreferrer' className='text-decoration-none text-reset'>
<div className='card h-100 shadow-sm border-0'>
<div className='position-relative'>
<img
src={mirrorUrl}
className='card-img-top rounded'
alt='Background'
style={{ objectFit: 'cover', aspectRatio: '16 / 9' }}
loading='lazy'
/>
<div className='position-absolute top-0 end-0 p-1'>
{image.video_url && (
<span className='badge bg-primary' style={{ fontSize: '0.6rem' }}>
Video
</span>
)}
</div>
</div>
<div className='card-body py-1 px-1'>
<div
className='d-flex justify-content-between align-items-center'
style={{ fontSize: '0.7rem' }}
>
<span className='text-muted text-truncate font-monospace me-1'>{md5}</span>
<span className='text-muted flex-shrink-0'>{dateStr}</span>
</div>
</div>
</div>
</a>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { DateTime } from 'luxon';
import { useEffect, useRef, useState } from 'react';
import type { LauncherWebSidebar, StoredData } from '../../../types';
import { fetchJson } from '../../../utils/api';
import { BASE_URL } from '../../../utils/constants';
interface Props {
target: { region: 'os' | 'cn'; dirName: string };
lang: string;
}
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;
}
};
export default function SidebarSection({ target, lang }: Props) {
const [latestSidebar, setLatestSidebar] = useState<{
sidebars: LauncherWebSidebar['sidebars'];
updatedAt: string;
} | null>(null);
const [loading, setLoading] = useState(false);
const [shouldLoad, setShouldLoad] = useState(false);
const collapseRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = collapseRef.current;
if (!el) return;
const handleShow = () => setShouldLoad(true);
el.addEventListener('show.bs.collapse', handleShow);
return () => el.removeEventListener('show.bs.collapse', handleShow);
}, []);
useEffect(() => {
const load = async () => {
if (!lang || !shouldLoad) return;
setLoading(true);
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/sidebar/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebSidebar>[]>(url);
const sortedData = [...data].sort(
(a, b) => DateTime.fromISO(b.updatedAt).toMillis() - DateTime.fromISO(a.updatedAt).toMillis(),
);
const latest = sortedData[0];
if (latest && latest.rsp && latest.rsp.sidebars) {
setLatestSidebar({ sidebars: latest.rsp.sidebars, updatedAt: latest.updatedAt });
} else {
setLatestSidebar(null);
}
} catch (e) {
setLatestSidebar(null);
} finally {
setLoading(false);
}
};
load();
}, [target, lang, shouldLoad]);
return (
<div className='card mb-3'>
<div
className='card-header d-flex justify-content-between align-items-center'
style={{ cursor: 'pointer' }}
data-bs-toggle='collapse'
data-bs-target='#collapseSidebar'
role='button'
>
<h3 className='h4 mb-0'>Sidebar</h3>
<i className='bi bi-chevron-down'></i>
</div>
<div id='collapseSidebar' className='collapse' ref={collapseRef}>
<div className='card-body'>
{loading ? (
<div className='text-muted p-2'>Loading sidebar data...</div>
) : !latestSidebar ? (
<div className='text-muted p-2'>No active sidebars.</div>
) : (
<>
<div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3'>
{latestSidebar.sidebars.map((item, idx) => (
<div key={idx} className='col'>
<div className='card h-100 shadow-sm'>
<div className='card-body'>
<div className='d-flex justify-content-between align-items-center mb-2'>
<h5 className='card-title mb-0'>{item.media}</h5>
{item.need_token && <span className='badge bg-warning text-dark lh-1 py-1'>Auth</span>}
</div>
{item.pic && (
<div className='mb-3'>
<img
src={getMirrorUrl(item.pic.url)}
className='img-fluid rounded'
alt={item.pic.description}
loading='lazy'
/>
<p className='text-muted small mt-1 mb-0'>{item.pic.description}</p>
</div>
)}
{item.jump_url && (
<a
href={item.jump_url}
target='_blank'
rel='noreferrer'
className='btn btn-sm btn-outline-primary mb-2 w-100'
>
Open Link
</a>
)}
{item.sidebar_labels && item.sidebar_labels.length > 0 && (
<div className='list-group list-group-flush border-top mt-2'>
{item.sidebar_labels.map((label, lIdx) => (
<a
key={lIdx}
href={label.jump_url}
target='_blank'
rel='noreferrer'
className='list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2 px-0'
>
<span style={{ fontSize: '0.9rem' }}>{label.content}</span>
<div className='d-flex gap-1'>
{label.need_token && (
<span className='badge bg-warning text-dark lh-1 py-1'>Auth</span>
)}
<i className='bi bi-box-arrow-up-right small text-muted'></i>
</div>
</a>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
<div className='text-muted small mt-3 text-end'>
Last updated: {DateTime.fromISO(latestSidebar.updatedAt).toFormat('yyyy/MM/dd HH:mm')}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { DateTime } from 'luxon';
import { useEffect, useRef, useState } from 'react';
import type { LauncherWebSingleEnt, StoredData } from '../../../types';
import { fetchJson } from '../../../utils/api';
import { BASE_URL } from '../../../utils/constants';
interface Props {
target: { region: 'os' | 'cn'; dirName: string };
lang: string;
}
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;
}
};
export default function SingleEntSection({ target, lang }: Props) {
const [entMap, setEntMap] = useState<Map<string, { ent: LauncherWebSingleEnt['single_ent']; firstSeen: string }>>(
new Map(),
);
const [loading, setLoading] = useState(false);
const [shouldLoad, setShouldLoad] = useState(false);
const collapseRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = collapseRef.current;
if (!el) return;
const handleShow = () => setShouldLoad(true);
el.addEventListener('show.bs.collapse', handleShow);
return () => el.removeEventListener('show.bs.collapse', handleShow);
}, []);
useEffect(() => {
const load = async () => {
if (!lang || !shouldLoad) return;
setLoading(true);
const url = `${BASE_URL}/akEndfield/launcher/web/${target.dirName}/single_ent/${lang}/all.json`;
try {
const data = await fetchJson<StoredData<LauncherWebSingleEnt>[]>(url);
const newEntMap = 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 (!newEntMap.has(key)) {
newEntMap.set(key, { ent, firstSeen: entry.updatedAt });
}
}
setEntMap(newEntMap);
} catch (e) {
setEntMap(new Map());
} finally {
setLoading(false);
}
};
load();
}, [target, lang, shouldLoad]);
return (
<div className='card mb-3'>
<div
className='card-header d-flex justify-content-between align-items-center'
style={{ cursor: 'pointer' }}
data-bs-toggle='collapse'
data-bs-target='#collapseSingleEnt'
role='button'
>
<h3 className='h4 mb-0'>Single Ent.</h3>
<i className='bi bi-chevron-down'></i>
</div>
<div id='collapseSingleEnt' className='collapse' ref={collapseRef}>
<div className='card-body'>
{loading ? (
<div className='text-muted p-2'>Loading single entry data...</div>
) : entMap.size === 0 ? (
<div className='text-muted p-2'>No data found.</div>
) : (
<div className='row row-cols-1 row-cols-md-2 g-4'>
{Array.from(entMap.entries()).map(([key, { ent, firstSeen }]) => (
<div key={key} className='col'>
<div className='card h-100 shadow-sm'>
<div className='card-body'>
<div className='d-flex justify-content-between align-items-center mb-3'>
<span className='text-muted small'>
First seen: {DateTime.fromISO(firstSeen).toFormat('yyyy/MM/dd HH:mm')}
</span>
{ent.need_token && <span className='badge bg-warning text-dark'>Auth</span>}
</div>
<div className='mb-3'>
<label className='form-label small fw-bold'>Version Image</label>
<a href={getMirrorUrl(ent.version_url)} target='_blank' rel='noreferrer'>
<img
src={getMirrorUrl(ent.version_url)}
className='img-fluid rounded border'
alt='Version'
loading='lazy'
/>
</a>
<p
className='text-muted font-monospace mt-1'
style={{ fontSize: '0.7rem', wordBreak: 'break-all' }}
>
MD5: {ent.version_md5}
</p>
</div>
{ent.button_url && (
<div className='mb-3'>
<label className='form-label small fw-bold'>Action Button</label>
<div className='d-flex gap-2'>
<div className='flex-grow-1 text-center'>
<img
src={getMirrorUrl(ent.button_url)}
className='img-fluid rounded border bg-light'
alt='Button'
style={{ maxHeight: '60px' }}
loading='lazy'
/>
<p className='text-muted small mt-1 mb-0'>Normal</p>
</div>
{ent.button_hover_url && (
<div className='flex-grow-1 text-center'>
<img
src={getMirrorUrl(ent.button_hover_url)}
className='img-fluid rounded border bg-light'
alt='Button Hover'
style={{ maxHeight: '60px' }}
loading='lazy'
/>
<p className='text-muted small mt-1 mb-0'>Hover</p>
</div>
)}
</div>
</div>
)}
{ent.jump_url && (
<a
href={ent.jump_url}
target='_blank'
rel='noreferrer'
className='btn btn-sm btn-outline-primary w-100'
>
Jump URL
</a>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}