mirror of
https://github.com/Coldsmiles/infstarweb.git
synced 2026-04-23 02:30:41 +08:00
feat: Redesign player details modal with improved layout and accordion for detailed stats
This commit is contained in:
45
.github/copilot-instructions.md
vendored
Normal file
45
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Project Guidelines
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
- This repo is framework-free: plain HTML + CSS + vanilla JavaScript (`index.html`, `stats.html`, `script.js`, `stats_script.js`).
|
||||||
|
- Keep existing naming style: `camelCase` for JS functions/variables and descriptive DOM ids/classes (for example `fetchCrowdfunding`, `setupMobileMenu`, `#players-grid`).
|
||||||
|
- Preserve current formatting patterns: 4-space indentation in HTML/CSS, simple function-based JS (no classes/modules/build tooling).
|
||||||
|
- Reuse shared tokens in `style.css` (`:root` variables such as `--bg-color`, `--accent-color`) instead of introducing new ad-hoc styles.
|
||||||
|
- Keep user-facing copy in Chinese unless the surrounding section is already English.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Public pages are static entry points:
|
||||||
|
- `index.html` + `script.js`: landing page, sponsors, fundraising progress, live server status.
|
||||||
|
- `stats.html` + `stats_script.js`: player leaderboard + searchable player cards + modal details.
|
||||||
|
- Shared visual system lives in `style.css`; `stats.html` adds page-specific inline `<style>` overrides.
|
||||||
|
- Data flow for stats:
|
||||||
|
1. `statsprocess.py` fetches raw player JSON files and writes normalized outputs to `stats/`.
|
||||||
|
2. It generates `stats/summary.json` consumed by `stats_script.js` via `fetch('stats/summary.json')`.
|
||||||
|
|
||||||
|
## Build and Test
|
||||||
|
- No package manager/build step exists in this workspace.
|
||||||
|
- Local static preview:
|
||||||
|
- `python3 -m http.server 8000`
|
||||||
|
- open `http://localhost:8000/`
|
||||||
|
- Regenerate player summary data:
|
||||||
|
- `python3 statsprocess.py`
|
||||||
|
- Python script dependencies are runtime imports in `statsprocess.py` (not pinned): `requests`, `tqdm`.
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
- Prefer progressive enhancement with `DOMContentLoaded` initializers (see both JS entry files).
|
||||||
|
- Keep network fetch paths relative for local assets (`fund_progress.txt`, `sponsors.txt`, `stats/summary.json`).
|
||||||
|
- For unavailable remote APIs, follow existing behavior: log errors and render fallback text instead of throwing.
|
||||||
|
- Do not introduce bundlers/framework migrations unless explicitly requested.
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
- External APIs/services currently used:
|
||||||
|
- Server status: `https://api.mcstatus.io/v2/status/java/mcpure.lunadeer.cn`
|
||||||
|
- Avatars: `https://minotar.net/...` and `https://crafatar.com/...`
|
||||||
|
- Player name resolution in pipeline: Ashcon + Mojang Session APIs (`statsprocess.py`).
|
||||||
|
- Source stats endpoint: `http://x2.sjcmc.cn:15960/stats/`.
|
||||||
|
- External assets are loaded directly from CDNs (Google Fonts, Font Awesome) and image hosts.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Treat player UUID/name datasets in `stats/` as production content; avoid destructive bulk edits.
|
||||||
|
- Preserve SEO/verification metadata in page `<head>` blocks unless a task explicitly targets SEO.
|
||||||
|
- Avoid adding secrets/tokens to repository files; keep any future credentials out of static HTML/JS.
|
||||||
191
stats.html
191
stats.html
@@ -267,6 +267,167 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal Redesign */
|
||||||
|
.modal-content.expanded-modal {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh; /* Scrollable if too tall */
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-top-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-identity {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-identity img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-name {
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-uuid {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-container.compact-stats {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-divider {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin: 10px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion Styles */
|
||||||
|
.accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
background: #fdfdfd;
|
||||||
|
padding: 12px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header .icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header .arrow {
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header.active .arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header.active {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content {
|
||||||
|
display: none; /* Hidden by default */
|
||||||
|
padding: 15px;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid for stats inside accordion */
|
||||||
|
.detail-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stat-label {
|
||||||
|
color: #666;
|
||||||
|
margin-right: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.modal-top-section {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-identity, .stats-list-container.compact-stats {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -411,14 +572,20 @@
|
|||||||
|
|
||||||
<!-- Player Details Modal -->
|
<!-- Player Details Modal -->
|
||||||
<div id="player-modal" class="modal">
|
<div id="player-modal" class="modal">
|
||||||
<div class="modal-content" style="max-width: 400px;">
|
<div class="modal-content expanded-modal">
|
||||||
<span class="close-modal">×</span>
|
<span class="close-modal">×</span>
|
||||||
<div style="text-align: center; margin-bottom: 20px;">
|
|
||||||
<img id="modal-avatar" src="" alt="Avatar" style="width: 100px; height: 100px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
|
<!-- Top Section: Header Info + Summary Stats -->
|
||||||
<h2 id="modal-name" style="margin-top: 10px;">Player Name</h2>
|
<div class="modal-top-section">
|
||||||
<p id="modal-uuid" style="font-size: 12px; color: #999; font-family: monospace;">UUID</p>
|
<!-- Left: Identity -->
|
||||||
|
<div class="modal-identity">
|
||||||
|
<img id="modal-avatar" src="" alt="Avatar">
|
||||||
|
<h2 id="modal-name">Player Name</h2>
|
||||||
|
<p id="modal-uuid">UUID</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-list-container">
|
|
||||||
|
<!-- Right: Summary Stats -->
|
||||||
|
<div class="stats-list-container compact-stats">
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label"><i class="fas fa-walking"></i> 行走距离</span>
|
<span class="stat-label"><i class="fas fa-walking"></i> 行走距离</span>
|
||||||
<span class="stat-value" id="modal-walk">0 m</span>
|
<span class="stat-value" id="modal-walk">0 m</span>
|
||||||
@@ -444,6 +611,18 @@
|
|||||||
<span class="stat-value" id="modal-playtime">0 秒</span>
|
<span class="stat-value" id="modal-playtime">0 秒</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Section: Detailed Stats Accordion -->
|
||||||
|
<div class="modal-details-section">
|
||||||
|
<hr class="modal-divider">
|
||||||
|
<div id="loading-details" style="text-align:center; padding:20px; color:#888;">正在加载详细数据...</div>
|
||||||
|
<div id="stats-accordion" class="accordion">
|
||||||
|
<!-- Dynamic Content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
155
stats_script.js
155
stats_script.js
@@ -222,12 +222,17 @@ function setupModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openModal(player) {
|
function openModal(player) {
|
||||||
|
const modal = document.getElementById("player-modal");
|
||||||
|
|
||||||
|
// Top Section: Identity
|
||||||
document.getElementById('modal-name').innerText = player.name;
|
document.getElementById('modal-name').innerText = player.name;
|
||||||
document.getElementById('modal-uuid').innerText = player.uuid;
|
document.getElementById('modal-uuid').innerText = player.uuid;
|
||||||
const avatar = document.getElementById('modal-avatar');
|
const avatar = document.getElementById('modal-avatar');
|
||||||
avatar.src = player.avatar;
|
avatar.src = player.avatar;
|
||||||
avatar.onerror = () => { avatar.src = `https://crafatar.com/avatars/${player.uuid}?size=64&overlay`; };
|
avatar.onerror = () => { avatar.src = `https://crafatar.com/avatars/${player.uuid}?size=64&overlay`; };
|
||||||
|
|
||||||
|
// Top Section: Summary Stats
|
||||||
|
// These are from summary.json which is already formatted
|
||||||
document.getElementById('modal-walk').innerText = player.stats.walk_fmt;
|
document.getElementById('modal-walk').innerText = player.stats.walk_fmt;
|
||||||
document.getElementById('modal-placed').innerText = player.stats.placed.toLocaleString();
|
document.getElementById('modal-placed').innerText = player.stats.placed.toLocaleString();
|
||||||
document.getElementById('modal-mined').innerText = player.stats.mined.toLocaleString();
|
document.getElementById('modal-mined').innerText = player.stats.mined.toLocaleString();
|
||||||
@@ -235,5 +240,155 @@ function openModal(player) {
|
|||||||
document.getElementById('modal-kills').innerText = player.stats.kills;
|
document.getElementById('modal-kills').innerText = player.stats.kills;
|
||||||
document.getElementById('modal-playtime').innerText = player.stats.play_time_fmt;
|
document.getElementById('modal-playtime').innerText = player.stats.play_time_fmt;
|
||||||
|
|
||||||
|
// Bottom Section: Reset and Load Details
|
||||||
|
const accordion = document.getElementById('stats-accordion');
|
||||||
|
accordion.innerHTML = '';
|
||||||
|
document.getElementById('loading-details').style.display = 'block';
|
||||||
|
document.getElementById('loading-details').innerText = '正在加载详细数据...';
|
||||||
|
|
||||||
modal.style.display = "flex";
|
modal.style.display = "flex";
|
||||||
|
|
||||||
|
// Load existing details
|
||||||
|
loadPlayerDetails(player.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlayerDetails(uuid) {
|
||||||
|
const accordion = document.getElementById('stats-accordion');
|
||||||
|
const loading = document.getElementById('loading-details');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`stats/${uuid}.json`);
|
||||||
|
if (!response.ok) throw new Error('Stats file not found');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.stats) {
|
||||||
|
loading.innerText = '暂无详细统计数据。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
renderDetailsAccordion(data.stats);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading details:', error);
|
||||||
|
loading.innerText = '无法加载详细数据。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailsAccordion(statsObj) {
|
||||||
|
const accordion = document.getElementById('stats-accordion');
|
||||||
|
|
||||||
|
// Define category mappings for better display names
|
||||||
|
const categoryMap = {
|
||||||
|
'minecraft:custom': { name: '通用统计', icon: 'fa-chart-bar' },
|
||||||
|
'minecraft:mined': { name: '挖掘统计', icon: 'fa-hammer' },
|
||||||
|
'minecraft:used': { name: '使用统计', icon: 'fa-hand-paper' },
|
||||||
|
'minecraft:crafted': { name: '合成统计', icon: 'fa-tools' },
|
||||||
|
'minecraft:broken': { name: '破坏统计', icon: 'fa-heart-broken' },
|
||||||
|
'minecraft:picked_up': { name: '拾取统计', icon: 'fa-box-open' },
|
||||||
|
'minecraft:dropped': { name: '丢弃统计', icon: 'fa-trash' },
|
||||||
|
'minecraft:killed': { name: '击杀统计', icon: 'fa-crosshairs' },
|
||||||
|
'minecraft:killed_by': { name: '死亡统计', icon: 'fa-skull' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format keys (minecraft:stone -> Stone)
|
||||||
|
const formatKey = (key) => {
|
||||||
|
if (key.includes(':')) key = key.split(':')[1];
|
||||||
|
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort categories to put 'custom' first, then others alphabetically
|
||||||
|
const sortedKeys = Object.keys(statsObj).sort((a, b) => {
|
||||||
|
if (a === 'minecraft:custom') return -1;
|
||||||
|
if (b === 'minecraft:custom') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedKeys.forEach(catKey => {
|
||||||
|
const subStats = statsObj[catKey];
|
||||||
|
if (Object.keys(subStats).length === 0) return;
|
||||||
|
|
||||||
|
const catInfo = categoryMap[catKey] || { name: formatKey(catKey), icon: 'fa-folder' };
|
||||||
|
|
||||||
|
// Create Accordion Item
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'accordion-item';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'accordion-header';
|
||||||
|
header.innerHTML = `
|
||||||
|
<span><i class="fas ${catInfo.icon} icon"></i> ${catInfo.name}</span>
|
||||||
|
<i class="fas fa-chevron-down arrow"></i>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'accordion-content';
|
||||||
|
|
||||||
|
// Grid for stats
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'detail-stats-grid';
|
||||||
|
|
||||||
|
// Sort sub-items by value descending
|
||||||
|
const subItems = Object.entries(subStats).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
subItems.forEach(([k, v]) => {
|
||||||
|
let label = formatKey(k);
|
||||||
|
let val = v.toLocaleString();
|
||||||
|
|
||||||
|
// Special formatting for time/distance in 'custom'
|
||||||
|
if (catKey === 'minecraft:custom') {
|
||||||
|
if (k.includes('time') || k.includes('minute')) {
|
||||||
|
// ticks to hours/min?
|
||||||
|
// Assuming 'play_time' is ticks (1/20s)
|
||||||
|
if (k.includes('play_time') || k.includes('time_since')) {
|
||||||
|
const sec = v / 20;
|
||||||
|
if (sec > 3600) val = (sec/3600).toFixed(1) + ' h';
|
||||||
|
else if (sec > 60) val = (sec/60).toFixed(1) + ' m';
|
||||||
|
else val = sec.toFixed(0) + ' s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (k.includes('cmt') || k.includes('one_cm')) { // one_cm
|
||||||
|
const m = v / 100;
|
||||||
|
if (m > 1000) val = (m/1000).toFixed(2) + ' km';
|
||||||
|
else val = m.toFixed(1) + ' m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statDiv = document.createElement('div');
|
||||||
|
statDiv.className = 'detail-stat-item';
|
||||||
|
statDiv.innerHTML = `
|
||||||
|
<span class="detail-stat-label" title="${label}">${label}</span>
|
||||||
|
<span class="detail-stat-value">${val}</span>
|
||||||
|
`;
|
||||||
|
grid.appendChild(statDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
content.appendChild(grid);
|
||||||
|
item.appendChild(header);
|
||||||
|
item.appendChild(content);
|
||||||
|
accordion.appendChild(item);
|
||||||
|
|
||||||
|
// Click Event
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const isActive = header.classList.contains('active');
|
||||||
|
|
||||||
|
// Close all others? Optional. Let's keep multiple openable.
|
||||||
|
// But if we want accordion behavior:
|
||||||
|
// document.querySelectorAll('.accordion-header').forEach(h => {
|
||||||
|
// h.classList.remove('active');
|
||||||
|
// h.nextElementSibling.classList.remove('show');
|
||||||
|
// });
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
header.classList.add('active');
|
||||||
|
content.classList.add('show');
|
||||||
|
} else {
|
||||||
|
header.classList.remove('active');
|
||||||
|
content.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user