mirror of
https://github.com/daydreamer-json/ak-endfield-api-archive.git
synced 2026-04-20 14:32:21 +02:00
feat(pages): add pages-v2 react variant
This commit is contained in:
45
pages-v2/src/components/tabs/AboutTab.tsx
Normal file
45
pages-v2/src/components/tabs/AboutTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
pages-v2/src/components/tabs/GamePackagesTab.tsx
Normal file
187
pages-v2/src/components/tabs/GamePackagesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
pages-v2/src/components/tabs/LauncherTab.tsx
Normal file
227
pages-v2/src/components/tabs/LauncherTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
pages-v2/src/components/tabs/OverviewTab.tsx
Normal file
307
pages-v2/src/components/tabs/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
pages-v2/src/components/tabs/PatchesTab.tsx
Normal file
209
pages-v2/src/components/tabs/PatchesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
pages-v2/src/components/tabs/ResourcesTab.tsx
Normal file
284
pages-v2/src/components/tabs/ResourcesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
pages-v2/src/components/tabs/WebTab.tsx
Normal file
64
pages-v2/src/components/tabs/WebTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
pages-v2/src/components/tabs/web/AnnouncementSection.tsx
Normal file
132
pages-v2/src/components/tabs/web/AnnouncementSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
pages-v2/src/components/tabs/web/BannerSection.tsx
Normal file
132
pages-v2/src/components/tabs/web/BannerSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
pages-v2/src/components/tabs/web/MainBgImageSection.tsx
Normal file
131
pages-v2/src/components/tabs/web/MainBgImageSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
pages-v2/src/components/tabs/web/SidebarSection.tsx
Normal file
150
pages-v2/src/components/tabs/web/SidebarSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
pages-v2/src/components/tabs/web/SingleEntSection.tsx
Normal file
165
pages-v2/src/components/tabs/web/SingleEntSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user