From 84b7b0f893d65f864f72a21e70f583e55bc1bbd4 Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Tue, 20 Oct 2026 13:38:23 +0800 Subject: [PATCH] feat(website): add competition results and service deployment - Add the event results page with schedule, rankings, Battle bracket, athletes, records, bilingual labels, and navigation entry. - Add the read/write competition data service, a complete Excel example, empty public state, and validation smoke tests. - Handle Furion statusCode/data responses from the production API and retain static fallback for the public page. - Document Docker sidecar deployment beside test_eventapi, persistent storage, private admin access, Nginx routing, and OSS asset rewriting. Verification: - node server/competition-api.smoke.mjs - node scripts/competition-response-test.mjs - node scripts/battle-layout-test.mjs - npm run build -- --base=https://assets.example.test/static/test/ - npm run rewrite:oss -- https://assets.example.test/static/test/ - Production /api/competition/state returned HTTP 200 after the sidecar started. Deployment note: publish the updated YD-WEB static build before public results can consume the unwrapped API response. The event remains unpublished until an administrator saves and publishes data. --- .gitattributes | 1 + .gitignore | 2 + COMPETITION-DEPLOY.md | 56 ++++++ deploy/competition-api/Dockerfile | 17 ++ deploy/competition-api/README.md | 36 ++++ deploy/competition-api/start-on-event-host.sh | 52 ++++++ deploy/nginx/jitsi-competition-api.conf | 6 + .../跃动跳绳完整赛事示例.xlsx | Bin 0 -> 15548 bytes package.json | 3 +- public/competition-state.json | 16 ++ public/competition/default-avatar.svg | 1 + scripts/battle-layout-test.mjs | 34 ++++ scripts/competition-response-test.mjs | 18 ++ scripts/rewrite-oss-paths.mjs | 8 +- server/competition-api.mjs | 144 ++++++++++++++++ server/competition-api.smoke.mjs | 55 ++++++ src/App.vue | 9 +- src/components/BattleBracket.vue | 117 +++++++++++++ src/components/SiteHeader.vue | 56 +----- src/components/SportsGroupedSection.vue | 5 + src/data/battle-layout.js | 45 +++++ src/data/competition-api.js | 25 +++ src/data/competition-response.js | 19 ++ src/data/page-content.js | 3 +- src/router.js | 2 + src/style.css | 163 ++++++++++++++++-- src/views/CompetitionResultsPage.vue | 146 ++++++++++++++++ vite.config.mjs | 5 +- 28 files changed, 972 insertions(+), 72 deletions(-) create mode 100644 .gitattributes create mode 100644 COMPETITION-DEPLOY.md create mode 100644 deploy/competition-api/Dockerfile create mode 100644 deploy/competition-api/README.md create mode 100644 deploy/competition-api/start-on-event-host.sh create mode 100644 deploy/nginx/jitsi-competition-api.conf create mode 100644 outputs/competition-sample-2026-10-19/跃动跳绳完整赛事示例.xlsx create mode 100644 public/competition-state.json create mode 100644 public/competition/default-avatar.svg create mode 100644 scripts/battle-layout-test.mjs create mode 100644 scripts/competition-response-test.mjs create mode 100644 server/competition-api.mjs create mode 100644 server/competition-api.smoke.mjs create mode 100644 src/components/BattleBracket.vue create mode 100644 src/data/battle-layout.js create mode 100644 src/data/competition-api.js create mode 100644 src/data/competition-response.js create mode 100644 src/views/CompetitionResultsPage.vue diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ab68740 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +deploy/competition-api/*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 4117859..6195edc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .vite/ *.log +server/data/ +outputs/competition-api-deploy-* diff --git a/COMPETITION-DEPLOY.md b/COMPETITION-DEPLOY.md new file mode 100644 index 0000000..909ac3e --- /dev/null +++ b/COMPETITION-DEPLOY.md @@ -0,0 +1,56 @@ +# 官网赛事成绩接入 + +官网入口为 `/#/sports/competition` 的第 4 项「赛事成绩」,独立页面为 `/#/sports/results`。`YD_Event.Web` 的「官网赛事信息」页面负责维护与发布。原需求文档的系统 B(现场评分控制台)不在此接入链路中。 + +## 运行接口 + +接口使用 Node.js 18+,没有额外依赖。生产环境必须设置以下环境变量后运行 `npm run serve:competition-api`: + +| 变量 | 用途 | +|---|---| +| `COMPETITION_DATA_PATH` | 持久化 JSON 文件路径;默认 `server/data/competition-state.json`。生产环境应放在持久化磁盘并备份 | +| `COMPETITION_PORT` | 监听端口,默认 `8787` | +| `COMPETITION_HOST` | 监听地址,默认 `127.0.0.1`;分机部署时设为赛事服务的私网 IP,并用防火墙限制 `8787` 端口只允许后台服务器访问 | +| `COMPETITION_ADMIN_ALLOWED_IPS` | 允许直接访问内部管理接口的客户端 IP,逗号分隔;分机部署时填写 `YD_Event.Admin` 服务器的私网 IP。服务默认还允许本机环回地址 | +| `COMPETITION_ALLOWED_ORIGINS` | 仅在浏览器直接访问赛事服务时需要;当前正式链路由 YD_Event.Admin 转发,官网浏览器不直接连接赛事服务 | + +官网通过 `https://jitsi-web.yuedongsports.com/api/competition/state` 读取已发布数据。这个匿名只读接口由 YD_Event.Admin 提供,并通过私网读取赛事服务的 `/api/competition/state`;未发布时赛事服务只返回空态。管理页仍通过需要登录的 `/api/website-competition` 读取草稿及保存,不需要管理密钥。官网每 10 秒重新读取一次公开数据。 + +启动赛事服务时设置 `COMPETITION_HOST=<赛事服务私网 IP>` 和 `COMPETITION_ADMIN_ALLOWED_IPS=<后台服务器私网 IP>`;在 YD_Event.Admin 设置 `CompetitionApi__AdminUrl=http://<赛事服务私网 IP>:8787/api/competition/admin`。防火墙只允许后台服务器访问赛事服务的 `8787` 端口。先从后台服务器执行 `curl -i http://<赛事服务私网 IP>:8787/api/competition/ping`,确认服务返回 `200`。`jitsi-web.yuedongsports.com` 沿用现有 `/api/` 到 YD_Event.Admin 的代理,无需将公开请求直接代理到赛事服务。 + +若运维已应用旧版赛事 Nginx 片段,应改用 [`deploy/nginx/jitsi-competition-api.conf`](deploy/nginx/jitsi-competition-api.conf):只屏蔽公网 `/api/competition/admin`,让公开 `/api/competition/state` 走现有后台代理。修改后执行 `nginx -t` 再重载 Nginx。 + +部署后从官网域名验证公开接口及 CORS 响应头: + +```bash +curl -i -H 'Origin: https://www.yuedongsports.com' https://jitsi-web.yuedongsports.com/api/competition/state +curl -i https://jitsi-web.yuedongsports.com/api/competition/admin +``` + +第一条请求应返回 `200` 和允许 `https://www.yuedongsports.com` 的 `Access-Control-Allow-Origin`;第二条公网管理路径应返回 `404`。若公开接口仍返回 `404`,先确认 YD_Event.Admin 已部署匿名只读控制器,且旧版 Nginx 赛事代理已移除。若返回 `502`,检查内网赛事服务、`CompetitionApi__AdminUrl` 和防火墙。内部管理接口只接受 `COMPETITION_ADMIN_ALLOWED_IPS` 中的来源地址;公网不可直连赛事服务的 `8787` 端口。 + +2026-10-20 排查发现:此前线上 `/api/competition/state` 返回后台生成的 `502`,原因是 `test_eventapi` 容器未配置 `CompetitionApi__AdminUrl`,同机没有监听 `8787` 的赛事数据服务。部署共享网络的赛事容器后,公开接口已恢复 `200`。当前后台响应由 Furion 包装为 `statusCode/data`,官网读取代码会解包其中的赛事 JSON;官网更新构建后才会使用这项解析修复。 + +若赛事服务与 `test_eventapi` 部署在同一台 Docker 主机,可按 [`deploy/competition-api/README.md`](deploy/competition-api/README.md) 启动独立的 Node 容器并共享后台容器的网络命名空间。两个容器因此共用回环地址,后台现有默认 `127.0.0.1:8787` 可用,且 `8787` 不需要对公网开放。后台容器被 Jenkins 重建时必须同步重建该赛事服务容器。 + +| 方法 | 路径 | 用途 | +|---|---|---| +| GET | `jitsi-web.yuedongsports.com/api/competition/state` | YD_Event.Admin 的匿名只读接口;只返回已发布数据 | +| GET/PUT | `jitsi-web.yuedongsports.com/api/website-competition` | 后台登录后读取草稿、保存或发布 | +| GET/PUT | `<赛事服务私网 IP>:8787/api/competition/admin` | 仅供 YD_Event.Admin 内部调用;按来源 IP 放行 | + +官网生产构建默认从 `https://jitsi-web.yuedongsports.com/api/competition/state` 读取,每 10 秒刷新一次,不再请求 `https://www.yuedongsports.com/api/competition/state`。如需覆盖,在构建官网时设置 `VITE_COMPETITION_API_URL` 为完整接口基地址。开发时官网 Vite 会把 `/api/competition` 代理到 `http://localhost:8787`,可用 `COMPETITION_API_TARGET` 覆盖。 + +YD_Event.Web 的「官网赛事信息」路由隐藏于菜单,未登录访问会跳转登录页;管理接口要求已有后台登录态。正式环境访问基地址为 `https://jitsi-web.yuedongsports.com/`。分机部署时将 `CompetitionApi__AdminUrl` 设为 `http://<赛事服务私网 IP>:8787/api/competition/admin`。后台管理和公开只读接口均由 YD_Event.Admin 通过私网转发到赛事服务。 + +升级两端服务后,旧的 `COMPETITION_ADMIN_TOKEN`、`CompetitionApi__AdminToken` 和 `CompetitionApi__AdminTokenFile` 可从部署配置中移除;它们不再被本功能读取。上线前由运维确认后台私网 IP 白名单、防火墙入站规则及公网管理路径屏蔽均已生效,再验证无密钥的后台保存流程。 + +## 上传格式 + +管理页提供包含填写说明和示例行的 Excel 模板。一次上传 `.xlsx` 文件会将赛程、全能成绩、挑战成绩、Battle、选手和纪录载入草稿,点击「保存官网赛事信息」才写入接口。页面也可下载当前数据的 Excel 文件。 + +Excel 工作表依次为「填写说明」「赛程」「全能成绩」「挑战成绩」「Battle场次」「选手」「纪录」。六张数据表的表头必须保留;删除示例行后可填写正式数据。全能成绩的名次使用官方成绩册数据,前台不重算名次。挑战成绩的「榜单」区分决赛和资格赛。 + +缺失的图片回退到默认头像。照片字段应使用浏览器可访问的 HTTPS 地址或官网同源路径;上传图片文件本身由现有图片存储服务负责。仓库内的 `public/competition-state.json` 是静态只读空态,不会存放管理后台的未发布数据。 + +运行接口验证:`node server/competition-api.smoke.mjs`。 diff --git a/deploy/competition-api/Dockerfile b/deploy/competition-api/Dockerfile new file mode 100644 index 0000000..bba8e46 --- /dev/null +++ b/deploy/competition-api/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY server/competition-api.mjs /app/server/competition-api.mjs +COPY public/competition-state.json /app/public/competition-state.json + +RUN mkdir -p /app/data && touch /app/data/.keep && chown -R node:node /app +USER node + +ENV NODE_ENV=production \ + COMPETITION_DATA_PATH=/app/data/competition-state.json + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('node:http').get('http://127.0.0.1:8787/api/competition/ping', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +CMD ["node", "server/competition-api.mjs"] diff --git a/deploy/competition-api/README.md b/deploy/competition-api/README.md new file mode 100644 index 0000000..84be632 --- /dev/null +++ b/deploy/competition-api/README.md @@ -0,0 +1,36 @@ +# 在现有后台服务器启动赛事数据服务 + +适用于 `test_eventapi` 已在 Docker 中运行,而同机尚无赛事数据服务的部署。服务容器共享后台容器的网络命名空间,只监听 `127.0.0.1:8787`。后台现有的默认上游地址因此可直接使用;不需要发布 8787 端口,也不需要改动后台的登录验证。 + +## 部署 + +将本目录的 `Dockerfile`、`start-on-event-host.sh`,以及仓库中的 `server/competition-api.mjs`、`public/competition-state.json` 按以下结构上传到服务器的同一个目录: + +```text +Dockerfile +start-on-event-host.sh +server/competition-api.mjs +public/competition-state.json +``` + +在该目录执行: + +```sh +sh start-on-event-host.sh +docker exec test_eventapi curl -i --max-time 5 http://127.0.0.1:8787/api/competition/ping +curl -i https://jitsi-web.yuedongsports.com/api/competition/state +``` + +如果使用交付压缩包,可先通过 MobaXterm 的 SFTP 面板上传 `competition-api-deploy-2026-10-20.tar.gz` 到服务器,然后执行: + +```sh +tar -xzf competition-api-deploy-2026-10-20.tar.gz +cd competition-api-deploy-2026-10-20 +sh start-on-event-host.sh +``` + +脚本执行后再运行上方的两条接口验证命令。 + +脚本会构建单独的 Node 镜像,创建持久化 Docker 卷 `yd-competition-data`,并启动容器 `yd-competition-api`。它不会替换或重启现有后台容器。如果同名服务容器已经存在,脚本会停止并提示人工检查,不会覆盖已有数据。管理页仍需后台登录才能访问;公网不得发布 `/api/competition/admin`。 + +若后台容器名称不同,在运行脚本前设置 `BACKEND_CONTAINER`。若后续由 Jenkins **重建**后台容器,赛事服务需要随新的后台容器重新建立网络共享;将两者纳入同一部署流程。升级赛事服务前备份 `yd-competition-data` 卷中的 `competition-state.json`。 diff --git a/deploy/competition-api/start-on-event-host.sh b/deploy/competition-api/start-on-event-host.sh new file mode 100644 index 0000000..f9c9f23 --- /dev/null +++ b/deploy/competition-api/start-on-event-host.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$script_dir" + +backend_container=${BACKEND_CONTAINER:-test_eventapi} +service_container=${SERVICE_CONTAINER:-yd-competition-api} +image_name=${IMAGE_NAME:-yd-competition-api:local} +volume_name=${DATA_VOLUME:-yd-competition-data} + +if [ "$(docker inspect --format '{{.State.Running}}' "$backend_container")" != true ]; then + echo "Backend container is not running: $backend_container" >&2 + exit 1 +fi + +if docker container inspect "$service_container" >/dev/null 2>&1; then + echo "Service container already exists: $service_container" >&2 + exit 1 +fi + +docker build -f Dockerfile -t "$image_name" . +docker volume create "$volume_name" >/dev/null +docker run -d \ + --name "$service_container" \ + --restart unless-stopped \ + --network "container:$backend_container" \ + --mount "type=volume,source=$volume_name,target=/app/data" \ + "$image_name" + +ready=false +attempt=0 +while [ "$attempt" -lt 20 ]; do + if docker exec "$backend_container" sh -c \ + 'curl -fsS --max-time 5 http://127.0.0.1:8787/api/competition/ping' >/dev/null 2>&1; then + ready=true + break + fi + attempt=$((attempt + 1)) + sleep 1 +done + +if [ "$ready" != true ]; then + echo "Competition API did not become ready on 127.0.0.1:8787" >&2 + docker logs --tail 30 "$service_container" >&2 + exit 1 +fi + +docker exec "$backend_container" sh -c \ + 'curl -fsS --max-time 5 http://127.0.0.1:8787/api/competition/ping' +echo +echo "Competition API is running beside $backend_container" diff --git a/deploy/nginx/jitsi-competition-api.conf b/deploy/nginx/jitsi-competition-api.conf new file mode 100644 index 0000000..67799c4 --- /dev/null +++ b/deploy/nginx/jitsi-competition-api.conf @@ -0,0 +1,6 @@ +# Include inside the HTTPS server block for jitsi-web.yuedongsports.com. +# Public GET /api/competition/state is served by YD_Event.Admin through +# the existing /api/ proxy. The internal write path must stay private. +location = /api/competition/admin { + return 404; +} diff --git a/outputs/competition-sample-2026-10-19/跃动跳绳完整赛事示例.xlsx b/outputs/competition-sample-2026-10-19/跃动跳绳完整赛事示例.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bef3561bc71d7f748ec07ce0be8d35f4f2d7b876 GIT binary patch literal 15548 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 T#KK!nG6K*EAgr1=-#-066)K8Y literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 884ef14..2b71efc 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "rewrite:oss": "node scripts/rewrite-oss-paths.mjs", "deploy:oss": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-oss.ps1", - "preview": "vite preview" + "preview": "vite preview", + "serve:competition-api": "node server/competition-api.mjs" }, "dependencies": { "vue": "^3.5.18", diff --git a/public/competition-state.json b/public/competition-state.json new file mode 100644 index 0000000..8d3d240 --- /dev/null +++ b/public/competition-state.json @@ -0,0 +1,16 @@ +{ + "published": false, + "competition": { + "nameZh": "", + "nameEn": "", + "dateZh": "", + "dateEn": "", + "venueZh": "", + "venueEn": "" + }, + "notice": { "enabled": false, "textZh": "", "textEn": "" }, + "schedule": { "currentId": "", "callingId": "", "upcomingCount": 3, "items": [] }, + "results": { "overall": [], "challenge": [], "battle": [] }, + "players": [], + "records": [] +} diff --git a/public/competition/default-avatar.svg b/public/competition/default-avatar.svg new file mode 100644 index 0000000..d7b331c --- /dev/null +++ b/public/competition/default-avatar.svg @@ -0,0 +1 @@ +NO PHOTO diff --git a/scripts/battle-layout-test.mjs b/scripts/battle-layout-test.mjs new file mode 100644 index 0000000..15dded6 --- /dev/null +++ b/scripts/battle-layout-test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { buildBattleLayout } from '../src/data/battle-layout.js'; + +const battle = { + rounds: [ + { matches: [ + { id: 'R1A', aPlayer: 'A', bPlayer: 'B', winner: 'A' }, + { id: 'R1B', aPlayer: 'C', bPlayer: 'D', winner: 'C' }, + { id: 'R1C', aPlayer: 'E', bPlayer: 'F', winner: 'E' }, + { id: 'R1D', aPlayer: 'G', bPlayer: 'H', winner: 'G' }, + ] }, + { matches: [ + { id: 'R2A', aPlayer: 'A', bPlayer: 'E', winner: 'A' }, + { id: 'R2B', aPlayer: 'C', bPlayer: 'G', winner: 'G' }, + ] }, + { matches: [{ id: 'FINAL', aPlayer: 'A', bPlayer: 'G', winner: 'A' }] }, + ], +}; + +const layout = buildBattleLayout(battle); +assert.equal(layout.lastRoundIndex, 2); +assert.deepEqual(layout.sources.get('FINAL'), { aFrom: 'R2A', bFrom: 'R2B' }); +assert.deepEqual(layout.sources.get('R2A'), { aFrom: 'R1A', bFrom: 'R1C' }); +assert.deepEqual([...layout.sides.left].sort(), ['R1A', 'R1C', 'R2A']); +assert.deepEqual([...layout.sides.right].sort(), ['R1B', 'R1D', 'R2B']); + +const explicit = buildBattleLayout({ rounds: [ + { matches: [{ id: 'A', winner: '' }, { id: 'B', winner: '' }] }, + { matches: [{ id: 'F', aFrom: 'A', bFrom: 'B' }] }, +] }); +assert.deepEqual(explicit.sources.get('F'), { aFrom: 'A', bFrom: 'B' }); +assert.equal(buildBattleLayout({ rounds: [{ matches: [{ id: 'ONLY' }] }] }).lastRoundIndex, 0); + +console.log('Battle 轮次和晋级来源解析通过'); diff --git a/scripts/competition-response-test.mjs b/scripts/competition-response-test.mjs new file mode 100644 index 0000000..4ad9b81 --- /dev/null +++ b/scripts/competition-response-test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { unwrapCompetitionState } from '../src/data/competition-response.js'; + +const state = { + published: true, + competition: {}, + notice: {}, + schedule: { items: [] }, + results: { overall: [], challenge: [], battle: [] }, + players: [], + records: [], +}; + +assert.deepEqual(unwrapCompetitionState(state), state); +assert.deepEqual(unwrapCompetitionState({ statusCode: 200, data: JSON.stringify(state) }), state); +assert.throws(() => unwrapCompetitionState({ statusCode: 502, errors: '上游连接失败' }), /上游连接失败/); +assert.throws(() => unwrapCompetitionState({ statusCode: 200, data: '{}' }), /结构不正确/); +console.log('Competition response parsing passed'); diff --git a/scripts/rewrite-oss-paths.mjs b/scripts/rewrite-oss-paths.mjs index fd8662d..4c5b8ac 100644 --- a/scripts/rewrite-oss-paths.mjs +++ b/scripts/rewrite-oss-paths.mjs @@ -24,7 +24,8 @@ collectFiles(distDir); // Vite has already rewritten static template/CSS URLs. Only rewrite root asset // paths that still begin immediately after a JavaScript quote or template tick. -const rootAssetPattern = /(["'`])\/(site|materials|sports-hall)\//g; +const rootAssetPattern = /(["'`])\/(site|materials|sports-hall|competition)\//g; +const rootCompetitionDataPattern = /(["'`])\/competition-state\.json/g; let changedFiles = 0; for (const file of files.filter((item) => extname(item) === '.js')) { @@ -32,7 +33,7 @@ for (const file of files.filter((item) => extname(item) === '.js')) { const rewritten = source.replace( rootAssetPattern, (_match, quote, folder) => `${quote}${base}${folder}/`, - ); + ).replace(rootCompetitionDataPattern, (_match, quote) => `${quote}${base}competition-state.json`); if (rewritten !== source) { writeFileSync(file, rewritten, 'utf8'); @@ -49,10 +50,11 @@ for (const file of files) { if (content.includes(duplicatePrefix) || /https:\/\/[^\s"'`]+https:\/\//.test(content)) { duplicated.push(file); } - if (extname(file) === '.js' && rootAssetPattern.test(content)) { + if (extname(file) === '.js' && (rootAssetPattern.test(content) || rootCompetitionDataPattern.test(content))) { unresolved.push(file); } rootAssetPattern.lastIndex = 0; + rootCompetitionDataPattern.lastIndex = 0; } if (duplicated.length) { diff --git a/server/competition-api.mjs b/server/competition-api.mjs new file mode 100644 index 0000000..8445181 --- /dev/null +++ b/server/competition-api.mjs @@ -0,0 +1,144 @@ +import { createServer } from 'node:http'; +import { readFile, mkdir, rename, writeFile } from 'node:fs/promises'; +import { isIP } from 'node:net'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const dataPath = resolve(process.env.COMPETITION_DATA_PATH || resolve(root, 'server/data/competition-state.json')); +const seedPath = resolve(root, 'public/competition-state.json'); +const allowedOrigins = new Set((process.env.COMPETITION_ALLOWED_ORIGINS || '').split(',').map((item) => item.trim()).filter(Boolean)); +const adminAllowedIps = new Set(['127.0.0.1', '::1', ...(process.env.COMPETITION_ADMIN_ALLOWED_IPS || '').split(',').map((item) => item.trim()).filter(Boolean)]); +const port = Number(process.env.COMPETITION_PORT || 8787); +const host = process.env.COMPETITION_HOST || '127.0.0.1'; +const maxBody = 8 * 1024 * 1024; +const clients = new Set(); +let writeQueue = Promise.resolve(); + +if ([...adminAllowedIps].some((address) => !isIP(address))) { + console.error('COMPETITION_ADMIN_ALLOWED_IPS 只能包含逗号分隔的 IP 地址'); + process.exit(1); +} + +function cors(request, response) { + const origin = request.headers.origin; + if (!origin || !allowedOrigins.has(origin)) return; + response.setHeader('Access-Control-Allow-Origin', origin); + response.setHeader('Vary', 'Origin'); + response.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + response.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS'); +} + +function send(response, status, value) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + response.end(JSON.stringify(value)); +} + +function adminIpAllowed(request) { + const address = request.socket.remoteAddress?.replace(/^::ffff:/, ''); + return adminAllowedIps.has(address); +} + +async function state() { + try { return JSON.parse(await readFile(dataPath, 'utf8')); } + catch (error) { + if (error.code !== 'ENOENT') throw error; + return JSON.parse(await readFile(seedPath, 'utf8')); + } +} + +function validText(value, limit = 300) { + return typeof value === 'string' && value.length <= limit; +} + +function validate(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) return '数据必须是 JSON 对象'; + if (typeof input.published !== 'boolean') return 'published 必须是布尔值'; + const c = input.competition; + if (!c || ['nameZh', 'nameEn', 'dateZh', 'dateEn', 'venueZh', 'venueEn'].some((key) => !validText(c[key]))) return '赛事名称、日期、场地字段无效'; + if (input.published && !c.nameZh.trim()) return '发布前必须填写赛事中文名称'; + const n = input.notice; + if (!n || typeof n.enabled !== 'boolean' || !validText(n.textZh, 1000) || !validText(n.textEn, 1000)) return '公告字段无效'; + const s = input.schedule; + if (!s || !validText(s.currentId, 100) || !validText(s.callingId, 100) || !Number.isInteger(s.upcomingCount) || s.upcomingCount < 0 || s.upcomingCount > 20 || !Array.isArray(s.items) || s.items.length > 1000) return '赛程字段无效'; + const ids = new Set(); + for (const item of s.items) { + if (!item || !validText(item.id, 100) || !item.id || ids.has(item.id) || ['time', 'nameZh', 'nameEn', 'group', 'gender', 'venueZh', 'venueEn'].some((key) => !validText(item[key] ?? ''))) return '赛程含重复编号或无效字段'; + ids.add(item.id); + } + if ((s.currentId && !ids.has(s.currentId)) || (s.callingId && !ids.has(s.callingId))) return '当前/检录场次必须存在于赛程中'; + if (s.upcomingIds != null && (!Array.isArray(s.upcomingIds) || s.upcomingIds.some((id) => !ids.has(id)))) return '即将开始场次必须存在于赛程中'; + if (!input.results || !['overall', 'challenge', 'battle'].every((key) => Array.isArray(input.results[key]) && input.results[key].length <= 1000)) return '成绩数据格式无效'; + if (!Array.isArray(input.players) || input.players.length > 10000 || !Array.isArray(input.records) || input.records.length > 1000) return '选手或纪录数据格式无效'; + if (input.players.some((player) => !player || !validText(player.id, 100) || !validText(player.nameZh || '') || !validText(player.nameEn || ''))) return '选手编号或姓名字段无效'; + if (input.records.some((record) => !record || !validText(record.id, 100) || !validText(record.eventZh || '') || !validText(record.nameZh || ''))) return '纪录字段无效'; + if (input.results.overall.some((bucket) => !bucket || !validText(bucket.key, 100) || !Array.isArray(bucket.entries) || bucket.entries.length > 10000 || bucket.entries.some((entry) => !entry || !validText(entry.playerId, 100)))) return '全能赛成绩格式无效'; + if (input.results.challenge.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rankings) || !Array.isArray(event.qualifier) || [event.rankings, event.qualifier].some((list) => list.length > 10000 || list.some((entry) => !entry || !validText(entry.playerId, 100))))) return '挑战赛成绩格式无效'; + if (input.results.battle.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rounds) || event.rounds.length > 20 || event.rounds.some((round) => !round || !Array.isArray(round.matches) || round.matches.length > 100 || round.matches.some((match) => !match || !validText(match.id, 100))))) return 'Battle 对阵格式无效'; + return ''; +} + +async function readBody(request) { + let total = 0; + const chunks = []; + for await (const chunk of request) { + total += chunk.length; + if (total > maxBody) { + const error = new Error('数据超过 8 MB'); + error.status = 413; + throw error; + } + chunks.push(chunk); + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { const error = new Error('JSON 格式错误'); error.status = 400; throw error; } +} + +function broadcast() { + for (const client of clients) client.write(`event: update\ndata: ${Date.now()}\n\n`); +} + +const server = createServer(async (request, response) => { + cors(request, response); + if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return; } + const path = new URL(request.url, 'http://localhost').pathname; + try { + if (path === '/api/competition/ping' && request.method === 'GET') return send(response, 200, { ok: true, version: 1, mode: 'service' }); + if (path === '/api/competition/state' && request.method === 'GET') { + const current = await state(); + if (current.published) return send(response, 200, current); + const empty = JSON.parse(await readFile(seedPath, 'utf8')); + return send(response, 200, empty); + } + if (path === '/api/competition/stream' && request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); + response.write(': connected\n\n'); + clients.add(response); + request.on('close', () => clients.delete(response)); + return; + } + if (path === '/api/competition/admin' && ['GET', 'PUT'].includes(request.method)) { + if (!adminIpAllowed(request)) return send(response, 403, { error: '管理接口仅允许指定内网地址访问' }); + if (request.method === 'GET') return send(response, 200, await state()); + const next = await readBody(request); + const problem = validate(next); + if (problem) return send(response, 400, { error: problem }); + await (writeQueue = writeQueue.catch(() => {}).then(async () => { + await mkdir(dirname(dataPath), { recursive: true }); + const temp = `${dataPath}.${process.pid}.tmp`; + await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + await rename(temp, dataPath); + })); + broadcast(); + return send(response, 200, { ok: true }); + } + return send(response, 404, { error: '接口不存在' }); + } catch (error) { + console.error(error); + return send(response, error.status || 500, { error: error.status ? error.message : '服务器处理失败' }); + } +}); + +const heartbeat = setInterval(() => { for (const client of clients) client.write(': heartbeat\n\n'); }, 25000); +server.on('close', () => clearInterval(heartbeat)); +server.listen(port, host, () => console.log(`Competition API listening on ${host}:${port}`)); diff --git a/server/competition-api.smoke.mjs b/server/competition-api.smoke.mjs new file mode 100644 index 0000000..3fd7f78 --- /dev/null +++ b/server/competition-api.smoke.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { get } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const directory = await mkdtemp(join(tmpdir(), 'competition-api-')); +const port = 19000 + Math.floor(Math.random() * 30000); +const base = `http://127.0.0.1:${port}/api/competition`; +const server = spawn(process.execPath, ['server/competition-api.mjs'], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + env: { ...process.env, COMPETITION_ADMIN_ALLOWED_IPS: '', COMPETITION_DATA_PATH: join(directory, 'state.json'), COMPETITION_PORT: String(port), COMPETITION_ALLOWED_ORIGINS: 'https://www.yuedongsports.com' }, + stdio: 'ignore', +}); + +try { + let ready = false; + for (let attempt = 0; attempt < 40; attempt++) { + try { ready = (await fetch(`${base}/ping`)).ok; if (ready) break; } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(ready, 'API did not start'); + assert.equal((await fetch(`${base}/admin`)).status, 200); + const deniedStatus = await new Promise((resolve, reject) => { + get(`${base}/admin`, { localAddress: '127.0.0.2' }, (response) => { + response.resume(); + resolve(response.statusCode); + }).on('error', reject); + }); + assert.equal(deniedStatus, 403, 'an unlisted IP reached the admin endpoint'); + const crossOriginState = await fetch(`${base}/state`, { headers: { Origin: 'https://www.yuedongsports.com' } }); + assert.equal(crossOriginState.headers.get('access-control-allow-origin'), 'https://www.yuedongsports.com'); + const streamController = new AbortController(); + const stream = await fetch(`${base}/stream`, { signal: streamController.signal }); + const reader = stream.body.getReader(); + await reader.read(); + const draft = JSON.parse(await (await fetch(`${base}/state`)).text()); + draft.competition.nameZh = '测试赛事'; + draft.schedule.items = [{ id: 'M1', time: '09:00', nameZh: '单摇', nameEn: 'Speed', group: 'A', gender: 'MALE', venueZh: '主场', venueEn: 'Hall A' }]; + assert.equal((await fetch(`${base}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })).status, 200); + const update = await Promise.race([reader.read(), new Promise((_, reject) => setTimeout(() => reject(new Error('SSE update timed out')), 2000))]); + assert.match(new TextDecoder().decode(update.value), /event: update/); + streamController.abort(); + assert.equal((await (await fetch(`${base}/state`)).json()).competition.nameZh, '', 'draft leaked to public'); + draft.published = true; + assert.equal((await fetch(`${base}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })).status, 200); + assert.equal((await (await fetch(`${base}/state`)).json()).competition.nameZh, '测试赛事'); + assert.equal((await (await fetch(`${base}/admin`)).json()).schedule.items.length, 1); + console.log('Competition API smoke test passed'); +} finally { + server.kill(); + await rm(directory, { recursive: true, force: true }); +} diff --git a/src/App.vue b/src/App.vue index c789443..12f7962 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,6 @@ diff --git a/src/components/BattleBracket.vue b/src/components/BattleBracket.vue new file mode 100644 index 0000000..2b08334 --- /dev/null +++ b/src/components/BattleBracket.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/components/SiteHeader.vue b/src/components/SiteHeader.vue index 449479e..a8a6b15 100644 --- a/src/components/SiteHeader.vue +++ b/src/components/SiteHeader.vue @@ -4,19 +4,10 @@ 跃动 YUEDONG @@ -95,42 +86,11 @@ const nav = [ { label: '跃动体育', to: '/sports/competition', - groups: [ - { - label: '竞赛演艺', - to: '/sports/competition', - children: [ - { label: '赛事招募', to: '/sports/competition#event-recruitment' }, - { label: '赛事新闻', to: '/sports/competition#event-news' }, - { label: '演艺活动', to: '/sports/competition#performances' } - ] - }, - { - label: '跃动运动馆', - to: '/sports/venues', - children: [ - { label: '运动馆介绍', to: '/sports/venues#venue-intro' }, - { label: '冠军班', to: '/sports/venues#champion-class' }, - { label: '特色训练营', to: '/sports/venues#camps' }, - { label: '运动馆地址', to: '/sports/venues#venue-address' } - ] - }, - { - label: '跃动装备', - to: '/sports/equipment', - children: [ - { label: '1阶 启蒙型', to: '/sports/equipment-stage-1' }, - { label: '2阶 初学型', to: '/sports/equipment-stage-2' }, - { label: '3阶 提速型', to: '/sports/equipment-stage-3' }, - { label: '4阶 考测型', to: '/sports/equipment-stage-4' }, - { label: '5阶 竞技型', to: '/sports/equipment-stage-5' }, - { label: '校园跳绳', to: '/sports/equipment-campus' }, - { label: '健身跳绳', to: '/sports/equipment-fitness' }, - { label: '跳绳鞋服', to: '/sports/equipment-apparel' }, - { label: '其它周边', to: '/sports/equipment-accessories' }, - { label: '购买渠道', to: '/sports/purchase-channels' } - ] - } + children: [ + { label: '竞赛演艺', to: '/sports/competition' }, + { label: '跃动运动馆', to: '/sports/venues' }, + { label: '跃动装备', to: '/sports/equipment' }, + { label: '赛事信息', to: '/sports/results' } ] }, { label: '跃动科技', to: '/technology' }, diff --git a/src/components/SportsGroupedSection.vue b/src/components/SportsGroupedSection.vue index 9132ca2..88771c9 100644 --- a/src/components/SportsGroupedSection.vue +++ b/src/components/SportsGroupedSection.vue @@ -12,6 +12,7 @@ > {{ String(index + 1).padStart(2, '0') }}{{ item.label }} + 04赛事成绩 ↗ @@ -53,6 +54,10 @@ +
+
EVENT RESULTS

赛事信息

查看赛事日程、实时成绩、选手信息与赛会纪录。

+ 进入赛事信息 +
diff --git a/src/data/battle-layout.js b/src/data/battle-layout.js new file mode 100644 index 0000000..5304382 --- /dev/null +++ b/src/data/battle-layout.js @@ -0,0 +1,45 @@ +export function buildBattleLayout(battle) { + const rounds = battle?.rounds || []; + const allMatches = rounds.flatMap((round) => round.matches || []); + const matchMap = new Map(allMatches.map((match) => [match.id, match])); + const sources = new Map(); + + rounds.forEach((round, index) => { + const previousMatches = rounds[index - 1]?.matches || []; + for (const match of round.matches || []) { + const from = (side) => { + const explicit = match[`${side}From`]; + if (explicit) return explicit; + const player = match[`${side}Player`]; + if (!player) return ''; + const candidates = previousMatches.filter((candidate) => candidate.winner && String(candidate.winner) === String(player)); + return candidates.length === 1 ? candidates[0].id : ''; + }; + sources.set(match.id, { aFrom: from('a'), bFrom: from('b') }); + } + }); + + const finalMatch = rounds.at(-1)?.matches?.[0]; + const descendants = (rootId) => { + const found = new Set(); + const visit = (id) => { + if (!id || found.has(id)) return; + found.add(id); + const link = sources.get(id); + visit(link?.aFrom); + visit(link?.bFrom); + }; + visit(rootId); + return found; + }; + const finalSources = sources.get(finalMatch?.id); + + return { + allMatches, + matchMap, + sources, + finalMatch, + lastRoundIndex: rounds.length - 1, + sides: { left: descendants(finalSources?.aFrom), right: descendants(finalSources?.bFrom) }, + }; +} diff --git a/src/data/competition-api.js b/src/data/competition-api.js new file mode 100644 index 0000000..8776fc1 --- /dev/null +++ b/src/data/competition-api.js @@ -0,0 +1,25 @@ +import { unwrapCompetitionState } from './competition-response.js'; + +const configuredBase = (import.meta.env.VITE_COMPETITION_API_URL || '').trim().replace(/\/$/, ''); +const base = configuredBase || (import.meta.env.PROD + ? 'https://jitsi-web.yuedongsports.com/api/competition' + : '/api/competition'); + +export const competitionApiBase = base; + +export async function loadCompetitionState() { + try { + const response = await fetch(`${base}/state`, { cache: 'no-store' }); + if (!response.ok) throw new Error(`接口返回 ${response.status}`); + return { state: unwrapCompetitionState(await response.json()), mode: 'service' }; + } catch (error) { + const fallback = await fetch(`${import.meta.env.BASE_URL}competition-state.json`, { cache: 'no-store' }); + if (!fallback.ok) throw error; + return { state: unwrapCompetitionState(await fallback.json()), mode: 'static' }; + } +} + +export function subscribeCompetitionUpdates(onUpdate) { + const timer = window.setInterval(onUpdate, 10000); + return () => window.clearInterval(timer); +} diff --git a/src/data/competition-response.js b/src/data/competition-response.js new file mode 100644 index 0000000..5fc0ce9 --- /dev/null +++ b/src/data/competition-response.js @@ -0,0 +1,19 @@ +export function unwrapCompetitionState(payload) { + let state = payload; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof state === 'string') state = JSON.parse(state); + if (!state || typeof state !== 'object' || !Object.hasOwn(state, 'statusCode')) break; + if (state.statusCode !== 200) { + throw new Error(state.error || state.message || state.errors || `接口返回 ${state.statusCode}`); + } + state = state.data; + } + if (!state || typeof state !== 'object' || typeof state.published !== 'boolean' + || !state.competition || !state.notice || !Array.isArray(state.schedule?.items) + || !Array.isArray(state.results?.overall) || !Array.isArray(state.results?.challenge) + || !Array.isArray(state.results?.battle) || !Array.isArray(state.players) + || !Array.isArray(state.records)) { + throw new Error('接口返回的赛事数据结构不正确'); + } + return state; +} diff --git a/src/data/page-content.js b/src/data/page-content.js index b67d470..08c773c 100644 --- a/src/data/page-content.js +++ b/src/data/page-content.js @@ -207,7 +207,8 @@ pages.sports = { cards: [ overviewCard('赛事招募', '汇集跃动旗下赛事报名、参赛通知与活动信息。', '/sports/competition#event-recruitment'), overviewCard('赛事新闻', '关注专业赛事、城市活动与青少年运动员的最新动态。', '/sports/competition#event-news'), - overviewCard('演艺活动', '冠军团队、花样跳绳与大型活动演出服务。', '/sports/competition#performances') + overviewCard('演艺活动', '冠军团队、花样跳绳与大型活动演出服务。', '/sports/competition#performances'), + overviewCard('赛事信息', '查询赛事信息、选手成绩与赛会纪录。', '/sports/results') ] }, 'event-recruitment': { diff --git a/src/router.js b/src/router.js index 6bf5224..2500633 100644 --- a/src/router.js +++ b/src/router.js @@ -4,6 +4,7 @@ import ContentPage from './views/ContentPage.vue'; import ContentDetailPage from './views/ContentDetailPage.vue'; import ProductDetailPage from './views/ProductDetailPage.vue'; import ContactPage from './views/ContactPage.vue'; +import CompetitionResultsPage from './views/CompetitionResultsPage.vue'; const routes = [ { path: '/', name: 'home', component: HomePage }, @@ -13,6 +14,7 @@ const routes = [ { path: '/sports/event-recruitment', redirect: '/sports/competition#event-recruitment' }, { path: '/sports/event-news', redirect: '/sports/competition#event-news' }, { path: '/sports/performances', redirect: '/sports/competition#performances' }, + { path: '/sports/results', name: 'competition-results', component: CompetitionResultsPage, meta: { page: 'sports-results' } }, { path: '/sports/venue-intro', redirect: '/sports/venues#venue-intro' }, { path: '/sports/champion-class', redirect: '/sports/venues#champion-class' }, { path: '/sports/camps', redirect: '/sports/venues#camps' }, diff --git a/src/style.css b/src/style.css index dcdec6d..5661e74 100644 --- a/src/style.css +++ b/src/style.css @@ -63,12 +63,6 @@ main { min-height: 72vh; } .dropdown a::before { content: ""; position: absolute; left: 10px; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: var(--blue-300); transform: translateY(-50%); transition: .22s; } .dropdown a:hover, .dropdown a.router-link-active { color: var(--blue-700); background: var(--blue-50); transform: translateX(3px); } .dropdown a:hover::before { background: var(--blue-600); box-shadow: 0 0 0 4px rgba(0,144,255,.12); } -.dropdown-mega { width: min(620px,calc(100vw - 48px)); padding: 18px; } -.dropdown-groups { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; } -.dropdown-group { min-width: 0; padding: 6px; border-radius: 11px; background: #f8fbff; } -.dropdown .dropdown-group-title { margin: 0; padding: 13px 14px; color: var(--blue-800); background: #eaf6ff; font-size: 14px; font-weight: 800; } -.dropdown .dropdown-group-title::before { display: none; } -.dropdown .dropdown-group-title:hover { transform: translateY(-2px); } .nav-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } .language-switch { height: 38px; display: inline-grid; grid-template-columns: repeat(2,34px); align-items: center; padding: 3px; border: 1px solid rgba(0,144,255,.2); border-radius: 20px; background: var(--blue-50); box-shadow: inset 0 0 0 1px rgba(255,255,255,.7); } .language-switch span,.language-switch a { height: 30px; display: grid; place-items: center; border-radius: 16px; font-size: 11px; font-weight: 800; letter-spacing: .4px; transition: color .25s ease, background .25s ease, transform .25s ease, box-shadow .25s ease; } @@ -92,16 +86,16 @@ main { min-height: 72vh; } .home-hero-slide { position: absolute; inset: 0; opacity: 0; background-position: center; background-size: cover; transform: translateX(20%) scale(1.055); transition: opacity 1.1s ease, transform 7s ease; } .home-hero-slide.active { opacity: 1; transform: translateX(25%) scale(1.105); } .home-hero-shade { position: absolute; inset: 0; z-index: -3; width: 100%; background: linear-gradient(90deg,transparent 45%,rgba(0,43,82,.04) 70%,rgba(0,20,42,.18) 100%); } -.home-hero-solid { position: absolute; inset: 0; z-index: -2; width: 80%; background: linear-gradient(90deg,#006fc9 0%,#0086ec 30%,#0090ff 45%,rgba(0,144,255,.98) 50%,rgba(0,144,255,.86) 55%,rgba(0,144,255,.56) 61%,rgba(0,144,255,.22) 68%,rgba(0,144,255,0) 76%); } +.home-hero-solid { position: absolute; inset: 0; z-index: -2; width: 82%; background: linear-gradient(90deg,#006fc9 0%,#0085e8 30%,rgba(0,144,255,.96) 48%,rgba(0,144,255,.76) 58%,rgba(0,126,224,.42) 68%,rgba(0,105,188,0) 79%); } .home-hero-grid { display: none; } .home-hero-ring { display: none; } .home-hero-ring::after { content: ""; position: absolute; left: 12%; top: 12%; width: 9px; height: 9px; border-radius: 50%; background: #fff; box-shadow: 0 0 22px var(--blue-300); } .ring-one { width: 570px; height: 570px; right: -160px; top: 120px; }.ring-two { width: 280px; height: 280px; right: 115px; top: 260px; animation-direction: reverse; animation-duration: 12s; } .home-hero-content { min-height: 720px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; } -.home-eyebrow { display: flex; align-items: center; gap: 14px; color: var(--blue-300); font-size: 11px; font-weight: 800; letter-spacing: 4px; } -.home-eyebrow::before { content: ""; width: 42px; height: 2px; background: var(--blue-600); } +.home-eyebrow { display: flex; align-items: center; gap: 14px; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 800; letter-spacing: 4px; } +.home-eyebrow::before { content: ""; width: 42px; height: 2px; background: rgba(255,255,255,.9); } .home-hero h1 { margin: 22px 0 24px; max-width: 650px; font-size: clamp(66px,7.3vw,108px); line-height: .96; letter-spacing: -6px; text-shadow: 0 18px 45px rgba(0,69,132,.2); } -.home-hero h1 em { color: var(--blue-600); font-style: normal; } +.home-hero h1 em { color: #fff; font-style: normal; } .home-hero-content > p { max-width: 720px; margin: 0; color: rgba(255,255,255,.92); font-size: clamp(19px,1.45vw,23px); font-weight: 500; line-height: 1.75; text-align: center; text-wrap: balance; text-shadow: 0 5px 20px rgba(0,61,118,.16); } .home-hero-actions { display: flex; align-items: center; gap: 16px; margin-top: 36px; } .home-primary-button,.home-ghost-button { display: inline-flex; align-items: center; justify-content: center; min-height: 52px; padding: 0 24px; border-radius: 11px; font-size: 13px; font-weight: 700; transition: .3s; } @@ -138,7 +132,15 @@ main { min-height: 72vh; } .home-product-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; }.home-product-grid > a { padding: 15px 15px 26px; border: 1px solid rgba(255,255,255,.12); border-radius: 19px 4px 19px 4px; background: rgba(255,255,255,.06); transition: .4s; }.home-product-grid > a:hover { transform: translateY(-9px); border-color: rgba(130,202,255,.5); background: rgba(0,144,255,.14); box-shadow: 0 25px 55px rgba(0,0,0,.2); }.home-product-image { position: relative; height: 300px; overflow: hidden; border-radius: 13px 3px 13px 3px; background: #fff; }.home-product-image img { width: 100%; height: 100%; object-fit: contain; transition: transform .55s; }.home-product-grid > a:hover img { transform: scale(1.06); }.home-product-image span { position: absolute; top: 12px; right: 12px; width: 34px; height: 34px; display: grid; place-items: center; color: #fff; border-radius: 50%; background: var(--blue-600); font-size: 9px; }.home-product-grid small { display: block; margin-top: 19px; color: var(--blue-300); font-size: 10px; font-weight: 800; }.home-product-grid h3 { min-height: 52px; margin: 8px 0 15px; font-size: 18px; line-height: 1.45; }.home-product-grid b { color: rgba(235,247,255,.55); font-size: 11px; } .home-innovation { display: grid; grid-template-columns: repeat(2,1fr); gap: 22px; padding-top: 115px; padding-bottom: 115px; }.innovation-card { position: relative; min-height: 420px; overflow: hidden; padding: 48px; color: #fff; border-radius: 26px 5px 26px 5px; box-shadow: var(--shadow); transition: transform .4s; }.innovation-card:hover { transform: translateY(-8px); }.technology-card { background: linear-gradient(145deg,#0076d4,#0090ff 70%); }.health-card { background: linear-gradient(145deg,#0090ff,#42b1ff); }.innovation-card::before { content: ""; position: absolute; right: -80px; bottom: -100px; width: 310px; height: 310px; border: 1px solid rgba(255,255,255,.2); border-radius: 50%; }.innovation-card > div { position: relative; z-index: 1; max-width: 480px; }.innovation-card small { color: var(--blue-100); font-size: 9px; font-weight: 800; letter-spacing: 3px; }.innovation-card h2 { margin: 12px 0 18px; font-size: 44px; }.innovation-card p { color: rgba(255,255,255,.8); line-height: 1.9; }.innovation-card b { display: block; margin-top: 35px; font-size: 12px; }.innovation-card > span { position: absolute; right: -8px; top: 50px; color: rgba(255,255,255,.1); font-size: 76px; font-weight: 900; letter-spacing: -4px; } .home-news { padding: 110px 0 125px; background: var(--blue-50); }.home-news-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 24px; }.home-news-grid > a { overflow: hidden; border: 1px solid var(--line); border-radius: 20px 4px 20px 4px; background: #fff; box-shadow: 0 13px 34px rgba(0,72,135,.07); transition: .4s; }.home-news-grid > a:hover { transform: translateY(-8px); box-shadow: 0 25px 58px rgba(0,72,135,.15); }.home-news-image { position: relative; height: 260px; overflow: hidden; }.home-news-image img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s; }.home-news-grid > a:hover img { transform: scale(1.06); }.home-news-image span { position: absolute; left: 16px; top: 16px; padding: 8px 11px; color: #fff; border-radius: 7px; background: var(--blue-600); font-size: 9px; font-weight: 800; letter-spacing: 1px; }.home-news-grid > a > div:last-child { padding: 25px 26px 28px; }.home-news-grid small { color: var(--blue-600); font-size: 10px; font-weight: 800; }.home-news-grid h3 { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 59px; margin: 10px 0 13px; color: var(--ink); font-size: 20px; line-height: 1.48; }.home-news-grid p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 3; -webkit-box-orient: vertical; min-height: 69px; color: var(--muted); font-size: 13px; line-height: 1.75; }.home-news-grid b { color: var(--blue-700); font-size: 11px; } -.home-cta { position: relative; overflow: hidden; padding: 105px 0; color: #fff; text-align: center; background: var(--blue-600); }.home-cta::before { content: ""; position: absolute; inset: 0; opacity: .15; background-image: linear-gradient(rgba(255,255,255,.25) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.25) 1px,transparent 1px); background-size: 58px 58px; }.home-cta-orbit { position: absolute; width: 520px; height: 520px; right: -220px; top: -260px; border: 1px solid rgba(255,255,255,.25); border-radius: 50%; }.home-cta .container { position: relative; z-index: 1; }.home-cta small { font-size: 9px; font-weight: 800; letter-spacing: 4px; }.home-cta h2 { max-width: 900px; margin: 16px auto 18px; font-size: clamp(38px,5vw,62px); line-height: 1.2; letter-spacing: -3px; }.home-cta p { color: rgba(255,255,255,.78); }.home-cta a { display: inline-flex; align-items: center; gap: 28px; margin-top: 25px; padding: 15px 22px; color: var(--blue-800); border-radius: 11px; background: #fff; box-shadow: 0 16px 35px rgba(0,44,85,.2); font-size: 13px; font-weight: 800; transition: transform .3s; }.home-cta a:hover { transform: translateY(-4px); } +.home-cta { position: relative; overflow: hidden; padding: 112px 0 118px; color: var(--ink); text-align: center; border-top: 1px solid rgba(0,144,255,.1); background: linear-gradient(145deg,#f8fcff 0%,#eaf6ff 52%,#dcefff 100%); } +.home-cta::before { content: ""; position: absolute; inset: 0; background-image: radial-gradient(circle at 16% 25%,rgba(0,144,255,.12),transparent 28%),linear-gradient(rgba(0,144,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(0,144,255,.055) 1px,transparent 1px); background-size: auto,58px 58px,58px 58px; } +.home-cta-orbit { position: absolute; width: 520px; height: 520px; right: -220px; top: -260px; border: 1px solid rgba(0,144,255,.22); border-radius: 50%; box-shadow: 0 0 0 72px rgba(0,144,255,.035),0 0 0 144px rgba(0,144,255,.025); } +.home-cta .container { position: relative; z-index: 1; } +.home-cta small { color: var(--blue-600); font-size: 9px; font-weight: 800; letter-spacing: 4px; } +.home-cta h2 { max-width: 900px; margin: 16px auto 18px; color: var(--ink); font-size: clamp(38px,5vw,62px); line-height: 1.2; letter-spacing: -3px; } +.home-cta p { color: #587087; } +.home-cta a { display: inline-flex; align-items: center; gap: 28px; margin-top: 25px; padding: 15px 24px; color: #fff; border: 1px solid rgba(255,255,255,.3); border-radius: 11px; background: linear-gradient(135deg,#0077d5,#0090ff); box-shadow: 0 16px 35px rgba(0,112,210,.24); font-size: 13px; font-weight: 800; transition: transform .3s,box-shadow .3s; } +.home-cta a:hover { transform: translateY(-4px); box-shadow: 0 22px 42px rgba(0,112,210,.32); } /* Original homepage, recolored without changing its content */ .home-original { padding-top: 82px; background: var(--blue-50); } @@ -715,6 +717,8 @@ main { min-height: 72vh; } .footer-inner a:hover { color: #fff; transform: translateX(15px); } .footer-inner a:hover::before { width: 9px; } .copyright { position: relative; z-index: 1; width: min(1360px,calc(100% - 56px)); margin: 60px auto 0; padding-top: 22px; display: flex; justify-content: space-between; border-top: 1px solid rgba(216,238,255,.12); color: rgba(216,238,255,.38); font-size: 10px; letter-spacing: 1px; opacity: 0; transform: translateY(18px); transition: opacity .7s ease .34s, transform .8s cubic-bezier(.2,.8,.2,1) .34s; } +.copyright a { color: rgba(216,238,255,.55); transition: color .25s ease; } +.copyright a:hover { color: #fff; } .site-footer.footer-visible::before { animation: footerGridDrift 10s linear infinite alternate; } .footer-visible .footer-inner > div,.footer-visible .copyright { opacity: 1; transform: translateY(0); } .footer-visible .footer-orbit { opacity: 1; transform: scale(1); } @@ -758,10 +762,6 @@ main { min-height: 72vh; } .dropdown::before, .dropdown-label { display: none; } .dropdown a { display: inline-block; margin-right: 18px; padding: 9px 0; font-size: 14px; } .dropdown a::before { display: none; } - .dropdown-mega { width: 100%; padding-left: 18px; } - .dropdown .dropdown-group-title { display: block; margin: 0; padding: 10px 0; background: transparent; } - .dropdown-groups { display: block; } - .dropdown-group { margin: 5px 0 10px; padding: 8px 10px; } .home-original { padding-top: 70px; } .page-hero { height: 400px; padding-top: 70px; } .scroll-cue { display: none; } @@ -844,7 +844,7 @@ main { min-height: 72vh; } .footer-brand { grid-column: 1 / -1; } .footer-inner > div:last-child { grid-column: auto; } .copyright { width: calc(100% - 36px); display: block; line-height: 2; } - .copyright span { display: block; } + .copyright > * { display: block; } .home-hero { min-height: 760px; } .home-hero-shade,.home-hero-grid { display: none; } .home-hero-shade { background: linear-gradient(90deg,rgba(0,99,183,.92),rgba(0,144,255,.7)),linear-gradient(0deg,rgba(0,111,201,.68),transparent 60%); } @@ -905,3 +905,134 @@ main { min-height: 72vh; } *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } .reveal { opacity: 1; transform: none; } } +.sports-results-entry > a { display: inline-flex; align-items: center; gap: 1rem; margin-top: 1.2rem; padding: .9rem 1.5rem; background: #dfff32; color: #192118; font-weight: 800; text-decoration: none; } +.sports-results-nav { display: flex; gap: .8rem; align-items: center; text-decoration: none; } +.competition-page { min-height: 100vh; background: #101512; color: #f3f5ec; } +.competition-page button, .competition-page select, .competition-page input { font: inherit; } +.competition-page button { cursor: pointer; } +.competition-page :focus-visible { outline: 3px solid #dfff32; outline-offset: 3px; } +.competition-hero { position: relative; overflow: hidden; min-height: 370px; background: radial-gradient(circle at 77% 15%, #536b2c 0, transparent 35%), linear-gradient(125deg, #151e17, #11140f 70%); border-bottom: 1px solid #354339; } +.competition-hero::after { content: '26'; position: absolute; right: 5%; bottom: -7rem; font: 900 clamp(15rem, 32vw, 36rem)/1 Arial, sans-serif; letter-spacing: -.12em; color: rgba(216, 255, 119, .055); pointer-events: none; } +.competition-hero-inner { position: relative; z-index: 1; padding-top: 4rem; padding-bottom: 3rem; } +.competition-back { color: #d5ecb9; text-decoration: none; font-size: .9rem; } +.competition-kicker { margin-top: 3rem; color: #dfff32; font-size: .76rem; letter-spacing: .28em; font-weight: 800; } +.competition-hero h1 { margin: .5rem 0 .2rem; font-size: clamp(3rem, 8vw, 6rem); line-height: 1.1; letter-spacing: -.06em; } +.competition-hero p { margin: 0; color: #b8c4b4; font-size: 1.1rem; } +.competition-hero-actions { display: flex; gap: 1rem; justify-content: space-between; align-items: center; margin-top: 2.3rem; } +.competition-status { border: 1px solid #657261; border-radius: 999px; padding: .45rem .8rem; color: #c4ccbf; font-size: .74rem; letter-spacing: .12em; } +.competition-status.live { color: #dcff77; border-color: #a5c337; } +.competition-language { display: flex; padding: .2rem; border: 1px solid #61765a; border-radius: 999px; } +.competition-language button { border: 0; background: transparent; color: #e7f0dd; padding: .3rem .8rem; border-radius: 999px; } +.competition-language button[aria-pressed='true'] { background: #dfff32; color: #172016; font-weight: 800; } +.competition-body { padding-top: 2rem; padding-bottom: 6rem; } +.competition-message { padding: .8rem 1rem; border: 1px solid #556551; background: #1e291e; color: #d5e2ce; } +.competition-message.error { border-color: #b46c55; color: #ffd7cd; } +.competition-message button { margin-left: .8rem; color: inherit; border: 0; background: transparent; text-decoration: underline; } +.competition-notice { display: flex; align-items: center; gap: 1.4rem; max-width: 100%; padding: 1rem 1.2rem; background: #dfff32; color: #1c2519; } +.competition-notice > strong { flex: none; font-size: .75rem; font-weight: 900; letter-spacing: .16em; } +.competition-notice-viewport { flex: 1; min-width: 0; overflow: hidden; } +.competition-notice-track { display: flex; width: max-content; } +.competition-notice-track p { flex: none; margin: 0; white-space: nowrap; } +.competition-notice-track p span { display: inline-block; } +.competition-notice-track.scrolling p { padding-right: 4rem; } +.competition-notice-track.scrolling { animation: competition-notice-scroll var(--notice-duration) linear infinite; } +@keyframes competition-notice-scroll { to { transform: translateX(-50%); } } +.competition-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #3c4a3f; margin: 2rem 0 3rem; } +.competition-tabs button, .competition-subtabs button { flex: none; border: 0; background: transparent; color: #aab8a9; padding: .9rem 1.4rem; } +.competition-tabs button.active { color: #eaffad; border-bottom: 3px solid #dfff32; font-weight: 800; } +.competition-section-head { margin-bottom: 2rem; } +.competition-section-head small { color: #dfff32; font-weight: 800; letter-spacing: .2em; } +.competition-section-head h2 { margin: .45rem 0; font-size: clamp(2rem, 5vw, 3.6rem); } +.competition-meta-grid, .competition-live-grid, .competition-result-grid, .competition-record-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } +.competition-meta-grid > div, .competition-live-grid > article, .competition-result-card, .competition-record-grid > article { border: 1px solid #3a493d; background: #1a221c; } +.competition-meta-grid > div { min-height: 100px; padding: 1.3rem; display: flex; flex-direction: column; gap: .6rem; } +.competition-meta-grid small, .competition-live-grid small, .competition-result-card small { color: #a9bb9f; font-size: .73rem; letter-spacing: .15em; } +.competition-meta-grid strong { font-size: 1.12rem; } +.competition-live-grid { margin-top: 1.3rem; } +.competition-live-grid article { min-height: 190px; padding: 1.4rem; } +.competition-live-grid h3 { font-size: 1.5rem; margin: 1.2rem 0 .4rem; } +.competition-live-grid p { color: #aab8a9; } +.competition-pulse { display: inline-block; width: 8px; height: 8px; margin-right: .5rem; border-radius: 50%; background: #dfff32; box-shadow: 0 0 0 5px #dfff3229; } +.competition-upcoming { display: flex; gap: 1rem; border-top: 1px solid #354638; padding: .6rem 0; } +.competition-upcoming:first-of-type { margin-top: 1rem; } +.competition-upcoming b { color: #dfff32; font-variant-numeric: tabular-nums; } +.competition-schedule { margin-top: 3rem; } +.competition-schedule h3 { font-size: 1.6rem; } +.competition-schedule > div { display: grid; grid-template-columns: 6rem 1fr 1fr; gap: 1rem; align-items: center; border-top: 1px solid #3a493d; padding: 1rem; } +.competition-schedule > div.current { background: #263321; } +.competition-schedule time { color: #dfff32; font-weight: 800; font-variant-numeric: tabular-nums; } +.competition-schedule span { color: #aab8a9; } +.competition-subtabs { display: flex; gap: .3rem; margin-bottom: 1.6rem; } +.competition-subtabs button { border: 1px solid #53634f; padding: .55rem 1rem; } +.competition-subtabs button.active { background: #dfff32; border-color: #dfff32; color: #1c2719; font-weight: 800; } +.competition-filter { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; } +.competition-filter select, .competition-search input { border: 1px solid #68766a; background: #19231b; color: #fff; padding: .7rem 1rem; } +.competition-result-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.competition-result-card { padding: 1.4rem; } +.competition-result-card header { border-bottom: 1px solid #3a493d; margin-bottom: .5rem; padding-bottom: .8rem; } +.competition-result-card h3 { font-size: 1.4rem; margin: .35rem 0 0; } +.competition-rank-row { display: flex; align-items: center; gap: .8rem; border-bottom: 1px solid #354139; padding: .65rem .15rem; } +.competition-rank-row > b { width: 2.2rem; color: #dfff32; font-size: 1.15rem; font-variant-numeric: tabular-nums; } +.competition-rank-row > span { flex: 1; overflow-wrap: anywhere; } +.competition-rank-row > strong { font-variant-numeric: tabular-nums; } +.competition-result-card > button { border: 0; background: transparent; color: #dfff32; padding: 1rem 0 .1rem; font-weight: 800; } +.competition-empty { padding: 3rem 1rem; color: #bcc9b8; text-align: center; border: 1px dashed #526352; } +.competition-search { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.4rem; } +.competition-search input { max-width: 430px; } +.competition-player-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } +.competition-player-card { display: flex; align-items: center; gap: 1rem; text-align: left; border: 1px solid #3a493d; background: #1a221c; color: #f3f5ec; padding: .7rem; } +.competition-player-card img { width: 70px; height: 84px; object-fit: cover; } +.competition-player-card span { flex: 1; min-width: 0; } +.competition-player-card small { display: block; color: #aab8a9; } +.competition-player-card strong { display: block; margin-top: .3rem; } +.competition-player-card b { color: #dfff32; } +.competition-record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.competition-record-grid article { display: flex; min-height: 200px; } +.competition-record-grid img { width: 38%; object-fit: cover; } +.competition-record-grid article > div { padding: 1.3rem; } +.competition-record-grid small { color: #aab8a9; } +.competition-record-grid strong { display: block; margin: .5rem 0; color: #dfff32; font: 800 clamp(2rem, 5vw, 4rem)/1 Arial, sans-serif; } +.competition-record-grid h3 { margin: 0; } +.battle-event { margin-bottom: 3rem; overflow-x: auto; padding-bottom: .5rem; } +.battle-event > h3 { margin: 0 0 1.2rem; font-size: clamp(1.35rem, 2vw, 1.8rem); } +.battle-board { position: relative; } +.battle-board.landscape { display: grid; gap: 1.5rem; height: clamp(510px, 43vw, 700px); } +.battle-column { display: flex; flex-direction: column; min-width: 0; } +.battle-column h4, .battle-stage h4 { position: relative; z-index: 1; margin: 0 0 .8rem; padding: .6rem .4rem; border: 1px solid #53624d; border-radius: 999px; background: #253124; color: #e8f5d1; font-size: .76rem; font-weight: 800; text-align: center; letter-spacing: .06em; } +.battle-column.is-final h4 { border-color: #dfff32; background: #34451f; color: #dfff32; } +.battle-column-matches { flex: 1; display: flex; flex-direction: column; justify-content: space-around; gap: .55rem; } +.battle-match { position: relative; z-index: 1; min-width: 0; overflow: hidden; border: 1px solid #607054; border-radius: 10px; background: #1d281e; box-shadow: 0 8px 24px #0005; } +.battle-match > div { display: flex; align-items: center; gap: .45rem; min-height: 3rem; padding: .35rem .5rem; border-bottom: 1px solid #3c4d3e; color: #d6e2d2; } +.battle-match > div:last-child { border-bottom: 0; } +.battle-match > div.winner { background: linear-gradient(90deg, #435f27, #304522); color: #f1ffd1; box-shadow: inset 3px 0 #dfff32; } +.battle-match img { flex: none; width: 30px; height: 30px; border: 1px solid #769270; border-radius: 50%; object-fit: cover; } +.battle-match span { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; font-size: clamp(.68rem, .85vw, .88rem); line-height: 1.25; overflow-wrap: anywhere; } +.battle-match b { flex: none; display: grid; place-items: center; min-width: 1.8rem; min-height: 1.8rem; padding: 0 .25rem; border-radius: 5px; background: #344134; color: #f5f8f0; font-size: .83rem; font-variant-numeric: tabular-nums; } +.battle-match .winner b { background: #dfff32; color: #243112; } +.battle-column.is-final .battle-match { border-color: #dfff32; box-shadow: 0 0 0 1px #dfff3240, 0 12px 30px #0007; } +.battle-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } +.battle-lines path { fill: none; stroke: #70836c; stroke-width: 1.5; stroke-linecap: round; opacity: .65; } +.battle-lines circle { fill: #70836c; opacity: .8; } +.battle-lines .advanced path { stroke: #b7d96a; stroke-width: 2; opacity: .9; } +.battle-lines .advanced circle { fill: #c5ed70; opacity: 1; } +.battle-board.portrait { display: flex; flex-direction: column; gap: 3.25rem; } +.battle-board.portrait > .battle-stage::after, .battle-board.portrait > .battle-champion::after { content: ''; position: absolute; left: 50%; bottom: -2.55rem; width: 2px; height: 2rem; transform: translateX(-50%); border-radius: 2px; background: linear-gradient(#6f8d5c, #a8c86a); opacity: .85; } +.battle-board.portrait > .battle-stage::before, .battle-board.portrait > .battle-champion::before { content: ''; position: absolute; left: 50%; bottom: -1.75rem; z-index: 1; width: 7px; height: 7px; transform: translateX(-50%) rotate(45deg); background: #b7d96a; } +.battle-board.portrait > :last-child::before, .battle-board.portrait > :last-child::after { display: none; } +.battle-stage { position: relative; z-index: 1; } +.battle-stage h4 { width: fit-content; min-width: 9rem; margin: 0 auto .8rem; } +.battle-stage-matches { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); gap: .7rem; } +.battle-stage .battle-match span { font-size: .83rem; } +.battle-champion { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: .6rem; width: min(100%, 340px); margin: 0 auto; padding: 1.4rem; border: 2px solid #dfff32; border-radius: 16px; background: radial-gradient(circle at 50% 0, #4a6028, #243222 75%); box-shadow: 0 0 28px #dfff3226, 0 16px 36px #0007; text-align: center; } +.battle-champion small { color: #dfff32; font-weight: 800; letter-spacing: .16em; } +.battle-champion > img { width: 68px; height: 68px; border: 2px solid #dfff32; border-radius: 50%; object-fit: cover; } +.battle-champion strong { color: #f5ffd9; font-size: 1.3rem; } +.battle-final-match { width: 100%; text-align: left; } +.competition-modal-wrap { position: fixed; z-index: 1000; inset: 0; overflow-y: auto; background: #000b; padding: 3rem 1rem; } +.competition-modal { position: relative; width: min(100%, 740px); max-height: calc(100vh - 6rem); overflow-y: auto; margin: auto; padding: 2rem; background: #1c251d; border: 1px solid #7b9369; box-shadow: 0 20px 80px #0008; } +.competition-modal-close { position: absolute; right: 1rem; top: .7rem; border: 0; background: transparent; color: #fff; font-size: 2rem; } +.competition-modal > small { color: #dfff32; letter-spacing: .16em; } +.competition-modal h2 { margin: .5rem 2rem 1.5rem 0; } +.competition-profile-photo { width: 110px; height: 135px; object-fit: cover; display: block; margin-bottom: 1rem; } +@media (max-width: 760px) { .competition-meta-grid, .competition-live-grid, .competition-result-grid, .competition-player-grid, .competition-record-grid { grid-template-columns: 1fr; } .competition-schedule > div { grid-template-columns: 4rem 1fr; } .competition-schedule span { grid-column: 2; } .competition-hero-inner { padding-top: 2rem; } .competition-kicker { margin-top: 2rem; } } +@media (prefers-reduced-motion: reduce) { .competition-page *, .competition-page *::before, .competition-page *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } .competition-notice-viewport { overflow-x: auto; } } diff --git a/src/views/CompetitionResultsPage.vue b/src/views/CompetitionResultsPage.vue new file mode 100644 index 0000000..070b7a3 --- /dev/null +++ b/src/views/CompetitionResultsPage.vue @@ -0,0 +1,146 @@ + + + diff --git a/vite.config.mjs b/vite.config.mjs index 481720d..7ae41e0 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -3,5 +3,8 @@ import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], - server: { host: '0.0.0.0' } + server: { + host: '0.0.0.0', + proxy: { '/api/competition': { target: process.env.COMPETITION_API_TARGET || 'http://localhost:8787', changeOrigin: true } } + } });