10 Commits

Author SHA1 Message Date
c4a94da9da Merge pull request #116 from fabrice404/dependabot/npm_and_yarn/ui/dependencies-patch-and-minor-ca6bd95cb1
npm(deps-dev): bump eslint from 9.39.1 to 9.39.2 in /ui in the dependencies-patch-and-minor group
2025-12-13 01:00:04 +01:00
1df247b875 Merge pull request #115 from fabrice404/dependabot/npm_and_yarn/scraper/dependencies-patch-and-minor-ab633d47b1
npm(deps-dev): bump the dependencies-patch-and-minor group in /scraper with 2 updates
2025-12-13 00:38:22 +01:00
1b6ebea812 npm(deps-dev): bump eslint
Bumps the dependencies-patch-and-minor group in /ui with 1 update: [eslint](https://github.com/eslint/eslint).


Updates `eslint` from 9.39.1 to 9.39.2
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 23:38:20 +00:00
ce965e7962 npm(deps-dev): bump the dependencies-patch-and-minor group
Bumps the dependencies-patch-and-minor group in /scraper with 2 updates: [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) and [eslint](https://github.com/eslint/eslint).


Updates `@eslint/js` from 9.39.1 to 9.39.2
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.2/packages/js)

Updates `eslint` from 9.39.1 to 9.39.2
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: eslint
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 23:38:15 +00:00
aa91cbc05b Merge pull request #114 from fabrice404/dependabot/npm_and_yarn/ui/dependencies-patch-and-minor-9bfe028876
npm(deps): bump the dependencies-patch-and-minor group in /ui with 8 updates
2025-12-12 00:30:47 +01:00
57c7d127fc npm(deps): bump the dependencies-patch-and-minor group
Bumps the dependencies-patch-and-minor group in /ui with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@next/third-parties](https://github.com/vercel/next.js/tree/HEAD/packages/third-parties) | `16.0.8` | `16.0.10` |
| [next](https://github.com/vercel/next.js) | `16.0.8` | `16.0.10` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.1` | `19.2.3` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.1` | `19.2.3` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.17` | `4.1.18` |
| [daisyui](https://github.com/saadeghi/daisyui/tree/HEAD/packages/daisyui) | `5.5.11` | `5.5.13` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.0.8` | `16.0.10` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.17` | `4.1.18` |


Updates `@next/third-parties` from 16.0.8 to 16.0.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.10/packages/third-parties)

Updates `next` from 16.0.8 to 16.0.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.8...v16.0.10)

Updates `react` from 19.2.1 to 19.2.3
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react)

Updates `react-dom` from 19.2.1 to 19.2.3
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react-dom)

Updates `@tailwindcss/postcss` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/@tailwindcss-postcss)

Updates `daisyui` from 5.5.11 to 5.5.13
- [Release notes](https://github.com/saadeghi/daisyui/releases)
- [Changelog](https://github.com/saadeghi/daisyui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/saadeghi/daisyui/commits/v5.5.13/packages/daisyui)

Updates `eslint-config-next` from 16.0.8 to 16.0.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.10/packages/eslint-config-next)

Updates `tailwindcss` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@next/third-parties"
  dependency-version: 16.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: next
  dependency-version: 16.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: react
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: react-dom
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: daisyui
  dependency-version: 5.5.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: eslint-config-next
  dependency-version: 16.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: tailwindcss
  dependency-version: 4.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-11 23:30:39 +00:00
a5d5c1eee8 Merge pull request #113 from fabrice404/dependabot/npm_and_yarn/ui/dependencies-patch-and-minor-d6087f71ff
npm(deps-dev): bump daisyui from 5.5.8 to 5.5.11 in /ui in the dependencies-patch-and-minor group
2025-12-11 01:00:04 +01:00
b9a508da19 Merge pull request #112 from fabrice404/dependabot/npm_and_yarn/scraper/dependencies-patch-and-minor-e68fff4225
npm(deps-dev): bump @types/node from 24.10.2 to 24.10.3 in /scraper in the dependencies-patch-and-minor group
2025-12-11 00:20:51 +01:00
b1651fe7e9 npm(deps-dev): bump daisyui
Bumps the dependencies-patch-and-minor group in /ui with 1 update: [daisyui](https://github.com/saadeghi/daisyui/tree/HEAD/packages/daisyui).


Updates `daisyui` from 5.5.8 to 5.5.11
- [Release notes](https://github.com/saadeghi/daisyui/releases)
- [Changelog](https://github.com/saadeghi/daisyui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/saadeghi/daisyui/commits/v5.5.11/packages/daisyui)

---
updated-dependencies:
- dependency-name: daisyui
  dependency-version: 5.5.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-10 23:20:45 +00:00
7380235ffb npm(deps-dev): bump @types/node
Bumps the dependencies-patch-and-minor group in /scraper with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 24.10.2 to 24.10.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.10.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-10 23:20:43 +00:00
45 changed files with 10995 additions and 24 deletions

44
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,44 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/scraper"
schedule:
interval: daily
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
commit-message:
prefix: "npm"
include: "scope"
groups:
dependencies-patch-and-minor:
update-types:
- minor
- patch
open-pull-requests-limit: 50
reviewers:
- fabrice404
assignees:
- fabrice404
- package-ecosystem: npm
directory: "/ui"
schedule:
interval: daily
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
commit-message:
prefix: "npm"
include: "scope"
groups:
dependencies-patch-and-minor:
update-types:
- minor
- patch
open-pull-requests-limit: 50
reviewers:
- fabrice404
assignees:
- fabrice404

27
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,27 @@
# name: Auto update calendar
# on:
# # schedule:
# # - cron: "*/10 * * * *"
# workflow_dispatch:
# jobs:
# run:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
# - run: npm install
# - run: npm start
# - name: Set date
# id: date_step
# run: echo "today=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_ENV
# - uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: "calendar updates on ${{ env.today }}"

16
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"cSpell.words": [
"Cortina",
"NOC", // National Olympic Committee
"NOCs", // National Olympic Committees
// color palette
"azzurro",
"giallo",
"rosa",
"rosso",
"verde",
"viola"
]
}

3
LICENSE Normal file
View File

@ -0,0 +1,3 @@
Copyright (c) 2025 Fabrice Lamant. All rights reserved.
No part of this work may be copied, reproduced, distributed, transmitted, displayed, performed, or otherwise used, in whole or in part, by any means or in any form, without the prior written permission of the copyright holder.

9
README.md Normal file
View File

@ -0,0 +1,9 @@
# Olympics/Paralympics Calendars
Work in progress for Milano-Cortina 2026 Winter Olympics calendars.
The calendars are available on https://olympics-calendar.lamant.dev/
> [!NOTE]
> Paris 2024 Olympics calendars have been archived to the [2024-paris-olympics](https://github.com/fabrice404/olympics-calendar/tree/2024-paris-olympics) branch.\
> Paris 2024 Paralympics calendars have been archived to the [2024-paris-paralympics](https://github.com/fabrice404/olympics-calendar/tree/2024-paris-paralympics) branch.

21
docker-compose.yaml Normal file
View File

@ -0,0 +1,21 @@
services:
scraper:
container_name: scraper
build: ./scraper
restart: unless-stopped
volumes:
- cache:/app/cache
- data:/app/output
ui:
container_name: ui
build: ./ui
ports:
- "3000:3000"
restart: unless-stopped
volumes:
- data:/app/data
volumes:
cache:
data:

View File

@ -1,8 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="refresh" content="0; url=https://olympics-calendar.lamant.dev/" />
</head>
<body>
</body>
</html>

View File

@ -1,8 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="refresh" content="0; url=https://olympics-calendar.lamant.dev/" />
</head>
<body>
</body>
</html>

2
scraper/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
cache/
output/

16
scraper/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:lts-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm install
FROM base AS build
WORKDIR /app
COPY . .
RUN npm run build
FROM node:lts-alpine AS runner
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY --from=build /app/dist .
ENV DEBUG=olympics-calendar:*
CMD ["node", "index.js"]

32
scraper/cache.ts Normal file
View File

@ -0,0 +1,32 @@
import Debug from "debug";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
export class Cache {
private debug = Debug("olympics-calendar:cache");
private cachePath = (key: string): string => {
return `./cache/${key}.cached`;
};
public get(key: string): string | null {
this.debug("get", key);
const path = this.cachePath(key);
if (existsSync(path)) {
return readFileSync(path, "utf-8");
}
return null;
}
public has(key: string): boolean {
this.debug(`has: key=${key}`);
const path = this.cachePath(key);
return existsSync(path);
}
public set(key: string, data: string): void {
this.debug(`set: key=${key}`);
const path = this.cachePath(key);
mkdirSync(path.split("/").slice(0, -1).join("/"), { recursive: true });
writeFileSync(path, data);
}
}

8
scraper/dev.ts Normal file
View File

@ -0,0 +1,8 @@
import { Scraper } from "./scraper";
const main = async () => {
const scraper = new Scraper();
await scraper.scrape();
};
main();

76
scraper/eslint.config.mjs Normal file
View File

@ -0,0 +1,76 @@
import eslint from "@eslint/js";
import perfectionist from "eslint-plugin-perfectionist";
import { defineConfig, globalIgnores } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
globalIgnores(["./dist/**", "./node_modules/**"]),
eslint.configs.recommended,
tseslint.configs.recommended,
{
plugins: {
perfectionist,
},
},
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"@typescript-eslint/no-unused-vars": ["error", { caughtErrors: "none" }],
"no-case-declarations": "off",
"comma-dangle": ["error", "only-multiline"],
complexity: ["error", 15],
quotes: ["error", "double"],
semi: ["error", "always"],
"perfectionist/sort-imports": [
"error",
{ order: "asc", type: "natural" },
],
"perfectionist/sort-classes": [
"error",
{
groups: [
"index-signature",
["private-static-property", "private-static-accessor-property"],
["protected-static-property", "protected-static-accessor-property"],
["static-property", "static-accessor-property"],
["private-property", "private-accessor-property"],
["protected-property", "protected-accessor-property"],
["property", "accessor-property"],
"constructor",
["private-static-get-method", "private-static-set-method"],
["protected-static-get-method", "protected-static-set-method"],
["static-get-method", "static-set-method"],
["private-get-method", "private-set-method"],
["protected-get-method", "protected-set-method"],
["get-method", "set-method"],
["private-static-method", "private-static-function-property"],
["protected-static-method", "protected-static-function-property"],
["static-method", "static-function-property"],
["private-method", "private-function-property"],
["protected-method", "protected-function-property"],
["method", "function-property"],
"static-block",
"unknown",
],
ignoreCase: true,
order: "asc",
type: "alphabetical",
},
],
"perfectionist/sort-named-exports": [
"error",
{ order: "asc", type: "natural" },
],
"no-console": "off",
},
},
);

120
scraper/ics-generator.ts Normal file
View File

@ -0,0 +1,120 @@
import Debug from "debug";
import { mkdirSync, writeFileSync } from "fs";
import { getFlag } from "./nocs";
import { Calendar } from "./types";
export class ICSGenerator {
private calendar: Calendar;
private debug = Debug("olympics-calendar:ics-generator");
constructor(calendar: Calendar) {
this.calendar = calendar;
}
private generateICSFile(
sportKey: string | null,
nocKey: string | null,
): void {
this.debug(
"generateICSFile",
sportKey || "all-sports",
nocKey || "all-nocs",
);
this.calendar.languages.forEach((lang) => {
const pathSportKey = sportKey ? sportKey : "all-sports";
const pathNocKey = nocKey ? nocKey : "calendar";
const filepath = `./output/${lang.code.toLowerCase()}/${pathSportKey.toLowerCase()}/${pathNocKey.toLowerCase()}.ics`;
mkdirSync(filepath.split("/").slice(0, -1).join("/"), { recursive: true });
const titleComponents = [];
if (nocKey) {
titleComponents.push(
`${this.calendar.nocs.find((n) => n.key === nocKey)!.name[lang.code]}`,
);
}
if (sportKey) {
titleComponents.push(this.calendar.sports.find((s) => s.key === sportKey)!.name[lang.code]);
}
titleComponents.push("Milano Cortina 2026");
const title = titleComponents.join(" - ");
const lines = [];
lines.push("BEGIN:VCALENDAR");
lines.push("VERSION:2.0");
lines.push(
`PRODID:-//fabrice404//olympics-calendar//${lang.code}/${pathSportKey}/${pathNocKey}`,
);
lines.push(`X-WR-CALNAME:${title}`);
lines.push(`NAME:${title}`);
this.calendar.events
.filter((event) => {
if (sportKey && event.sport !== sportKey) return false;
if (nocKey) {
if (event.match) {
const team1Key = event.match.team1.key;
const team2Key = event.match.team2.key;
if (team1Key !== nocKey && team2Key !== nocKey) {
return false;
}
} else {
return false;
}
}
return true;
})
.forEach((event) => {
lines.push("BEGIN:VEVENT");
lines.push(`UID:${event.key.replace(/--/g, "-")}`);
lines.push(`DTSTAMP:${event.start.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`);
lines.push(`DTSTART:${event.start.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`);
lines.push(`DTEND:${event.end.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`);
lines.push(`LOCATION:${event.location[lang.code] || ""}`);
const sport = this.calendar.sports.find(
(s) => s.key === event.sport,
)!;
lines.push(`DESCRIPTION:${sport.name[lang.code]} - ${event.name[lang.code] || ""}`);
const summary = `SUMMARY:${event.name[lang.code] || ""}`;
if (event.match) {
const team1Name = event.match.team1.name[lang.code] || event.match.team1.key;
const team1Flag = getFlag(event.match.team1.key);
const team2Name = event.match.team2.name[lang.code] || event.match.team2.key;
const team2Flag = getFlag(event.match.team2.key);
if (team1Name && team2Name) {
lines.push(`SUMMARY:${team1Flag} ${team1Name} - ${team2Name} ${team2Flag}`);
}
}
lines.push(summary);
lines.push("END:VEVENT");
});
lines.push("END:VCALENDAR");
writeFileSync(filepath, lines.join("\n"));
});
}
public generate(): void {
this.debug("generate");
this.generateICSFile(null, null);
this.calendar.sports.forEach((sport) => {
this.generateICSFile(sport.key, null);
this.calendar.nocs.forEach((noc) => {
this.generateICSFile(sport.key, noc.key);
});
});
this.calendar.nocs.forEach((noc) => {
this.generateICSFile(null, noc.key);
});
}
}

12
scraper/index.ts Normal file
View File

@ -0,0 +1,12 @@
import nodeCron from "node-cron";
import { Scraper } from "./scraper";
const main = async () => {
nodeCron.schedule("* * * * *", async () => {
const scraper = new Scraper();
await scraper.scrape();
});
};
main();

212
scraper/nocs.ts Normal file
View File

@ -0,0 +1,212 @@
export const flags: { [key: string]: string } = {
AFG: "🇦🇫",
ALB: "🇦🇱",
ALG: "🇩🇿",
AIN: "🇦🇸",
AND: "🇦🇩",
ANG: "🇦🇴",
ANT: "🇦🇬",
ARG: "🇦🇷",
ARM: "🇦🇲",
ARU: "🇦🇼",
ASA: "🇦🇸",
AUS: "🇦🇺",
AUT: "🇦🇹",
AZE: "🇦🇿",
BAH: "🇧🇸",
BAN: "🇧🇩",
BAR: "🇧🇧",
BDI: "🇧🇮",
BEL: "🇧🇪",
BEN: "🇧🇯",
BER: "🇧🇲",
BHU: "🇧🇹",
BIH: "🇧🇦",
BIZ: "🇧🇿",
BOL: "🇧🇴",
BOT: "🇧🇼",
BRA: "🇧🇷",
BRN: "🇧🇭",
BRU: "🇧🇳",
BUL: "🇧🇬",
BUR: "🇧🇫",
CAF: "🇨🇫",
CAM: "🇰🇭",
CAN: "🇨🇦",
CAY: "🇰🇾",
CGO: "🇨🇬",
CHA: "🇹🇩",
CHI: "🇨🇱",
CHN: "🇨🇳",
CIV: "🇨🇮",
CMR: "🇨🇲",
COD: "🇨🇩",
COK: "🇨🇰",
COL: "🇨🇴",
COM: "🇰🇲",
CPV: "🇨🇻",
CRC: "🇨🇷",
CRO: "🇭🇷",
CUB: "🇨🇺",
CYP: "🇨🇾",
CZE: "🇨🇿",
DEN: "🇩🇰",
DJI: "🇩🇯",
DMA: "🇩🇲",
DOM: "🇩🇴",
ECU: "🇪🇨",
EGY: "🇪🇬",
EOR: "🏳️",
ERI: "🇪🇷",
ESA: "🇸🇻",
ESP: "🇪🇸",
EST: "🇪🇪",
ETH: "🇪🇹",
FIJ: "🇫🇯",
FIN: "🇫🇮",
FRA: "🇫🇷",
FSM: "🇫🇲",
GAB: "🇬🇦",
GAM: "🇬🇲",
GBR: "🇬🇧",
GBS: "🇬🇼",
GEO: "🇬🇪",
GEQ: "🇬🇶",
GER: "🇩🇪",
GHA: "🇬🇭",
GRE: "🇬🇷",
GRN: "🇬🇩",
GUA: "🇬🇹",
GUI: "🇬🇳",
GUM: "🇬🇺",
GUY: "🇬🇾",
HAI: "🇭🇹",
HKG: "🇭🇰",
HON: "🇭🇳",
HUN: "🇭🇺",
INA: "🇮🇩",
IND: "🇮🇳",
IRI: "🇮🇷",
IRL: "🇮🇪",
IRQ: "🇮🇶",
ISL: "🇮🇸",
ISR: "🇮🇱",
ISV: "🇻🇮",
ITA: "🇮🇹",
IVB: "🇻🇬",
JAM: "🇯🇲",
JOR: "🇯🇴",
JPN: "🇯🇵",
KAZ: "🇰🇿",
KEN: "🇰🇪",
KGZ: "🇰🇬",
KIR: "🇰🇮",
KOR: "🇰🇷",
KOS: "🇽🇰",
KSA: "🇸🇦",
KUW: "🇰🇼",
LAO: "🇱🇦",
LAT: "🇱🇻",
LBA: "🇱🇾",
LBN: "🇱🇧",
LBR: "🇱🇷",
LCA: "🇱🇨",
LES: "🇱🇸",
LIE: "🇱🇮",
LTU: "🇱🇹",
LUX: "🇱🇺",
MAD: "🇲🇬",
MAR: "🇲🇦",
MAS: "🇲🇾",
MAW: "🇲🇼",
MDA: "🇲🇩",
MDV: "🇲🇻",
MEX: "🇲🇽",
MGL: "🇲🇳",
MHL: "🇲🇭",
MKD: "🇲🇰",
MLI: "🇲🇱",
MLT: "🇲🇹",
MNE: "🇲🇪",
MON: "🇲🇨",
MOZ: "🇲🇿",
MRI: "🇲🇺",
MTN: "🇲🇷",
MYA: "🇲🇲",
NAM: "🇳🇦",
NCA: "🇳🇮",
NED: "🇳🇱",
NEP: "🇳🇵",
NGR: "🇳🇬",
NIG: "🇳🇪",
NOR: "🇳🇴",
NRU: "🇳🇷",
NZL: "🇳🇿",
OMA: "🇴🇲",
PAK: "🇵🇰",
PAN: "🇵🇦",
PAR: "🇵🇾",
PER: "🇵🇪",
PHI: "🇵🇭",
PLE: "🇵🇸",
PLW: "🇵🇼",
PNG: "🇵🇬",
POL: "🇵🇱",
POR: "🇵🇹",
PRK: "🇰🇵",
PUR: "🇵🇷",
QAT: "🇶🇦",
ROU: "🇷🇴",
RSA: "🇿🇦",
RWA: "🇷🇼",
SAM: "🇼🇸",
SEN: "🇸🇳",
SEY: "🇸🇨",
SGP: "🇸🇬",
SKN: "🇰🇳",
SLE: "🇸🇱",
SLO: "🇸🇮",
SMR: "🇸🇲",
SOL: "🇸🇧",
SOM: "🇸🇴",
SRB: "🇷🇸",
SRI: "🇱🇰",
SSD: "🇸🇸",
STP: "🇸🇹",
SUD: "🇸🇩",
SUI: "🇨🇭",
SUR: "🇸🇷",
SVK: "🇸🇰",
SWE: "🇸🇪",
SWZ: "🇸🇿",
SYR: "🇸🇾",
TAN: "🇹🇿",
TGA: "🇹🇴",
THA: "🇹🇭",
TJK: "🇹🇯",
TKM: "🇹🇲",
TLS: "🇹🇱",
TOG: "🇹🇬",
TPE: "🇹🇼",
TTO: "🇹🇹",
TUN: "🇹🇳",
TUR: "🇹🇷",
TUV: "🇹🇻",
UAE: "🇦🇪",
UGA: "🇺🇬",
UKR: "🇺🇦",
URU: "🇺🇾",
USA: "🇺🇸",
UZB: "🇺🇿",
VAN: "🇻🇺",
VEN: "🇻🇪",
VIE: "🇻🇳",
VIN: "🇻🇨",
YEM: "🇾🇪",
ZAM: "🇿🇲",
ZIM: "🇿🇼",
};
export const getFlag = (nocKey: string): string => {
return flags[nocKey.toUpperCase()] || "🏳️";
};

9
scraper/nodemon.json Normal file
View File

@ -0,0 +1,9 @@
{
"verbose": false,
"ignore": [
"node_modules",
"cache/**",
"output/**"
],
"ext": "ts,json"
}

2209
scraper/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
scraper/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "scraper",
"version": "1.0.0",
"author": "Fabrice Lamant",
"type": "commonjs",
"main": "index.js",
"scripts": {
"build": "tsc --build --verbose",
"start": "DEBUG=olympics-calendar* ts-node index.ts",
"dev": "DEBUG=olympics-calendar* nodemon dev.ts",
"lint": "eslint . --ext .ts"
},
"dependencies": {
"axios": "^1.13.2",
"debug": "^4.4.3",
"node-cron": "^4.2.1",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/debug": "^4.1.12",
"@types/node": "^24.10.3",
"eslint": "^9.39.2",
"eslint-plugin-perfectionist": "^4.15.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.49.0"
}
}

195
scraper/scraper.ts Normal file
View File

@ -0,0 +1,195 @@
import { get } from "axios";
import Debug from "debug";
import { writeFileSync } from "fs";
import { Cache } from "./cache";
import { ICSGenerator } from "./ics-generator";
import { Calendar, Event, Language, PageData, Sport, Team } from "./types";
const BASE_URL = "https://www.olympics.com";
const BASE_SCHEDULE_PATH = "milano-cortina-2026/schedule/overview";
export class Scraper {
private cache = new Cache();
private debug = Debug("olympics-calendar:scraper");
private events: Event[] = [];
private languages: Language[] = [];
private nocs: Team[] = [];
private sports: Sport[] = [];
private async getPageData(path: string): Promise<PageData> {
this.debug(`getPageData: path=${path}`);
if (!this.cache.has(path)) {
const url = `${BASE_URL}${path}`;
this.debug(url);
const response = await get(url, {
headers: {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
},
});
const page = await response.data;
const dataMatch = page.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
if (!dataMatch) {
throw new Error(
`Could not find __NEXT_DATA__ script tag for URL: ${url}`,
);
}
const data = dataMatch[1];
if (data) {
this.cache.set(path, JSON.stringify(JSON.parse(data), null, 2));
}
}
return JSON.parse(this.cache.get(path)!);
}
private saveCalendar(): void {
this.debug("saveCalendar");
const calendar = this.getCalendar();
writeFileSync("./output/calendar.json", JSON.stringify(calendar));
}
private async scrapeEvents(): Promise<void> {
this.debug("scrapeEvents");
for (const sport of this.sports) {
for (const lang of this.languages) {
const data = await this.getPageData(
`/${lang.code}/milano-cortina-2026/schedule/${sport.key}`,
);
const scheduleList = data.props.pageProps.page.items
.find(
(item) => item.type === "module" && item.name === "scheduleList",
)!
.data.schedules.map((schedule) => schedule.units)
.flat();
for (const scheduleElement of scheduleList) {
if (
this.events.find((e) => e.key === scheduleElement.unitCode) == null
) {
this.events.push({
key: scheduleElement.unitCode,
sport: sport.key,
start: scheduleElement.startDateTimeUtc,
end: scheduleElement.endDateTimeUtc,
isTraining: scheduleElement.isTraining,
medal: scheduleElement.medal,
name: {},
location: {},
});
}
const event = this.events.find(
(e) => e.key === scheduleElement.unitCode,
)!;
event.name[lang.code] = scheduleElement.description;
event.location[lang.code] = scheduleElement.venue?.description || "";
if (scheduleElement.match) {
if (event.match == null) {
event.match = {
team1: {
key: scheduleElement.match.team1.teamCode.replace(
/[^A-Z]/gi,
"",
),
name: {},
},
team2: {
key: scheduleElement.match.team2.teamCode.replace(
/[^A-Z]/gi,
"",
),
name: {},
},
};
}
event.match.team1.name[lang.code] = (
scheduleElement.match.team1.description || ""
).replace(/,/gi, "");
event.match.team2.name[lang.code] = (
scheduleElement.match.team2.description || ""
).replace(/,/gi, "");
for (const team of [
scheduleElement.match.team1,
scheduleElement.match.team2,
]) {
const nocKey = team.teamCode.replace(/[^A-Z]/gi, "");
if (this.nocs.find((n) => n.key === nocKey) == null) {
this.nocs.push({ key: nocKey, name: {} });
}
const noc = this.nocs.find((n) => n.key === nocKey)!;
noc.name[lang.code] = (team.description || "").replace(/,/gi, "");
}
}
}
}
}
}
private async scrapeLanguages(): Promise<void> {
this.debug("scrapeLanguages");
const pageData = await this.getPageData(`/en/${BASE_SCHEDULE_PATH}`);
const languagesData =
pageData.props.pageProps.page.template.properties.header.mainNav
.languages;
this.languages = languagesData
.filter((lang) =>
lang.link.match(/\/milano-cortina-2026\/schedule\/overview$/),
)
.map((lang) => ({
code: lang.lang,
name: lang.label,
}));
}
private async scrapeSports(): Promise<void> {
this.debug("scrapeSports");
for (const lang of this.languages) {
this.debug(`Scraping language: ${lang.code}`);
const pageData = await this.getPageData(
`/${lang.code}/${BASE_SCHEDULE_PATH}`,
);
const disciplines = pageData.props.pageProps.page.items.find(
(item) => item.type === "module" && item.name === "scheduleGrid",
)!.data.disciplines;
for (const discipline of disciplines.filter(
(d) => d.disciplineCode.toLowerCase() !== "cer",
)) {
const key = discipline.disciplineCode.toLowerCase();
if (this.sports.find((s) => s.key === key) == null) {
this.sports.push({ key, name: {}, order: -1 });
}
const sport = this.sports.find((s) => s.key === key)!;
sport.name[lang.code] = discipline.description;
sport.order = discipline.order;
}
}
}
public getCalendar(): Calendar {
return {
languages: this.languages,
sports: this.sports,
nocs: this.nocs,
events: this.events,
};
}
public async scrape(): Promise<void> {
this.debug("scrape");
await this.scrapeLanguages();
await this.scrapeSports();
await this.scrapeEvents();
this.saveCalendar();
new ICSGenerator(this.getCalendar()).generate();
}
}

41
scraper/tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
"outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"lib": ["esnext"],
"types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": false,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}

99
scraper/types.d.ts vendored Normal file
View File

@ -0,0 +1,99 @@
export interface MultilingualString {
[key: string]: string;
}
export interface Language {
code: string;
name: string;
}
export interface Sport {
key: string;
name: MultilingualString;
order: number;
}
export interface Team {
key: string;
name: MultilingualString;
}
export interface Match {
team1: Team;
team2: Team;
}
export interface Event {
key: string;
start: string;
end: string;
sport: string;
isTraining: boolean;
medal: "0" | "1" | "3";
name: MultilingualString;
location: MultilingualString;
match?: Match;
}
export interface Calendar {
languages: Language[];
sports: Sport[];
events: Event[];
nocs: Team[];
}
export interface PageData {
props: {
pageProps: {
page: {
template: {
properties: {
header: {
mainNav: {
languages: {
link: string;
lang: string;
label: string;
}[];
};
};
};
};
items: {
type: string;
name: string;
data: {
disciplines: {
disciplineCode: string;
order: number;
description: string;
}[];
schedules: {
units: {
unitCode: string;
startDateTimeUtc: string;
endDateTimeUtc: string;
isTraining: boolean;
medal: "0" | "1" | "3";
description: string;
venue: {
description: string;
};
match?: {
team1: {
teamCode: string;
description: string;
};
team2: {
teamCode: string;
description: string;
};
};
}[];
}[];
};
}[];
};
};
};
}

View File

@ -1,8 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="refresh" content="0; url=https://olympics-calendar.lamant.dev/" />
</head>
<body>
</body>
</html>

43
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/data/

21
ui/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:lts-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm install
FROM base AS build
WORKDIR /app
COPY . .
RUN npm run build
FROM node:lts-alpine AS runner
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -0,0 +1,24 @@
import { promises as fs } from "fs";
import { NextResponse } from "next/server";
import path from "path";
const DATA_FOLDER = path.resolve("data");
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug?: string[] | undefined }> }
): Promise<NextResponse> {
try {
const { slug } = await params || [];
const filePath = slug ? path.join(DATA_FOLDER, ...slug) : null;
if (!filePath) throw new Error()
const content = await fs.readFile(filePath);
if (!content) throw new Error()
return new NextResponse(content, { status: 200 });
} catch (ex) {
console.log(ex);
return new NextResponse("File not found", { status: 404 });
}
}

BIN
ui/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

267
ui/app/flag.tsx Normal file
View File

@ -0,0 +1,267 @@
export default function Flag({ iso3, name }: { iso3: string; name: string }) {
const iso3to2: { [key: string]: string } = {
AFG: "AF",
ALA: "AX",
ALB: "AL",
DZA: "DZ",
ASM: "AS",
AND: "AD",
AGO: "AO",
AIA: "AI",
ATA: "AQ",
ATG: "AG",
ARG: "AR",
ARM: "AM",
ABW: "AW",
AUS: "AU",
AUT: "AT",
AZE: "AZ",
BHS: "BS",
BHR: "BH",
BGD: "BD",
BRB: "BB",
BLR: "BY",
BEL: "BE",
BLZ: "BZ",
BEN: "BJ",
BMU: "BM",
BTN: "BT",
BOL: "BO",
BES: "BQ",
BIH: "BA",
BWA: "BW",
BVT: "BV",
BRA: "BR",
VGB: "VG",
IOT: "IO",
BRN: "BN",
BGR: "BG",
BFA: "BF",
BDI: "BI",
KHM: "KH",
CMR: "CM",
CAN: "CA",
CPV: "CV",
CYM: "KY",
CAF: "CF",
TCD: "TD",
CHL: "CL",
CHN: "CN",
HKG: "HK",
MAC: "MO",
CXR: "CX",
CCK: "CC",
COL: "CO",
COM: "KM",
COG: "CG",
COD: "CD",
COK: "CK",
CRI: "CR",
CIV: "CI",
HRV: "HR",
CUB: "CU",
CUW: "CW",
CYP: "CY",
CZE: "CZ",
DNK: "DK",
DJI: "DJ",
DMA: "DM",
DOM: "DO",
ECU: "EC",
EGY: "EG",
SLV: "SV",
GNQ: "GQ",
ERI: "ER",
EST: "EE",
ETH: "ET",
FLK: "FK",
FRO: "FO",
FJI: "FJ",
FIN: "FI",
FRA: "FR",
GUF: "GF",
PYF: "PF",
ATF: "TF",
GAB: "GA",
GMB: "GM",
GEO: "GE",
DEU: "DE",
GHA: "GH",
GIB: "GI",
GRC: "GR",
GRL: "GL",
GRD: "GD",
GLP: "GP",
GUM: "GU",
GTM: "GT",
GGY: "GG",
GIN: "GN",
GNB: "GW",
GUY: "GY",
HTI: "HT",
HMD: "HM",
VAT: "VA",
HND: "HN",
HUN: "HU",
ISL: "IS",
IND: "IN",
IDN: "ID",
IRN: "IR",
IRQ: "IQ",
IRL: "IE",
IMN: "IM",
ISR: "IL",
ITA: "IT",
JAM: "JM",
JPN: "JP",
JEY: "JE",
JOR: "JO",
KAZ: "KZ",
KEN: "KE",
KIR: "KI",
PRK: "KP",
KOR: "KR",
KWT: "KW",
KGZ: "KG",
LAO: "LA",
LVA: "LV",
LBN: "LB",
LSO: "LS",
LBR: "LR",
LBY: "LY",
LIE: "LI",
LTU: "LT",
LUX: "LU",
MKD: "MK",
MDG: "MG",
MWI: "MW",
MYS: "MY",
MDV: "MV",
MLI: "ML",
MLT: "MT",
MHL: "MH",
MTQ: "MQ",
MRT: "MR",
MUS: "MU",
MYT: "YT",
MEX: "MX",
FSM: "FM",
MDA: "MD",
MCO: "MC",
MNG: "MN",
MNE: "ME",
MSR: "MS",
MAR: "MA",
MOZ: "MZ",
MMR: "MM",
NAM: "NA",
NRU: "NR",
NPL: "NP",
NLD: "NL",
ANT: "AN",
NCL: "NC",
NZL: "NZ",
NIC: "NI",
NER: "NE",
NGA: "NG",
NIU: "NU",
NFK: "NF",
MNP: "MP",
NOR: "NO",
OMN: "OM",
PAK: "PK",
PLW: "PW",
PSE: "PS",
PAN: "PA",
PNG: "PG",
PRY: "PY",
PER: "PE",
PHL: "PH",
PCN: "PN",
POL: "PL",
PRT: "PT",
PRI: "PR",
QAT: "QA",
REU: "RE",
ROU: "RO",
RUS: "RU",
RWA: "RW",
BLM: "BL",
SHN: "SH",
KNA: "KN",
LCA: "LC",
MAF: "MF",
SPM: "PM",
VCT: "VC",
WSM: "WS",
SMR: "SM",
STP: "ST",
SAU: "SA",
SEN: "SN",
SRB: "RS",
SYC: "SC",
SLE: "SL",
SGP: "SG",
SXM: "SX",
SVK: "SK",
SVN: "SI",
SLB: "SB",
SOM: "SO",
ZAF: "ZA",
SGS: "GS",
SSD: "SS",
ESP: "ES",
LKA: "LK",
SDN: "SD",
SUR: "SR",
SJM: "SJ",
SWZ: "SZ",
SWE: "SE",
CHE: "CH",
SYR: "SY",
TWN: "TW",
TJK: "TJ",
TZA: "TZ",
THA: "TH",
TLS: "TL",
TGO: "TG",
TKL: "TK",
TON: "TO",
TTO: "TT",
TUN: "TN",
TUR: "TR",
TKM: "TM",
TCA: "TC",
TUV: "TV",
UGA: "UG",
UKR: "UA",
ARE: "AE",
GBR: "GB",
USA: "US",
UMI: "UM",
URY: "UY",
UZB: "UZ",
VUT: "VU",
VEN: "VE",
VNM: "VN",
VIR: "VI",
WLF: "WF",
ESH: "EH",
YEM: "YE",
ZMB: "ZM",
ZWE: "ZW",
XKX: "XK",
SUI: "CH",
GER: "DE",
};
const iso2 = (iso3to2[iso3.toUpperCase()] || "").toLowerCase();
return <img
src={`https://gstatic.olympics.com/s3/noc/oly/3x2/${iso3.toUpperCase()}.png`}
height="24"
alt={`${iso3} - ${iso2}`}
className="inline-block mx-2 h-5 border-1 border-gray-300" />
}

69
ui/app/globals.css Normal file
View File

@ -0,0 +1,69 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: winter;
}
html {
background-color: #eeeeee;
}
.fg-azzurro {
color: #b0cde9;
}
.fg-rosso {
color: #fe5a59;
}
.fg-viola {
color: #c9badc;
}
.fg-giallo {
color: #f2e95d;
}
.fg-rosa {
color: #e9bdd2;
}
.fg-verde {
color: #769d91;
}
.fg-main {
color: #01647c;
}
.bg-main {
background-color: #01647c;
color : #ffffff;
}
.fg-gold {
color: #FFD700;
}
.bg-gold {
background-color: #FFD700;
color : #000000;
}
.fg-silver {
color: #C0C0C0;
}
.bg-silver {
background-color: #C0C0C0;
color : #000000;
}
.fg-bronze {
color: #CD7F32;
}
.bg-bronze {
background-color: #CD7F32;
color : #000000;
}

38
ui/app/layout.tsx Normal file
View File

@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Head from "next/head";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Milano Cortina 2026 Winter Olympics Calendar",
description: "Made with ❤️ by Fabrice Lamant",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html data-theme="winter">
<Head>
<link rel="icon" href="/favicon.ico" sizes="any" />
</Head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

395
ui/app/page.tsx Normal file
View File

@ -0,0 +1,395 @@
"use client";
import { loadSchedule } from "../lib/data";
import { useEffect, useState } from "react";
import Flag from "./flag";
import { COPY, COPY_SUCCESS, FILTER_BY_COUNTRY, FILTER_BY_SPORT, MADE_BY_FABRICE, NOT_AFFILIATED } from "../lib/text";
import useLocalStorage from "@/lib/local-storage";
import { GoogleAnalytics } from "@next/third-parties/google";
interface MultilingualString {
[key: string]: string;
}
interface Language {
code: string;
name: string;
}
interface Sport {
key: string;
name: MultilingualString
}
interface Team {
key: string;
name: MultilingualString;
}
interface Match {
team1: Team;
team2: Team;
}
interface Event {
key: string;
start: string;
end: string;
sport: string;
isTraining: boolean;
medal: '0' | '1' | '3';
name: MultilingualString;
match?: Match;
}
interface Calendar {
languages: Language[];
sports: Sport[];
events: Event[];
nocs: Team[];
}
const COLORS = ['azzurro', 'giallo', 'rosa', 'rosso', 'verde', 'viola'];
export default function Home() {
const qs = typeof window !== 'undefined' ? window.location.search ? new URLSearchParams(window.location.search) : new URLSearchParams() : new URLSearchParams();
const [data, setData] = useState<Calendar | null>(null);
const [language, setLanguage] = useLocalStorage('lang', (navigator.language || 'en').split('-')[0]);
const [cookieConsent, setCookieConsent] = useLocalStorage('cookie-consent', 'null');
const translate = (text: MultilingualString) => {
return text[`${language}`] || text['en'] || Object.values(text)[0] || '';
};
const generateLink = ({ noc, sport, lang }: { noc?: string; sport?: string, lang?: string }) => {
const currentParams = new URLSearchParams(qs.toString());
if (noc !== undefined) {
if (noc === "") {
currentParams.delete('noc');
} else {
currentParams.set('noc', noc);
}
}
if (sport !== undefined) {
if (sport === "") {
currentParams.delete('sport');
} else {
currentParams.set('sport', sport);
}
}
if (lang !== undefined) {
if (lang === "") {
currentParams.delete('lang');
} else {
currentParams.set('lang', lang);
}
}
const paramString = currentParams.toString();
return paramString ? `./?${paramString}` : '.';
}
const generateCalendarLink = () => {
const host = typeof window !== 'undefined' ? window.location.host : '';
const noc = (qs.get('noc') || 'calendar').toLowerCase();
const sport = (qs.get('sport') || 'all-sports').toLowerCase();
return `http://${host}/api/data/${language}/${sport}/${noc}.ics`;
};
const getColor = (i: number) => COLORS[i % COLORS.length];
useEffect(() => {
if (data == null) {
loadSchedule()
.then(setData)
.catch(console.log);
}
}, [data]);
const filter = (event: Event) => {
let visible = true;
if (event.end < new Date().toISOString()) {
return false;
}
const sport = qs.get('sport');
if (sport && event.sport !== sport) {
visible = false;
}
const noc = qs.get('noc');
if (noc) {
if (event.match) {
if (event.match.team1.key !== noc && event.match.team2.key !== noc) {
visible = false;
}
} else {
visible = false;
}
}
return visible;
}
const copyToClipboard = (text: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const button = document.getElementById('copy_button')!;
navigator.clipboard.writeText(text).then(() => {
button.textContent = translate(COPY_SUCCESS);
button.classList.add('text-success');
button.classList.add('font-bold');
setTimeout(() => {
// document.getElementById('copy_toast')?.classList.remove('toast-open');
button.textContent = translate(COPY);
button.classList.remove('text-success');
button.classList.remove('font-bold');
}, 2000);
});
}
}
const calendarLink = generateCalendarLink();
if (data) {
let lastDay = "";
if (data.languages.find(lang => lang.code === language) === undefined) {
setLanguage('en')
}
return (
<div>
<div className="navbar bg-main">
<div className="navbar-start">
<a href="." className="text-xl">Milano Cortina 2026 Winter Olympics Calendar</a>
</div>
<div className="navbar-end">
<ul className="menu menu-horizontal px-2">
<li className="px-2">
<div className="dropdown">
<div tabIndex={0} role="button" className="select bg-transparent">
{qs.get('sport') ? (
<>{translate(data.sports.find((sport) => sport.key === qs.get('sport'))!.name)}</>
) : (
<>{translate(FILTER_BY_SPORT)}</>
)}
</div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 text-black rounded-box z-1 w-52 p-2 shadow-sm">
{data.sports.sort((a, b) => translate(a.name).localeCompare(translate(b.name))).map(sport => {
if (sport.key === qs.get('sport')) {
return (
<li key={sport.key}>
<a href={generateLink({ sport: "" })}><div aria-label="success" className="status status-success"></div> {translate(sport.name)}</a>
</li>
)
}
return (
<li key={sport.key}>
<a href={generateLink({ sport: sport.key })}>{translate(sport.name)}</a>
</li>
)
})}
</ul>
</div>
</li>
<li className="px-2">
<div className="dropdown">
<div tabIndex={0} role="button" className="select bg-transparent">
{qs.get('noc') ? (
<>{translate(data.nocs.find((noc) => noc.key === qs.get('noc'))!.name)}</>
) : (
<>{translate(FILTER_BY_COUNTRY)}</>
)}
</div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 text-black rounded-box z-1 w-52 p-2 shadow-sm">
{data.nocs.sort((a, b) => translate(a.name).localeCompare(translate(b.name))).map(noc => {
if (noc.key === qs.get('noc')) {
return (
<li key={noc.key}>
<a href={generateLink({ noc: "" })}><div aria-label="success" className="status status-success"></div> {translate(noc.name)}</a>
</li>
)
}
return (
<li key={noc.key}>
<a href={generateLink({ noc: noc.key })}>{translate(noc.name)}</a>
</li>
)
})}
</ul>
</div>
</li>
<li className="px-2">
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost">
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path strokeLinejoin="round" strokeLinecap="round" strokeWidth="2" fill="none" stroke="currentColor" d="M12 21a9 9 0 1 0 0-18m0 18a9 9 0 1 1 0-18m0 18c2.761 0 3.941-5.163 3.941-9S14.761 3 12 3m0 18c-2.761 0-3.941-5.163-3.941-9S9.239 3 12 3M3.5 9h17m-17 6h17"></path>
</svg>
<svg className="mt-px hidden size-2 fill-current opacity-60 sm:inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path></svg>
</div>
<ul tabIndex={-1} className="menu menu-sm dropdown-content bg-base-100 text-black rounded-box z-1 mt-3 w-52 p-2 shadow">
{data.languages.map(lang => (
<li key={lang.code}>
<a onClick={() => setLanguage(lang.code)}>{lang.code.toUpperCase()} - {lang.name}</a>
</li>
))}
</ul>
</div>
</li>
</ul>
</div>
</div>
<div>
<div className="text-center pt-6">
<span className="input w-1/3">
<input type="text" placeholder={calendarLink} readOnly={true} />
<button id="copy_button" className="label cursor-pointer" onClick={() => copyToClipboard(calendarLink)}>{translate(COPY)}</button>
</span>
<a className="inline-block" href={calendarLink.replace("https://", "webcal://")} target="_blank">
<img src="/img/icon-apple.svg" alt="Apple Calendar" className="inline-block size-6 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://calendar.google.com/calendar/u/0/r?cid=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-google.svg" alt="Google Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://outlook.office.com/calendar/0/deeplink/subscribe?url=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-office365.svg" alt="Office 365 Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://outlook.live.com/calendar/0/deeplink/subscribe?url=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-outlookcom.svg" alt="Outlook Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://calendar.yahoo.com/?ics=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-yahoo.svg" alt="Yahoo Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
</div>
{
data.events
.filter(event => filter(event))
.sort((a, b) => a.start.localeCompare(b.start))
.map((event, i) => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const startHours = startDate.getHours().toString().padStart(2, '0');
const startMinutes = startDate.getMinutes().toString().padStart(2, '0');
const endHours = endDate.getHours().toString().padStart(2, '0');
const endMinutes = endDate.getMinutes().toString().padStart(2, '0');
const participants = [];
let titleColor = "fg-main";
if (event.medal === '1') {
titleColor = "bg-gold";
} else if (event.medal === '3') {
titleColor = "bg-bronze";
}
if (event.match) {
participants.push(event.match.team1.key);
participants.push(event.match.team2.key);
}
const day = event.start.split('T')[0];
let dayHeader = <></>;
if (lastDay !== day) {
dayHeader = (
<div className="day-header text-center my-8">
<h2 className="text-3xl font-light fg-main">
{new Date(day).toLocaleDateString(language, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</h2>
</div>
);
}
lastDay = day;
return (
<div key={event.key}>
{dayHeader}
<div className="py-4 mx-auto my-4 bg-white w-3/4 rounded-lg">
<div className={`fg-${getColor(i)} w-1/4 align-top text-right inline-block text-5xl tabular-nums pr-2 border-r border-slate-900/10`}>
<span className="time-start">{startHours}:{startMinutes}</span>
<div className="time-end text-xs">{endHours}:{endMinutes}</div>
</div>
<div className="w-3/5 align-top inline-block text-black pl-2">
<div className="px-2">
{translate(data.sports.find(sport => sport.key === event.sport)?.name || {}).toUpperCase()}
</div>
<div className={`font-bold inline-block px-2 ${titleColor}`}>{translate(event.name)}</div>
{event.match?.team1?.key && event.match?.team2.key && (
<div className="competitors min-w-md max-w-md px-2 font-light">
<div className="w-1/3 inline-block">
{translate(event.match.team1.name)}
</div>
<div className="w-1/9 inline-block">
<Flag iso3={event.match.team1.key} name={translate(event.match.team1.name)} />
</div>
<div className="w-1/9 inline-block text-center">-</div>
<div className="w-1/9 inline-block text-right">
<Flag iso3={event.match.team2.key} name={translate(event.match.team2.name)} />
</div>
<div className="w-1/3 inline-block text-right">
{translate(event.match.team2.name)}
</div>
</div>
)}
</div>
</div>
</div>
)
})
}
</div>
<footer className="footer footer-horizontal footer-center bg-gray-800 text-primary-content p-10">
<aside>
<p className="font-bold">
{translate(MADE_BY_FABRICE)}
</p>
<p>{translate(NOT_AFFILIATED)}</p>
</aside>
<nav>
<div className="grid grid-flow-col gap-4">
<a href="https://github.com/fabrice404/olympics-calendar" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className="fill-current">
<path d="M5.315 2.1c.791 -.113 1.9 .145 3.333 .966l.272 .161l.16 .1l.397 -.083a13.3 13.3 0 0 1 4.59 -.08l.456 .08l.396 .083l.161 -.1c1.385 -.84 2.487 -1.17 3.322 -1.148l.164 .008l.147 .017l.076 .014l.05 .011l.144 .047a1 1 0 0 1 .53 .514a5.2 5.2 0 0 1 .397 2.91l-.047 .267l-.046 .196l.123 .163c.574 .795 .93 1.728 1.03 2.707l.023 .295l.007 .272c0 3.855 -1.659 5.883 -4.644 6.68l-.245 .061l-.132 .029l.014 .161l.008 .157l.004 .365l-.002 .213l-.003 3.834a1 1 0 0 1 -.883 .993l-.117 .007h-6a1 1 0 0 1 -.993 -.883l-.007 -.117v-.734c-1.818 .26 -3.03 -.424 -4.11 -1.878l-.535 -.766c-.28 -.396 -.455 -.579 -.589 -.644l-.048 -.019a1 1 0 0 1 .564 -1.918c.642 .188 1.074 .568 1.57 1.239l.538 .769c.76 1.079 1.36 1.459 2.609 1.191l.001 -.678l-.018 -.168a5.03 5.03 0 0 1 -.021 -.824l.017 -.185l.019 -.12l-.108 -.024c-2.976 -.71 -4.703 -2.573 -4.875 -6.139l-.01 -.31l-.004 -.292a5.6 5.6 0 0 1 .908 -3.051l.152 -.222l.122 -.163l-.045 -.196a5.2 5.2 0 0 1 .145 -2.642l.1 -.282l.106 -.253a1 1 0 0 1 .529 -.514l.144 -.047l.154 -.03z" />
</svg>
</a>
</div>
</nav>
</footer>
{cookieConsent === 'true' &&
<GoogleAnalytics gaId="G-SLBLJRE0CM" />
}
{cookieConsent === 'null' &&
<div className="sticky bottom-0 bg-gray-800 text-white text-center p-8">
<p className="p-4">This website uses cookies for statistics purposes and to enhance the user experience.</p>
<button className="btn btn-sm mx-2" onClick={() => setCookieConsent('true')}>Accept</button>
<button className="btn btn-sm btn-outline" onClick={() => setCookieConsent('false')}>Decline</button>
</div>
}
</div>
);
}
return (
<div>Loading</div>
);
}

18
ui/eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

5
ui/lib/data.ts Normal file
View File

@ -0,0 +1,5 @@
export const loadSchedule = async () => {
const response = await fetch('/api/data/calendar.json');
const data = await response.json();
return data;
};

30
ui/lib/local-storage.ts Normal file
View File

@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from "react"
export const useLocalStorage = (key: string, initialValue: string) => {
const [state, setState] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
if (state !== undefined) {
window.localStorage.setItem(key, JSON.stringify(state));
} else {
window.localStorage.removeItem(key);
}
} catch { }
}, [key, state]);
const setValue = useCallback((value: string) => {
setState(value);
}, [])
return [state, setValue];
};
export default useLocalStorage;

84
ui/lib/text.ts Normal file
View File

@ -0,0 +1,84 @@
export const FILTER_BY_COUNTRY = {
en: "Filter by country",
fr: "Filtrer par pays",
es: "Filtrar por país",
de: "Nach Land filtern",
it: "Filtra per paese",
pt: "Filtrar por país",
zh: "按国家筛选",
ja: "国でフィルタリング",
ru: "Фильтр по стране"
}
export const FILTER_BY_SPORT = {
en: "Filter by sport",
fr: "Filtrer par sport",
es: "Filtrar por deporte",
de: "Nach Sport filtern",
it: "Filtra per sport",
pt: "Filtrar por esporte",
zh: "按运动筛选",
ja: "スポーツでフィルタリング",
ru: "Фильтр по виду спорта"
};
export const GET_CALENDAR = {
en: "Get Calendar",
fr: "Obtenir le calendrier",
es: "Obtener calendario",
de: "Kalender abrufen",
it: "Ottieni calendario",
pt: "Obter calendário",
zh: "获取日历",
ja: "カレンダーを取得",
ru: "Получить календарь"
}
export const COPY = {
en: "Copy",
fr: "Copier",
es: "Copiar",
de: "Kopieren",
it: "Copia",
pt: "Copiar",
zh: "复制",
ja: "コピー",
ru: "Копировать"
}
export const COPY_SUCCESS = {
en: "Link copied to clipboard!",
fr: "Lien copié dans le presse-papiers !",
es: "¡Enlace copiado al portapapeles!",
de: "Link in die Zwischenablage kopiert!",
it: "Link copiato negli appunti!",
pt: "Link copiado para a área de transferência!",
zh: "链接已复制到剪贴板!",
ja: "リンクがクリップボードにコピーされました!",
ru: "Ссылка скопирована в буфер обмена!"
}
export const MADE_BY_FABRICE = {
en: "Made with ❤️ by Fabrice Lamant",
fr: "Fait avec ❤️ par Fabrice Lamant",
es: "Hecho con ❤️ por Fabrice Lamant",
de: "Hergestellt mit ❤️ von Fabrice Lamant",
it: "Realizzato con ❤️ da Fabrice Lamant",
pt: "Feito com ❤️ por Fabrice Lamant",
zh: "由 Fabrice Lamant 用 ❤️ 制作",
ja: "Fabrice Lamant によって❤️で作られました",
ru: "Сделано с ❤️ Фабрисом Ламантом"
}
export const NOT_AFFILIATED = {
en: "This webiste is not affiliated with the International Olympic Committee. All trademarks, logos and brand names are the property of their respective owners.",
fr: "Ce site web n'est pas affilié au Comité International Olympique. Toutes les marques, logos et noms de marque sont la propriété de leurs propriétaires respectifs.",
es: "Este sitio web no está afiliado al Comité Olímpico Internacional. Todas las marcas, logotipos y nombres comerciales son propiedad de sus respectivos dueños.",
de: "Diese Website ist nicht mit dem Internationalen Olympischen Komitee verbunden. Alle Marken, Logos und Markennamen sind Eigentum ihrer jeweiligen Besitzer.",
it: "Questo sito web non è affiliato al Comitato Olimpico Internazionale. Tutti i marchi, loghi e nomi di marca sono di proprietà dei rispettivi proprietari.",
pt: "Este site não é afiliado ao Comitê Olímpico Internacional. Todas as marcas, logotipos e nomes comerciais são propriedade de seus respectivos donos.",
zh: "本网站与国际奥林匹克委员会无关。所有商标、徽标和品牌名称均为其各自所有者的财产。",
ja: "このウェブサイトは国際オリンピック委員会とは提携していません。すべての商標、ロゴ、ブランド名はそれぞれの所有者の財産です。",
ru: "Этот веб-сайт не аффилирован с Международным олимпийским комитетом. Все торговые марки, логотипы и названия брендов являются собственностью их соответствующих владельцев."
}

7
ui/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

6650
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
ui/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@next/third-parties": "^16.0.10",
"next": "16.0.10",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"daisyui": "^5.5.13",
"eslint": "^9.39.2",
"eslint-config-next": "16.0.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
ui/postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 56.7 56.7" style="enable-background:new 0 0 56.7 56.7;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#606060;}
</style>
<g>
<title>background</title>
<rect x="-1" y="-1" class="st0" width="582" height="402"/>
</g>
<g>
<title>Layer 1</title>
<path class="st1" d="M41.8,30.5c-0.1-6.2,5.1-9.2,5.3-9.4c-2.9-4.2-7.4-4.8-9-4.9c-3.8-0.4-7.5,2.3-9.4,2.3c-1.9,0-4.9-2.2-8.1-2.1
c-4.2,0.1-8,2.4-10.2,6.2C6,30.1,9.3,41.2,13.5,47.3c2.1,3,4.5,6.3,7.8,6.2c3.1-0.1,4.3-2,8.1-2s4.8,2,8.1,2c3.4-0.1,5.5-3,7.5-6
c2.4-3.5,3.3-6.8,3.4-7C48.3,40.4,41.8,38,41.8,30.5z"/>
<path class="st1" d="M35.6,12.2c1.7-2.1,2.9-5,2.6-7.9c-2.5,0.1-5.5,1.6-7.2,3.7c-1.6,1.8-3,4.8-2.6,7.6
C31,15.9,33.9,14.3,35.6,12.2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#FBBC05;}
.st2{fill:#EA4335;}
.st3{fill:#34A853;}
.st4{fill:#4285F4;}
</style>
<g>
<rect class="st0" width="128" height="128"/>
<path class="st1" d="M27.6,64c0-4.2,0.7-8.1,1.9-11.9L7.9,35.6C3.7,44.2,1.4,53.8,1.4,64c0,10.2,2.4,19.8,6.6,28.3l21.6-16.5
C28.3,72.1,27.6,68.1,27.6,64"/>
<path class="st2" d="M65.5,26.2c9,0,17.2,3.2,23.6,8.4L107.7,16C96.3,6.1,81.8,0,65.5,0C40.1,0,18.4,14.5,7.9,35.6l21.6,16.5
C34.5,37,48.6,26.2,65.5,26.2"/>
<path class="st3" d="M65.5,101.8c-16.8,0-31-10.9-35.9-25.9L7.9,92.4C18.4,113.5,40.1,128,65.5,128c15.6,0,30.6-5.6,41.8-16
L86.7,96.2C81,99.9,73.7,101.8,65.5,101.8"/>
<path class="st4" d="M126.6,64c0-3.8-0.6-7.9-1.5-11.6H65.5v24.7h34.4c-1.7,8.4-6.4,14.9-13.1,19.1l20.5,15.8
C119,101.1,126.6,84.9,126.6,64"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg enable-background="new 0 0 2075 2499.8" viewBox="0 0 2075 2499.8" xmlns="http://www.w3.org/2000/svg"><path d="m0 2016.6v-1519.8l1344.4-496.8 730.6 233.7v2045.9l-730.6 220.3-1344.4-483.3 1344.4 161.8v-1769.2l-876.8 204.6v1198.3z" fill="#eb3c00"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#0072C6;}
</style>
<g>
<title>background</title>
<rect x="-1" y="-1" class="st0" width="582" height="402"/>
</g>
<g>
<title>Layer 1</title>
<path class="st1" d="M644.9,764.4V492.7c19.3,13.3,37.4,25.6,55.4,38c16.2,11.1,18.9,11,35.6-0.1c92.5-61.5,185-122.9,277.5-184.4
c2.8-1.9,5.7-3.6,9.8-6.1c0.3,4.2,0.8,7.4,0.8,10.5c0,118.2,0.1,236.4,0,354.7c0,39.3-19.9,59.1-59,59.1c-102.8,0-205.6,0-308.4,0
C652.9,764.4,649.2,764.4,644.9,764.4L644.9,764.4z"/>
<path class="st1" d="M645.6,216.9h10.5c108.6,0,217.1,0,325.7,0c19.4,0,34.4,11.9,40.5,30.5c2.7,8.2,0,12.2-6.7,16.6
c-84.1,56.3-168,112.9-252,169.4c-12,8.1-24.1,15.9-35.9,24.3c-6.4,4.6-12.2,4.8-18.8,0.5c-19.2-12.4-38.7-24.2-57.8-36.7
c-3.2-2.1-6.5-6.9-6.5-10.4c-0.3-63.5-0.2-127-0.1-190.5C644.5,219.8,644.9,219.1,645.6,216.9L645.6,216.9z"/>
<path class="st1" d="M596.7,1024C397.6,982.3,199.3,940.8,0.3,899.2v-13.1c0-252.5,0.1-505-0.3-757.5c0-10.5,2.8-14.1,13-16
c159.1-30.4,318.2-61.2,477.3-92C525.4,13.7,560.5,7,596.7,0L596.7,1024L596.7,1024z M438,511c-0.4-41.6-6.1-80-26.7-114.9
c-16.6-28.1-39-49.4-71.3-58.6c-62.8-17.9-122.8,11.5-152.4,75c-18.9,40.7-23,83.9-19.3,128.1c3.2,38.3,14.3,73.7,39,104
c49.1,60.2,138.4,60.6,188.2,0.8C428.4,605.8,437.4,558.7,438,511L438,511z"/>
<path class="st1" d="M232.9,511.9c-0.1-29.7,3.9-58.4,20.7-83.8c15.5-23.5,39-33.1,64.9-26.6c20.1,5,32.3,19.3,40.4,37.2
c13.8,30.7,16.2,63.3,12.1,95.9c-2.3,18.1-7.5,36.5-14.8,53.2c-9.5,21.6-27.8,34-52.2,34.5c-24.6,0.5-41.7-12.2-53.6-32.6
C236.3,565.8,232.9,539.2,232.9,511.9L232.9,511.9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1792 1792" style="enable-background:new 0 0 1792 1792;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#4A089F;}
</style>
<g>
<title>background</title>
<rect x="-1" y="-1" class="st0" width="582" height="402"/>
</g>
<g>
<title>Layer 1</title>
<path class="st1" d="M987,957l13,707c-41.3-7.3-76.3-11-105-11c-27.3,0-62.3,3.7-105,11l13-707c-26.7-46-82.8-144.5-168.5-295.5
S476.7,385.7,418,287S299,92.7,237,0c38.7,10,74.7,15,108,15c28.7,0,65.7-5,111-15c42,74,86.5,150.5,133.5,229.5
s102.7,171.2,167,276.5S867,687,895,733c24.7-40.7,61.2-99.8,109.5-177.5c48.3-77.7,87.5-141,117.5-190s65-107.7,105-176
S1302.7,58,1334,0c36,9.3,71.7,14,107,14c37.3,0,75.3-4.7,114-14c-18.7,26-38.7,55.5-60,88.5s-37.8,59.2-49.5,78.5
s-30.5,51.3-56.5,96s-42.3,72.7-49,84C1242.7,512.3,1125,715.7,987,957z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

34
ui/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}