v1.2.0 独立详情页+赛季选择+季后赛对阵图:/team|player|game|news|person/<id> 详情页(交叉链接);弹窗加查看完整详情;排名页多赛季切换(2024-25/2025-26);季后赛bracket对阵图(playoffs.py);seed_seasons补充数据

This commit is contained in:
2026-08-17 13:31:26 +08:00
parent fcf5d3fee4
commit 4e360be8c7
10 changed files with 711 additions and 16 deletions
+72 -3
View File
@@ -54,7 +54,10 @@ function loadView(v) {
else if (v === "games") loadGames("");
else if (v === "news") loadNews("");
else if (v === "persons") loadPersons("");
else if (v === "standings") loadStandings("");
else if (v === "standings") {
if ($("#season-select").options.length) { loadStandings(); loadPlayoffs(); }
else loadSeasons();
}
}
/* ================= 对话 ================= */
@@ -197,6 +200,7 @@ async function openTeam(id) {
<div class="section-title">🧑‍🏫 主教练</div><div>${esc(t.head_coach)}</div>
<div class="section-title">⭐ 主要球员(本赛季场均)</div>${roster || "暂无"}
<div class="section-title">📅 近期比赛</div>${games || "暂无"}
<div style="margin-top:16px;text-align:right">${DL("team", t.id)}</div>
`);
}
@@ -229,6 +233,7 @@ async function openPlayer(id) {
</div>
<div class="section-title">🏅 荣誉</div><div style="font-size:13.5px;color:#c9d4e0">${esc(p.awards || "暂无")}</div>
<div class="section-title">📝 简介</div><div style="font-size:13.5px;color:#c9d4e0">${esc(p.bio || "暂无")}</div>
<div style="margin-top:16px;text-align:right">${DL("player", p.id)}</div>
`);
}
@@ -280,6 +285,7 @@ async function openGame(id) {
<div style="text-align:center;color:var(--sub);font-size:13px">🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}</div>
${stats ? `<div class="section-title">📊 球员技术统计</div>
<table><tr><th>球队</th><th>球员</th><th>得分</th><th>篮板</th><th>助攻</th><th>抢断</th><th>盖帽</th><th>分钟</th></tr>${stats}</table>` : ""}
<div style="margin-top:16px;text-align:right">${DL("game", g.id)}</div>
`);
}
@@ -304,6 +310,7 @@ async function openNews(id) {
<h2>${esc(n.title)}</h2>
<div class="en">🕐 ${esc(n.publish_time)} · ${esc(n.source)} · ${esc(n.author || "")} · ${n.kind === "wiki" ? "百科词条" : "新闻"}</div>
<div class="news-body" style="margin-top:12px">${esc(n.content).replace(/\n/g, "<br>")}</div>
<div style="margin-top:16px;text-align:right">${DL("news", n.id)}</div>
`);
}
@@ -334,19 +341,40 @@ async function openPerson(id) {
<div class="en">${esc(p.role_cn)} · ${esc(p.title || "")}${p.team ? " · " + esc(p.team) : ""}</div>
<div class="section-title">📝 简介</div><div style="font-size:14px;line-height:1.8;color:#c9d4e0">${esc(p.bio || "暂无")}</div>
<div class="section-title">🏅 成就</div><div style="font-size:14px;line-height:1.8;color:#c9d4e0">${esc(p.achievements || "暂无")}</div>
<div style="margin-top:16px;text-align:right">${DL("person", p.id)}</div>
`);
}
/* ================= 排名 ================= */
let standingsConf = "";
let standingsSeason = "";
let bracketData = null;
async function loadSeasons() {
try {
const ss = await getJSON("/api/seasons");
if (ss.length) {
standingsSeason = ss[0]; // 默认最新赛季
$("#season-select").innerHTML = ss.map((s) => `<option value="${esc(s)}">${esc(s)} 赛季</option>`).join("");
loadStandings();
loadPlayoffs();
}
} catch (e) {}
}
$("#season-select").addEventListener("change", (e) => {
standingsSeason = e.target.value;
loadStandings();
loadPlayoffs();
});
async function loadStandings() {
const rows = await getJSON(`/api/standings?conf=${encodeURIComponent(standingsConf)}`);
const rows = await getJSON(`/api/standings?conf=${encodeURIComponent(standingsConf)}&season=${encodeURIComponent(standingsSeason || "")}`);
const groups = {};
rows.forEach((r) => { (groups[r.conference] = groups[r.conference] || []).push(r); });
$("#wrap-standings").innerHTML = Object.entries(groups).map(([conf, list]) => `
<h3 style="margin:14px 0 8px;color:var(--orange2)">${conf}赛区</h3>
<table><tr><th>排名</th><th>球队</th><th>胜</th><th>负</th><th>胜率</th><th>战绩</th></tr>
${list.map((r) => `<tr><td class="num">${r.rank}</td><td>${esc(r.team)}</td>
${list.map((r) => `<tr><td class="num">${r.rank}</td><td><a class="xlink" href="/team/${r.team_id}">${esc(r.team)}</a></td>
<td class="num">${r.wins}</td><td class="num">${r.losses}</td>
<td class="num">${r.win_pct}%</td><td class="num">${r.wins}-${r.losses}</td></tr>`).join("")}
</table>`).join("");
@@ -358,11 +386,52 @@ $$("#view-standings .btn.small").forEach((b) => b.addEventListener("click", () =
loadStandings();
}));
/* ================= 季后赛对阵图 ================= */
async function loadPlayoffs() {
try {
bracketData = await getJSON(`/api/playoffs?season=${encodeURIComponent(standingsSeason || "")}`);
renderBracket();
} catch (e) {
$("#wrap-bracket").innerHTML = `<div class="meta">对阵图加载失败:${esc(e.message)}</div>`;
}
}
function matchHtml(m) {
if (!m) return "";
const gid = (m.games || [])[0];
const row = (side) => `<div class="mteam ${side.advance ? "win" : ""}" ${gid ? `onclick="openGame(${gid})"` : ""}>
<span class="mteam-name">${side.advance ? "🏆 " : ""}${esc(side.team)}</span><span class="mteam-wins">${side.wins}</span></div>`;
return `<div class="match">${row(m.home)}${row(m.away)}</div>`;
}
function renderBracket() {
const r = (bracketData?.rounds) || {};
const first = [...(r.first?.["东部"] || []), ...(r.first?.["西部"] || [])];
const semi = [...(r.semi?.["东部"] || []), ...(r.semi?.["西部"] || [])];
const conf = [r.conf?.["东部"], r.conf?.["西部"]].filter(Boolean);
const final = r.final;
const col = (title, items) => `<div class="bracket-col"><div class="bracket-col-title">${title}</div>${items.map(matchHtml).join("")}</div>`;
$("#wrap-bracket").innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin:14px 0 8px">
<h3 style="color:var(--orange2)">🏆 季后赛对阵图</h3>
<span class="season-pill">${esc(bracketData?.season || standingsSeason)} 赛季</span>
</div>
<div class="bracket">
${col("首轮", first)}
${col("半决赛", semi)}
${col("分区决赛", conf)}
${col("总决赛", final ? [final] : [])}
</div>
<div class="meta" style="margin-top:8px">💡 点击对阵可查看比赛详情 · 胜者显示 🏆</div>`;
}
/* ================= 弹窗 ================= */
function openModal(html) {
$("#modal-body").innerHTML = html;
$("#modal").classList.remove("hidden");
}
/* 详情页链接(弹窗底部) */
const DL = (type, id) => `<a class="detail-link" href="/${type}/${id}" target="_blank">查看完整详情 →</a>`;
$("#modal-close").addEventListener("click", () => $("#modal").classList.add("hidden"));
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") $("#modal").classList.add("hidden"); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#modal").classList.add("hidden"); });
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>详情 - NBA球迷大全</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.detail-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; flex-wrap: wrap; }
.back-btn { background: var(--bg2); border: 1px solid var(--line); color: var(--txt); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-size: 13.5px; }
.back-btn:hover { border-color: var(--orange2); color: var(--orange2); }
.detail-title { font-size: 24px; font-weight: 800; }
.detail-title .en { color: var(--sub); font-size: 14px; font-weight: 400; margin-left: 8px; }
.detail-sub { color: var(--sub); font-size: 13.5px; margin-top: 4px; }
.hero { background: linear-gradient(135deg, #1a120b, #1c2333); border: 1px solid var(--line); border-radius: 16px; padding: 24px 26px; margin-bottom: 18px; }
.hero .big { font-size: 26px; font-weight: 800; background: linear-gradient(90deg, #fb923c, #f97316, #fbbf24); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.kv { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px 14px; margin: 16px 0; }
.kv div { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
.kv .k { color: var(--sub); font-size: 11.5px; display: block; }
.kv .v { font-size: 14.5px; font-weight: 600; }
.section { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; margin-bottom: 16px; }
.section h3 { font-size: 15.5px; color: var(--orange2); margin-bottom: 12px; }
.section .txt { font-size: 14px; line-height: 1.9; color: #c9d4e0; }
.xlink { color: var(--blue); text-decoration: none; }
.xlink:hover { text-decoration: underline; color: var(--orange2); }
.row-item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 8px 2px; border-bottom: 1px dashed var(--line); font-size: 13.5px; flex-wrap: wrap; }
.row-item:last-child { border-bottom: none; }
.num-badge { color: var(--orange2); font-weight: 700; }
.win { color: var(--green); font-weight: 700; }
.lose { color: var(--sub); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 11px; text-align: left; font-size: 13px; border-bottom: 1px solid var(--line); }
th { background: var(--bg2); color: var(--sub); font-size: 12px; }
td.num { text-align: center; }
.score-head { display: flex; align-items: center; justify-content: center; gap: 16px; font-size: 22px; margin: 10px 0 6px; flex-wrap: wrap; }
.score-head .s { font-weight: 800; font-size: 30px; }
.news-body { line-height: 2; font-size: 14.5px; color: #c9d4e0; white-space: pre-wrap; }
.season-pill { display: inline-block; background: rgba(56,189,248,.1); color: var(--blue); border-radius: 999px; padding: 2px 10px; font-size: 12px; margin: 2px 4px 2px 0; }
.loading { text-align: center; color: var(--sub); padding: 80px 0; font-size: 15px; }
</style>
</head>
<body>
<header class="topbar">
<div class="logo">
<span class="logo-ball">🏀</span>
<div>
<h1 id="site-name">NBA球迷大全</h1>
<p class="sub">详情页</p>
</div>
</div>
<nav class="tabs">
<a class="tab" href="/">💬 对话</a>
<a class="tab" href="/#/teams">🏟️ 球队</a>
<a class="tab" href="/#/players">⭐ 球员</a>
<a class="tab" href="/#/games">📅 比赛</a>
<a class="tab" href="/#/news">📰 新闻</a>
<a class="tab" href="/#/persons">👥 人物</a>
<a class="tab" href="/#/standings">🏆 排名</a>
</nav>
</header>
<main>
<div class="detail-bar">
<button class="back-btn" onclick="history.length > 1 ? history.back() : (location.href='/')">← 返回</button>
<div id="crumb"></div>
</div>
<div id="detail-body"><div class="loading">⏳ 加载中…</div></div>
</main>
<footer class="footer" id="footer-text">NBA球迷大全 · 数据为模拟演示数据</footer>
<script>
fetch("/api/boot").then(r => r.json()).then(b => {
if (b.site_name) { document.title = b.site_name + " · 详情"; document.getElementById("site-name").textContent = b.site_name; }
if (b.footer_text) document.getElementById("footer-text").textContent = b.footer_text;
}).catch(() => {});
</script>
<script src="/static/detail.js"></script>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
/* NBA球迷大全 独立详情页渲染(/team/1 /player/1 /game/1 /news/1 /person/1 */
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const STATUS_CN = { scheduled: "未开始", live: "进行中", finished: "已结束" };
async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error("记录不存在或已删除"); return r.json(); }
/* 交叉链接 */
const T = (id, name) => `<a class="xlink" href="/team/${id}">${esc(name)}</a>`;
const P = (id, name) => `<a class="xlink" href="/player/${id}">${esc(name)}</a>`;
const G = (id, text) => `<a class="xlink" href="/game/${id}">${esc(text)}</a>`;
const N = (id, title) => `<a class="xlink" href="/news/${id}">${esc(title)}</a>`;
const PER = (id, name) => `<a class="xlink" href="/person/${id}">${esc(name)}</a>`;
/* ================= 球队 ================= */
function renderTeam(d) {
const t = d.team;
const standings = (d.standings || []).map((s) =>
`<span class="season-pill">${esc(s.season)} ${esc(s.conference)}${s.rank}名 · ${s.wins}-${s.losses}</span>`).join("");
const roster = (d.roster || []).map((p) => `
<div class="row-item">
<span>${P(p.id, p.name)} <span class="en">#${p.number} · ${esc(p.position)}</span></span>
<span>本季场均 <span class="num-badge">${p.season.pts}</span> 分 / ${p.season.reb} 板 / ${p.season.ast} 助</span>
</div>`).join("") || '<div class="txt">暂无球员数据</div>';
const games = (d.recent_games || []).map((g) => {
const score = g.status === "finished" ? `<span class="num-badge">${g.away_score} : ${g.home_score}</span>` : "VS";
return `<div class="row-item">
<span>${G(g.id, `${g.away_team} ${score} ${g.home_team}`)}</span>
<span class="en">${esc(g.round_name)} · ${esc(g.game_time.slice(0, 16))} · ${STATUS_CN[g.status]}</span>
</div>`;
}).join("") || '<div class="txt">暂无比赛</div>';
$("#crumb").innerHTML = `<span class="en">🏟️ 球队 / ${esc(t.name)}</span>`;
$("#detail-body").innerHTML = `
<div class="hero">
<div class="big">${esc(t.name)}</div>
<div class="detail-sub">${esc(t.name_en)} · ${esc(t.city)} · ${esc(t.arena)} · 建队 ${t.founded} 年 · 总冠军 ×${t.champion_count}</div>
</div>
<div class="section"><h3>📈 各赛季战绩</h3>${standings || '<span class="txt">暂无排名数据</span>'}</div>
<div class="section"><h3>📝 球队简介</h3><div class="txt">${esc(t.intro || "暂无简介")}</div></div>
<div class="section"><h3>🧑‍🏫 主教练</h3><div class="txt">${esc(t.head_coach || "未知")}</div></div>
<div class="section"><h3>⭐ 主要球员(本赛季场均)</h3>${roster}</div>
<div class="section"><h3>📅 近期比赛</h3>${games}</div>`;
}
/* ================= 球员 ================= */
function renderPlayer(p) {
const logs = (p.game_logs || []).map((g) => {
const score = g.home_score !== null ? `${g.away_score} : ${g.home_score}` : "VS";
return `<div class="row-item">
<span>${G(g.game_id, `${g.away_team} ${score} ${g.home_team}`)} <span class="en">${esc(g.round_name)} · ${esc(g.game_time.slice(0, 10))}</span></span>
<span><span class="num-badge">${g.pts}</span>分 ${g.reb}${g.ast}${g.stl}${g.blk}${g.min}分钟</span>
</div>`;
}).join("") || '<div class="txt">暂无比赛记录</div>';
$("#crumb").innerHTML = `<span class="en">⭐ 球员 / ${esc(p.name)}</span>`;
$("#detail-body").innerHTML = `
<div class="hero">
<div class="big">${esc(p.name)}</div>
<div class="detail-sub">${esc(p.name_en || "")} · ${p.team ? T(p.team_id ?? 0, p.team) : "自由球员"} · ${esc(p.position)} · #${p.number} · ${esc(p.country)}</div>
</div>
<div class="section"><h3>📋 基本信息</h3>
<div class="kv">
<div><span class="k">身高</span><span class="v">${p.height_cm} cm</span></div>
<div><span class="k">体重</span><span class="v">${p.weight_kg} kg</span></div>
<div><span class="k">选秀</span><span class="v">${esc(p.draft)}</span></div>
<div><span class="k">年薪</span><span class="v">$${(p.salary_m / 100).toFixed(2)} 亿</span></div>
<div><span class="k">本季场均</span><span class="v">${p.season.pts}${p.season.reb}${p.season.ast}助</span></div>
<div><span class="k">本季防守</span><span class="v">${p.season.stl}${p.season.blk}帽</span></div>
<div><span class="k">生涯场均</span><span class="v">${p.career.pts}${p.career.reb}${p.career.ast}助</span></div>
<div><span class="k">生涯场次</span><span class="v">${p.career.games} 场</span></div>
</div>
</div>
<div class="section"><h3>🏅 主要荣誉</h3><div class="txt">${esc(p.awards || "暂无")}</div></div>
<div class="section"><h3>📝 球员简介</h3><div class="txt">${esc(p.bio || "暂无")}</div></div>
<div class="section"><h3>🏀 近期比赛记录</h3>${logs}</div>`;
}
/* ================= 比赛 ================= */
function renderGame(g) {
const finished = g.status === "finished";
const head = finished
? `<div class="score-head">${T(g.home_team_id, g.home_team)} <span class="s">${g.home_score}</span> : <span class="s">${g.away_score}</span> ${T(g.away_team_id, g.away_team)}</div>`
: `<div class="score-head">${T(g.home_team_id, g.home_team)} VS ${T(g.away_team_id, g.away_team)}</div>`;
const stats = (g.box_score || []).map((s) => `
<tr><td>${esc(s.team_name)}</td><td>${P(s.player_id, s.player_name)}</td>
<td class="num">${s.points}</td><td class="num">${s.rebounds}</td><td class="num">${s.assists}</td>
<td class="num">${s.steals}</td><td class="num">${s.blocks}</td><td class="num">${s.minutes}</td></tr>`).join("");
$("#crumb").innerHTML = `<span class="en">📅 比赛 / ${esc(g.round_name)}</span>`;
$("#detail-body").innerHTML = `
<div class="hero">
<div style="text-align:center;color:var(--sub);font-size:13px">${esc(g.round_name)} · ${STATUS_CN[g.status]}</div>
${head}
<div style="text-align:center;color:var(--sub);font-size:13px;margin-top:6px">🕐 ${esc(g.game_time)} · ${esc(g.venue)} · ${esc(g.broadcast)}</div>
</div>
${stats ? `<div class="section"><h3>📊 球员技术统计</h3>
<table><tr><th>球队</th><th>球员</th><th>得分</th><th>篮板</th><th>助攻</th><th>抢断</th><th>盖帽</th><th>分钟</th></tr>${stats}</table>
</div>` : '<div class="section"><h3>📊 球员技术统计</h3><div class="txt">暂无统计</div></div>'}`;
}
/* ================= 新闻/百科 ================= */
function renderNews(n) {
$("#crumb").innerHTML = `<span class="en">📰 ${n.kind === "wiki" ? "百科" : "新闻"} / ${esc(n.title)}</span>`;
$("#detail-body").innerHTML = `
<div class="hero">
<div class="big">${esc(n.title)}</div>
<div class="detail-sub">🕐 ${esc(n.publish_time || "")} · ${esc(n.source || "")}${n.author ? " · " + esc(n.author) : ""} · ${n.kind === "wiki" ? "百科词条" : "新闻"}${n.tags ? " · " + esc(n.tags) : ""}</div>
</div>
<div class="section"><h3>📄 正文</h3><div class="news-body">${esc(n.content || "")}</div></div>`;
}
/* ================= 人物 ================= */
function renderPerson(p) {
$("#crumb").innerHTML = `<span class="en">👥 人物 / ${esc(p.name)}</span>`;
$("#detail-body").innerHTML = `
<div class="hero">
<div class="big">${esc(p.name)}</div>
<div class="detail-sub">${esc(p.role_cn || p.role || "")} · ${esc(p.title || "")}${p.team_id ? " · " + T(p.team_id, p.team_name || "相关球队") : ""}</div>
</div>
<div class="section"><h3>📝 人物简介</h3><div class="txt">${esc(p.bio || "暂无")}</div></div>
<div class="section"><h3>🏅 主要成就</h3><div class="txt">${esc(p.achievements || "暂无")}</div></div>`;
}
/* ================= 路由 ================= */
const ROUTERS = { team: renderTeam, player: renderPlayer, game: renderGame, news: renderNews, person: renderPerson };
const APIS = { team: "/api/teams/", player: "/api/players/", game: "/api/games/", news: "/api/news/", person: "/api/persons/" };
(async function init() {
const m = location.pathname.match(/^\/(team|player|game|news|person)\/(\d+)\/?$/);
if (!m) { $("#detail-body").innerHTML = '<div class="loading">❌ 地址无效</div>'; return; }
const [etype, eid] = [m[1], parseInt(m[2])];
try {
const d = await getJSON(APIS[etype] + eid);
ROUTERS[etype](d);
} catch (e) {
$("#detail-body").innerHTML = `<div class="loading">❌ ${esc(e.message)}<br><br><a class="tab" href="/">← 返回首页</a></div>`;
}
})();
+3
View File
@@ -84,11 +84,14 @@
</section>
<section id="view-standings" class="view">
<div class="toolbar">
<label class="season-label" for="season-select">📅 赛季</label>
<select id="season-select" class="search" style="flex:0 0 auto;max-width:160px"></select>
<button class="btn small active" data-conf="">全部</button>
<button class="btn small" data-conf="西部">西部</button>
<button class="btn small" data-conf="东部">东部</button>
</div>
<div class="table-wrap" id="wrap-standings"></div>
<div id="wrap-bracket"></div>
</section>
</main>
+17
View File
@@ -140,6 +140,23 @@ th { background: var(--bg2); color: var(--sub); font-weight: 600; font-size: 12.
tr:hover td { background: rgba(249,115,22,.05); }
td.num { text-align: center; }
/* ---------- 赛季选择 / 详情页链接 / 对阵图 ---------- */
.season-label { color: var(--sub); font-size: 13px; }
.xlink { color: var(--blue); text-decoration: none; }
.xlink:hover { color: var(--orange2); text-decoration: underline; }
.detail-link { display: inline-block; color: var(--orange2); text-decoration: none; font-size: 13.5px; font-weight: 600; border: 1px solid #7c3a1e; background: rgba(249,115,22,.08); border-radius: 999px; padding: 6px 14px; transition: .15s; }
.detail-link:hover { background: rgba(249,115,22,.18); }
.bracket { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 6px; }
.bracket-col { flex: 1; min-width: 190px; background: var(--bg2); border: 1px solid var(--line); border-radius: 12px; padding: 10px; }
.bracket-col-title { color: var(--sub); font-size: 12px; text-align: center; margin-bottom: 8px; letter-spacing: 1px; }
.match { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin-bottom: 10px; overflow: hidden; }
.mteam { display: flex; justify-content: space-between; align-items: center; padding: 7px 10px; font-size: 12.5px; cursor: pointer; border-bottom: 1px dashed var(--line); }
.mteam:last-child { border-bottom: none; }
.mteam:hover { background: rgba(249,115,22,.08); }
.mteam.win { color: var(--green); font-weight: 700; }
.mteam-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mteam-wins { color: var(--orange2); font-weight: 800; margin-left: 6px; }
/* ---------- 弹窗 ---------- */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
.modal.hidden { display: none; }