1 Commits

Author SHA1 Message Date
Fabrice Lamant
c92a3ca57a gh-pages 2025-12-09 21:01:34 +01:00
47 changed files with 24 additions and 11402 deletions

View File

@@ -1,44 +0,0 @@
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

View File

@@ -1,19 +0,0 @@
name: Build
on:
push:
branches:
- main
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: sonarsource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

View File

@@ -1,27 +0,0 @@
# 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
View File

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

View File

@@ -1,3 +0,0 @@
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.

View File

@@ -1,9 +0,0 @@
# 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.

View File

@@ -1,21 +0,0 @@
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:

8
index.html Normal file
View File

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

8
medals.html Normal file
View File

@@ -0,0 +1,8 @@
<!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
View File

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

View File

@@ -1,16 +0,0 @@
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"]

View File

@@ -1,32 +0,0 @@
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}.json`;
};
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, "utf-8");
}
}

View File

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

View File

@@ -1,76 +0,0 @@
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",
},
},
);

View File

@@ -1,177 +0,0 @@
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 cleanLine(line: string): string {
if (line.length <= 75) {
return line;
}
const chunks: string[] = [];
let index = 0;
while (index < line.length) {
let chunk = line.slice(index, index + 75);
if (index > 0) {
chunk = " " + chunk.trim();
}
chunks.push(chunk);
index += 75;
}
return chunks.join("\r\n");
}
private generateICSFile(
sportKey: string | null,
nocKey: string | null,
): void {
this.debug(
"generateICSFile",
sportKey || "all-sports",
nocKey || "all-nocs",
);
let types = ["all-events", "medal-events", "gold-medal-events"];
if (nocKey) {
types = ["all-events"];
}
for (const type of types) {
for (const lang of this.calendar.languages) {
const pathSportKey = sportKey || "all-sports";
let pathCalendar = "calendar";
if (type != "all-events") {
pathCalendar = type;
} else if (nocKey) {
pathCalendar = nocKey;
}
const filepath = `./output/${lang.code.toLowerCase()}/${pathSportKey.toLowerCase()}/${pathCalendar.toLowerCase()}.ics`;
mkdirSync(filepath.split("/").slice(0, -1).join("/"), { recursive: true });
const titleComponents: string[] = [];
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: string[] = [];
lines.push("BEGIN:VCALENDAR");
lines.push("VERSION:2.0");
lines.push(
`PRODID:-//fabrice404//olympics-calendar//${lang.code}/${pathSportKey}/${pathCalendar}`,
);
lines.push(`X-WR-CALNAME:${title}`);
lines.push(`NAME:${title}`);
this.calendar.events
.filter((event) => {
if ((sportKey && event.sport !== sportKey) || event.sport === "CER") {
return false;
}
if (nocKey && !event.nocs.includes(nocKey)) {
return false;
}
if (
(type === "medal-events" && event.medal === "0") ||
(type === "gold-medal-events" && event.medal !== "1")
) {
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,
)!;
let description = `DESCRIPTION:${sport.name[lang.code]} - ${event.name[lang.code] || ""}`;
let summary = `SUMMARY:${event.name[lang.code] || ""}`;
if (event.competitors?.length === 2) {
const competitor1 = this.getCompetitor(event.competitors[0]!, lang.code);
const competitor2 = this.getCompetitor(event.competitors[1]!, lang.code);
if (competitor1 && competitor2) {
summary = `SUMMARY:${competitor1?.flag} ${competitor1.name} - ${competitor2?.name} ${competitor2.flag}`;
}
} else if (event.competitors?.length > 0) {
const competitors = event.competitors
.map((competitorId) => this.getCompetitor(competitorId, lang.code))
.map((competitor) => `\\n${competitor.flag} ${competitor.name}`).join("");
description += `${competitors}`;
}
lines.push(summary);
lines.push(this.cleanLine(description));
lines.push("END:VEVENT");
});
lines.push("END:VCALENDAR");
if (lines.length <= 10) {
this.debug("Skipping empty ICS file:", filepath);
} else {
writeFileSync(filepath, lines.join("\r\n"), "utf-8");
}
}
}
}
private getCompetitor(competitorId: string, lang: string) {
if (competitorId.startsWith("team:")) {
const team = this.calendar.nocs.find(noc => noc.key === competitorId.replace("team:", ""));
return {
noc: team?.key,
name: team?.name[lang] || team?.key || competitorId,
flag: getFlag(team?.key || ""),
};
}
const competitor = this.calendar.competitors.find(comp => comp.code === competitorId)!;
return {
noc: competitor.noc,
name: competitor.name,
flag: getFlag(competitor.noc),
};
}
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);
});
}
}

View File

@@ -1,22 +0,0 @@
import { removeSync } from "fs-extra/esm";
import nodeCron from "node-cron";
import { Scraper } from "./scraper";
const main = () => {
nodeCron.schedule("*/10 * * * *", () => {
removeSync("./cache/schedules");
const scraper = new Scraper();
scraper.scrape();
});
nodeCron.schedule("0 0 * * *", () => {
removeSync("./cache/disciplinesevents");
removeSync("./cache/nocs");
});
const scraper = new Scraper();
scraper.scrape();
};
main();

View File

@@ -1,212 +0,0 @@
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()] || "🏳️";
};

View File

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

2311
scraper/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
{
"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.6",
"debug": "^4.4.3",
"fs-extra": "^11.3.4",
"node-cron": "^4.2.1",
"nodemon": "^3.1.14",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@types/debug": "^4.1.12",
"@types/node": "^24.11.0",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^4.15.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
}
}

View File

@@ -1,219 +0,0 @@
import Debug from "debug";
import { writeFileSync } from "fs";
import { Cache } from "./cache";
import { ICSGenerator } from "./ics-generator";
import { Calendar, Event, Language, Sport, NOC, Competitor } from "./types";
const proxy = process.env.HTTP_PROXY || "";
export class Scraper {
private readonly cache = new Cache();
private readonly competitors: Competitor[] = [];
private readonly debug = Debug("olympics-calendar:scraper");
private readonly events: Event[] = [];
private readonly languages: Language[] = [
{ code: "en", name: "English", code3: "ENG" },
{ code: "it", name: "Italiano", code3: "ITA" },
{ code: "fr", name: "Français", code3: "FRA" },
{ code: "de", name: "Deutsch", code3: "DEU" },
{ code: "pt", name: "Português", code3: "POR" },
{ code: "es", name: "Español", code3: "SPA" },
{ code: "ja", name: "日本語", code3: "JPN" },
{ code: "zh", name: "中文", code3: "CHI" },
{ code: "hi", name: "हिन्दी", code3: "HIN" },
{ code: "ko", name: "한국어", code3: "KOR" },
{ code: "ru", name: "Русский", code3: "RUS" },
];
private readonly nocs: NOC[] = [];
private readonly sports: Sport[] = [];
private dateToUtcString(dateString: string): string {
const date = new Date(dateString);
const utc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());
return new Date(utc).toISOString();
}
private async getJSONData(url: string, cacheKey: string): Promise<any> {
this.debug(`getJSONData: url=${url}`);
if (!this.cache.has(cacheKey)) {
const response = await fetch(`${proxy}${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",
"accept-language": "en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7",
"cache-control": "max-age=0",
"priority": "u=0, i",
"sec-ch-ua": "\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"macOS\"",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
},
"body": null,
"method": "GET"
});
const result = await response.json();
this.cache.set(cacheKey, JSON.stringify(result, null, 2));
}
return JSON.parse(this.cache.get(cacheKey)!);
}
private saveCalendar(): void {
this.debug("saveCalendar");
const calendar = this.getCalendar();
writeFileSync("./output/calendar.json", JSON.stringify(calendar, null, 2), "utf-8");
}
private async scrapeEvents(): Promise<void> {
this.debug("scrapeEvents");
for (const lang of this.languages) {
this.debug(`Scraping events: ${lang.code}`);
for (let i = 3; i <= 23; i++) {
const url = `https://www.olympics.com/wmr-owg2026/schedules/api/${lang.code3}/schedule/lite/day/2026-02-${i.toString().padStart(2, "0")}`;
const data = await this.getJSONData(url, `schedules/day/2026-02-${i.toString().padStart(2, "0")}/${lang.code3}`);
for (const event of data.units) {
if (!event.endDate) {
continue;
}
const { id: key } = event;
if (!this.events.some((e) => e.key === key)) {
this.events.push({
key,
sport: event.disciplineCode,
start: this.dateToUtcString(event.startDate),
end: this.dateToUtcString(event.endDate),
medal: event.medalFlag.toString(),
name: {},
location: {},
nocs: [],
competitors: [],
});
}
const calendarEvent = this.events.find((e) => e.key === key)!;
calendarEvent.name[lang.code] = event.eventUnitName;
calendarEvent.location[lang.code] = event.venueDescription;
if (event.competitors) {
for (const competitor of event.competitors) {
if (competitor.code === "TBD") {
continue;
}
const { code, name, noc, competitorType } = competitor;
if (!calendarEvent.nocs.some((n) => n === noc)) {
calendarEvent.nocs.push(noc);
}
if (competitorType) {
if (!calendarEvent.competitors.some((c) => c === code)) {
calendarEvent.competitors.push(code);
}
this.setCompetitor(code, noc, name);
this.setNoc(noc, "", lang.code);
} else {
const key = `team:${noc}`;
if (!calendarEvent.competitors.some((c) => c === key)) {
calendarEvent.competitors.push(key);
}
this.setNoc(noc, name, lang.code);
}
}
}
}
}
}
}
private async scrapeNOCs(): Promise<void> {
this.debug("scrapeNOCs");
for (const lang of this.languages) {
this.debug(`Scraping NOCs: ${lang.code}`);
const url = `https://www.olympics.com/wmr-owg2026/info/api/${lang.code3}/nocs`;
const data = await this.getJSONData(url, `nocs/${lang.code3}`);
for (const noc of this.nocs) {
const found = data.nocs.find((n) => n.id === noc.key);
this.setNoc(found.id, found.name, lang.code);
}
}
}
private async scrapeSports(): Promise<void> {
this.debug("scrapeSports");
for (const lang of this.languages) {
this.debug(`Scraping sports: ${lang.code}`);
const url = `https://www.olympics.com/wmr-owg2026/info/api/${lang.code3}/disciplinesevents`;
const data = await this.getJSONData(url, `disciplinesevents/${lang.code3}`);
for (const discipline of data.disciplines) {
const { id, name } = discipline;
if (!this.sports.some((s) => s.key === id)) {
this.sports.push({ key: id, name: {}, order: 0 });
}
const sport = this.sports.find((s) => s.key === id)!;
sport.name[lang.code] = name;
}
}
this.sports
.toSorted((a, b) => (a.order < b.order ? -1 : 1))
.forEach((sport, index) => {
sport.order = index + 1;
});
}
private setCompetitor(code: string, noc: string, name: string): void {
if (!code)
return;
if (!this.competitors.some((c) => c.code === code)) {
this.competitors.push({ code, noc, name });
}
}
private setNoc(key: string, name: string, langCode: string): void {
if (!key)
return;
if (!this.nocs.some((n) => n.key === key)) {
this.nocs.push({ key, name: {} });
}
const noc = this.nocs.find((n) => n.key === key)!;
noc.name[langCode] = name;
}
public getCalendar(): Calendar {
return {
languages: this.languages,
sports: this.sports,
nocs: this.nocs,
competitors: this.competitors,
events: this.events,
};
}
public async scrape(): Promise<void> {
this.debug("scrape");
await this.scrapeSports();
await this.scrapeEvents();
await this.scrapeNOCs();
this.saveCalendar();
new ICSGenerator(this.getCalendar()).generate();
}
}

View File

@@ -1,42 +0,0 @@
{
// 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
"noImplicitAny": false,
// "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
}
}

46
scraper/types.d.ts vendored
View File

@@ -1,46 +0,0 @@
export interface MultilingualString {
[key: string]: string;
}
export interface Language {
code: string;
name: string;
code3: string;
}
export interface Sport {
key: string;
name: MultilingualString;
order: number;
}
export interface NOC {
key: string;
name: MultilingualString;
}
export interface Competitor {
noc: string;
code: string;
name: string;
}
export interface Event {
key: string;
start: string;
end: string;
sport: string;
medal: "0" | "1" | "3";
name: MultilingualString;
location: MultilingualString;
nocs: string[];
competitors: string[];
}
export interface Calendar {
languages: Language[];
sports: Sport[];
nocs: NOC[];
competitors: Competitor[];
events: Event[];
}

View File

@@ -1 +0,0 @@
sonar.projectKey=fabrice404_olympics-calendar_55a1e8d3-edef-4319-a482-364594aa26ef

8
today.html Normal file
View File

@@ -0,0 +1,8 @@
<!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
View File

@@ -1,43 +0,0 @@
# 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/

View File

@@ -1,21 +0,0 @@
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

@@ -1,573 +0,0 @@
/* eslint-disable @next/next/no-html-link-for-pages */
"use client";
import { loadSchedule } from "../../lib/data";
import { use, useEffect, useState } from "react";
import Flag from "../flag";
import { ALL_EVENTS, COPY, COPY_SUCCESS, FILTER_BY_COUNTRY, FILTER_BY_EVENT_TYPE, FILTER_BY_SPORT, GOLD_MEDAL_EVENTS, LANGUAGE, MADE_BY_FABRICE, MEDAL_EVENTS, NOT_AFFILIATED, NO_EVENT_FOR_FILTERS } from "../../lib/text";
import useLocalStorage from "@/lib/local-storage";
import { GoogleAnalytics } from "@next/third-parties/google";
import { Google_Sans, SUSE_Mono } from "next/font/google";
import { permanentRedirect, usePathname } from "next/navigation";
const googleSans = Google_Sans({
variable: "--font-google-sans",
subsets: ["latin"],
fallback: ["sans-serif"],
});
const suseMono = SUSE_Mono({
variable: "--font-suse-mono",
subsets: ["latin"],
fallback: ["sans-serif"],
});
export interface MultilingualString {
[key: string]: string;
}
export interface Language {
code: string;
name: string;
code3: string;
}
export interface Sport {
key: string;
name: MultilingualString;
order: number;
}
export interface NOC {
key: string;
name: MultilingualString;
}
export interface Competitor {
noc: string;
code: string;
name: string;
}
export interface Event {
key: string;
start: string;
end: string;
sport: string;
medal: "0" | "1" | "3";
name: MultilingualString;
location: MultilingualString;
nocs: string[];
competitors: string[];
}
export interface Calendar {
languages: Language[];
sports: Sport[];
nocs: NOC[];
competitors: Competitor[];
events: Event[];
}
const COLORS = ['azzurro', 'giallo', 'rosa', 'rosso', 'verde', 'viola'];
const EVENT_TYPE_ALL = "all";
const EVENT_TYPE_MEDAL = "medal-events";
const EVENT_TYPE_GOLD_MEDAL = "gold-medal-events";
const EVENT_TYPES = [{
key: EVENT_TYPE_ALL.toUpperCase(),
name: ALL_EVENTS
}, {
key: EVENT_TYPE_MEDAL.toUpperCase(),
name: MEDAL_EVENTS,
}, {
key: EVENT_TYPE_GOLD_MEDAL.toUpperCase(),
name: GOLD_MEDAL_EVENTS,
}]
const DEFAULT_NOC = "world";
const DEFAULT_SPORT = "all-sports";
const DEFAULT_EVENT_TYPE = EVENT_TYPE_ALL;
export default function Home({
params,
}: {
params: Promise<{ slug: string }>
}) {
const qs = typeof window !== 'undefined' ? window.location.search ? new URLSearchParams(window.location.search) : new URLSearchParams() : new URLSearchParams();
const { slug } = use(params);
const pathname = usePathname();
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 getParams = () => ({
noc: slug?.length ? slug[0].toLowerCase() : DEFAULT_NOC,
sport: slug?.length >= 2 ? slug[1].toLowerCase() : DEFAULT_SPORT,
type: slug?.length >= 3 ? slug[2].toLowerCase() : DEFAULT_EVENT_TYPE,
})
const generateLink = ({ noc, sport, type }: { noc?: string; sport?: string; type?: string }) => {
const { noc: newNOC, sport: newSport, type: newType } = getParams();
if (type && type !== DEFAULT_EVENT_TYPE) {
return `/${noc || newNOC}/${sport || newSport}/${type}`.toLowerCase();
}
return `/${noc || newNOC}/${sport || newSport}`.toLowerCase();
}
const generateCalendarLink = () => {
const host = typeof window !== 'undefined' ? window.location.host : '';
const { noc, sport, type } = getParams();
if (type !== DEFAULT_EVENT_TYPE) {
return `http://${host}/api/data/${language}/${sport}/${type}.ics`;
}
return `http://${host}/api/data/${language}/${sport}/${noc === DEFAULT_NOC ? "calendar" : noc}.ics`;
};
const getColor = (i: number) => COLORS[i % COLORS.length];
useEffect(() => {
let selectedNOC = DEFAULT_NOC;
let selectedSport = DEFAULT_SPORT;
if (qs.get('noc')) {
selectedNOC = qs.get('noc')!.toLowerCase();
} else if (slug?.length >= 1) {
selectedNOC = getParams().noc.toLowerCase();
}
if (qs.get('sport')) {
selectedSport = qs.get('sport')!.toLowerCase();
} else if (slug?.length >= 2) {
selectedSport = getParams().sport.toLowerCase();
}
let expectedUrl = `/${selectedNOC}/${selectedSport}`;
if (getParams().type !== DEFAULT_EVENT_TYPE) {
expectedUrl = `/${selectedNOC}/${selectedSport}/${getParams().type}`;
}
if (pathname !== expectedUrl) {
permanentRedirect(expectedUrl);
}
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 { noc, sport, type } = getParams();
if (
noc !== DEFAULT_NOC &&
!event.nocs.includes(noc.toUpperCase())
) {
visible = false;
}
if (
sport !== DEFAULT_SPORT &&
event.sport !== sport.toUpperCase()
) {
visible = false;
}
if (type !== DEFAULT_EVENT_TYPE) {
if (
(type === "medal-events" && event.medal === "0") ||
(type === "gold-medal-events" && event.medal !== "1")
) {
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(() => {
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')
}
const events = data.events.filter(event => filter(event));
let main = (
<div>
<div className="text-center pt-10 mb-100">
{translate(NO_EVENT_FOR_FILTERS)}
</div>
</div>
)
if (events.length) {
main = (
<div>
<div className="text-center pt-6">
<span className="input md: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>
<div className="pt-2 md:pt-0 lg:inline-block">
<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>
</div>
{
events
.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');
let titleColor = "fg-main";
if (event.medal === '1') {
titleColor = "bg-gold";
} else if (event.medal === '3') {
titleColor = "bg-bronze";
}
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(event.start).toLocaleDateString(language, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</h2>
</div>
);
}
// eslint-disable-next-line react-hooks/immutability
lastDay = day;
const getCompetitor = (competitorId: string) => {
if (competitorId.startsWith("team:")) {
const team = data.nocs.find(noc => noc.key === competitorId.replace("team:", ""));
return { noc: team!.key, name: translate(team!.name) };
}
const competitor = data.competitors.find(comp => comp.code === competitorId);
return { noc: competitor!.noc, name: competitor!.name };
};
let competitors = <></>;
if (event.competitors.length > 0) {
competitors = (
<ul>
{
event.competitors
.filter(Boolean)
.map((competitorId) => {
const competitor = getCompetitor(competitorId);
if (!competitor) return null;
const noc = data.nocs.find(noc => noc.key === competitor.noc);
if (event.competitors.length === 2 || getParams().noc.toUpperCase() === competitor.noc) {
return (
<li key={competitorId}>
<Flag iso3={competitor.noc} name={translate(noc!.name)} /> {competitor.name}
</li>
);
}
})
}
</ul>
);
}
let nocs = <></>;
if (event.nocs.length > 0 && event.competitors.length !== 2) {
nocs = (
<div>
{
event.nocs.filter(Boolean).toSorted((a, b) => a.localeCompare(b)).map((nocKey) => {
const noc = data.nocs.find(noc => noc.key === nocKey);
if (!noc) return null;
return (
<Flag key={nocKey} iso3={noc.key} name={translate(noc.name)} size="sm" />
);
})
}
</div>
);
}
return (
<div key={event.key}>
{dayHeader}
<div className="flex p-2 m-2 my-4 bg-white rounded-md md:w-3/4 md:mx-auto">
<div className={`flex-none fg-${getColor(i)} ${suseMono.className} text-right font-bold align-top inline-block tabular-nums pr-2`}>
<span className="text-xl">{startHours}:{startMinutes}</span>
<div className="text-xs">{endHours}:{endMinutes}</div>
</div>
<div className="shrink align-top inline-block text-black pl-2 border-l border-slate-900/10">
<div className="font-bold">
{translate(data.sports.find(sport => sport.key === event.sport)?.name || {})}
</div>
<div className={`inline-block font-bold text-sm ${titleColor}`}>{translate(event.name)}</div>
{competitors}
{nocs}
</div>
</div>
</div>
)
})
}
</div>
)
}
const header = (
<div className="navbar bg-main">
<div className={`flex-1 ${googleSans.className}`}>
<a href="/world/all-sports" className="text-xl font-bold">Olympics Calendar</a>
</div>
<div className="flex">
<label htmlFor="my-drawer-5" className="drawer-button btn btn-main btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block h-5 w-5 stroke-current"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path> </svg>
</label>
</div>
</div>
);
const footer = (
<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>
);
let cookie = <></>;
if (cookieConsent === 'true') {
cookie = (
<GoogleAnalytics gaId="G-SLBLJRE0CM" />
);
} else if (cookieConsent === 'null') {
cookie = (
<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>
);
}
return (
<div>
<div className="drawer drawer-end">
<input id="my-drawer-5" type="checkbox" className="drawer-toggle" />
<div className="drawer-content">
{header}
{main}
{footer}
{cookie}
</div>
<div className="drawer-side">
<label htmlFor="my-drawer-5" aria-label="close sidebar" className="drawer-overlay"></label>
<div className="menu bg-base-200 min-h-full w-80 p-4">
<div>
<span className="font-bold">{translate(FILTER_BY_COUNTRY)}</span>
{getParams().noc !== DEFAULT_NOC && (
<div className="my-1">
<a href={generateLink({ noc: DEFAULT_NOC })} className="btn bg-white btn-sm">
<span className="font-bold text-red-400">X</span> {translate(data.nocs.find(noc => noc.key === getParams().noc.toUpperCase())!.name)}
</a>
</div>
)}
<div className="bg-white h-[200px] max-h-[200px] overflow-y-scroll">
<ul>
{data.nocs.toSorted((a, b) => translate(a.name).localeCompare(translate(b.name))).map(noc => {
if (noc.key === getParams().noc.toUpperCase()) {
return (
<li key={noc.key}>
<a href={generateLink({ noc: DEFAULT_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>
</div>
<div className="mt-2 pt-2 border-t-1 border-slate-300">
<span className="font-bold">{translate(FILTER_BY_SPORT)}</span>
{getParams().sport !== DEFAULT_SPORT && (
<div className="my-1">
<a href={generateLink({ sport: DEFAULT_SPORT })} className="btn bg-white btn-sm">
<span className="font-bold text-red-400">X</span> {translate(data.sports.find(sport => sport.key === getParams().sport.toUpperCase())!.name)}
</a>
</div>
)}
<div className="bg-white h-[200px] max-h-[200px] overflow-y-scroll">
<ul>
{data.sports.toSorted((a, b) => translate(a.name).localeCompare(translate(b.name))).map(sport => {
if (sport.key === getParams().sport.toUpperCase()) {
return (
<li key={sport.key}>
<a href={generateLink({ sport: DEFAULT_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>
</div>
<div className="mt-2 pt-2 border-t-1 border-slate-300">
<span className="font-bold">{translate(FILTER_BY_EVENT_TYPE)}</span>
{getParams().type !== DEFAULT_EVENT_TYPE && (
<div className="my-1">
<a href={generateLink({ type: DEFAULT_EVENT_TYPE })} className="btn bg-white btn-sm">
<span className="font-bold text-red-400">X</span> {translate(EVENT_TYPES.find(type => type.key === getParams().type.toUpperCase())?.name || {})}
</a>
</div>
)}
<div className="bg-white h-[100px] max-h-[100px] overflow-y-scroll">
<ul>
{EVENT_TYPES.map(type => {
if (type.key === getParams().type.toUpperCase()) {
return (
<li key={type.key}>
<a href={generateLink({ type: DEFAULT_EVENT_TYPE })}><div aria-label="success" className="status status-success"></div> {translate(type.name)}</a>
</li>
)
}
return (
<li key={type.key}>
<a href={generateLink({ type: type.key })}>{translate(type.name)}</a>
</li>
)
})}
</ul>
</div>
</div>
<div className="mt-2 pt-2 border-t-1 border-slate-300">
<span className="font-bold">{translate(LANGUAGE)}</span>
<div className="bg-white h-[200px] max-h-[200px] overflow-y-scroll">
<ul>
{data.languages.map(lang => {
if (lang.code === language) {
return (
<li key={lang.code}>
<a onClick={() => setLanguage(lang.code)}><div aria-label="success" className="status status-success"></div> {lang.code.toUpperCase()} - {lang.name}</a>
</li>
);
}
return (
<li key={lang.code}>
<a onClick={() => setLanguage(lang.code)}>{lang.code.toUpperCase()} - {lang.name}</a>
</li>
);
})}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div>Loading</div>
);
}

View File

@@ -1,24 +0,0 @@
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, "utf-8");
if (!content) throw new Error()
return new NextResponse(content, { status: 200, headers: { "Content-Type": slug?.join("/").endsWith(".ics") ? "text/calendar" : "text/json" } });
} catch (ex) {
console.log(ex);
return new NextResponse("File not found", { status: 404 });
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,269 +0,0 @@
export default function Flag({ iso3, name, size }: { iso3: string; name: string, size?: "sm" }) {
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();
const className = size === "sm" ? 'inline-block h-3 mr-1' : 'inline-block h-5 mr-1 border-1 border-gray-300';
return <img
alt={name}
className={className}
src={`https://gstatic.olympics.com/s3/noc/oly/3x2/${iso3.toUpperCase()}.png`}
title={name}
/>
}

View File

@@ -1,69 +0,0 @@
@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;
}

View File

@@ -1,40 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, DM_Sans } from "next/font/google";
import "./globals.css";
import Head from "next/head";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
fallback: ["sans-serif"],
});
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
fallback: ["sans-serif"],
});
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} ${dmSans.className} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -1,18 +0,0 @@
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;

View File

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

View File

@@ -1,30 +0,0 @@
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;

View File

@@ -1,156 +0,0 @@
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: "Этот веб-сайт не аффилирован с Международным олимпийским комитетом. Все торговые марки, логотипы и названия брендов являются собственностью их соответствующих владельцев."
}
export const NO_EVENT_FOR_FILTERS = {
en: "There's no event for the selected team and/or sport, please adjust your filters.",
fr: "Il n'y a pas d'événement pour l'équipe et/ou le sport sélectionné, veuillez ajuster vos filtres.",
es: "No hay ningún evento para el equipo y/o deporte seleccionado, por favor ajuste sus filtros.",
de: "Es gibt kein Ereignis für das ausgewählte Team und/oder den ausgewählten Sport, bitte passen Sie Ihre Filter an.",
it: "Non ci sono eventi per la squadra e/o lo sport selezionato, si prega di regolare i filtri.",
pt: "Não há evento para a equipe e/ou esporte selecionado, por favor ajuste seus filtros.",
zh: "所选团队和/或运动没有事件,请调整您的过滤器。",
ja: "選択したチームおよび/またはスポーツのイベントはありません。フィルターを調整してください。",
ru: "Для выбранной команды и/или вида спорта нет событий, пожалуйста, отрегулируйте ваши фильтры."
}
export const LANGUAGE = {
en: "Language",
fr: "Langue",
es: "Idioma",
de: "Sprache",
it: "Lingua",
pt: "Idioma",
zh: "语言",
ja: "言語",
ru: "Язык",
}
export const FILTER_BY_EVENT_TYPE = {
en: "Filter by event type",
fr: "Filtrer par type d'événement",
es: "Filtrar por tipo de evento",
de: "Nach Veranstaltungstyp filtern",
it: "Filtra per tipo di evento",
pt: "Filtrar por tipo de evento",
zh: "按事件类型筛选",
ja: "イベントタイプでフィルタリング",
ru: "Фильтр по типу события"
}
export const ALL_EVENTS = {
en: "All events",
fr: "Tous les événements",
es: "Todos los eventos",
de: "Alle Veranstaltungen",
it: "Tutti gli eventi",
pt: "Todos os eventos",
zh: "所有事件",
ja: "すべてのイベント",
ru: "Все события"
};
export const MEDAL_EVENTS = {
en: "Medal events",
fr: "Épreuves de médailles",
es: "Eventos de medallas",
de: "Medaillenveranstaltungen",
it: "Eventi di medaglia",
pt: "Eventos de medalha",
zh: "奖牌赛事",
ja: "メダルイベント",
ru: "Медальные события"
};
export const GOLD_MEDAL_EVENTS = {
en: "Gold medal events",
fr: "Épreuves de médailles d'or",
es: "Eventos de medallas de oro",
de: "Goldmedaillenveranstaltungen",
it: "Eventi di medaglia d'oro",
pt: "Eventos de medalha de ouro",
zh: "金牌赛事",
ja: "金メダルイベント",
ru: "Золотые медальные события"
};

View File

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

6641
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"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.1.6",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"daisyui": "^5.5.19",
"eslint": "^9.39.3",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

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

View File

@@ -1,21 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,23 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 256 B

View File

@@ -1,29 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,21 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,34 +0,0 @@
{
"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"]
}