mirror of
https://github.com/fabrice404/olympics-calendar.git
synced 2025-12-13 06:39:47 +00:00
Compare commits
27 Commits
cb06f7d6d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4a94da9da | |||
| 1df247b875 | |||
| 1b6ebea812 | |||
| ce965e7962 | |||
| aa91cbc05b | |||
| 57c7d127fc | |||
| a5d5c1eee8 | |||
| b9a508da19 | |||
| b1651fe7e9 | |||
| 7380235ffb | |||
| 41f153f856 | |||
| 747bcc9ae3 | |||
| 90d74bbdec | |||
| f2eb5675b4 | |||
| 1e97eb5c77 | |||
| 7a64054ffc | |||
| c2d8727733 | |||
| f869a81ff1 | |||
| a9d5b1d58e | |||
| c8332aea35 | |||
| fef4690b0b | |||
| 3015fc2e78 | |||
| dcec1e8898 | |||
| bdf47b3957 | |||
| e2b6c42f01 | |||
| 5074fd5155 | |||
| 14c99c6252 |
23
.github/dependabot.yml
vendored
23
.github/dependabot.yml
vendored
@ -2,7 +2,28 @@ version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
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:
|
||||
|
||||
46
.github/workflows/publish.yml
vendored
46
.github/workflows/publish.yml
vendored
@ -1,27 +1,27 @@
|
||||
name: Auto update calendar
|
||||
# name: Auto update calendar
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: "*/10 * * * *"
|
||||
workflow_dispatch:
|
||||
# on:
|
||||
# # schedule:
|
||||
# # - cron: "*/10 * * * *"
|
||||
# workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
# jobs:
|
||||
# run:
|
||||
# runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# 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 }}"
|
||||
# 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
16
.vscode/settings.json
vendored
Normal 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
3
LICENSE
Normal 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.
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
|
||||
0
cache/.gitkeep
vendored
0
cache/.gitkeep
vendored
21
docker-compose.yaml
Normal file
21
docker-compose.yaml
Normal 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:
|
||||
2
scraper/.gitignore
vendored
Normal file
2
scraper/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
cache/
|
||||
output/
|
||||
16
scraper/Dockerfile
Normal file
16
scraper/Dockerfile
Normal 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"]
|
||||
@ -1,30 +1,32 @@
|
||||
import Debug from "debug";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const debug = Debug(`olympics-calendar:cache`);
|
||||
export class Cache {
|
||||
private debug = Debug("olympics-calendar:cache");
|
||||
|
||||
const cachePath = (key: string): string => {
|
||||
return `../cache/${key}`;
|
||||
}
|
||||
private cachePath = (key: string): string => {
|
||||
return `./cache/${key}.cached`;
|
||||
};
|
||||
|
||||
export const get = (key: string): string | null => {
|
||||
debug(`get: key=${key}`);
|
||||
const path = cachePath(key);
|
||||
if (existsSync(path)) {
|
||||
return readFileSync(path, "utf-8");
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const has = (key: string): boolean => {
|
||||
debug(`has: key=${key}`);
|
||||
const path = cachePath(key);
|
||||
return existsSync(path);
|
||||
}
|
||||
public has(key: string): boolean {
|
||||
this.debug(`has: key=${key}`);
|
||||
const path = this.cachePath(key);
|
||||
return existsSync(path);
|
||||
}
|
||||
|
||||
export const set = (key: string, data: string): void => {
|
||||
debug(`set: key=${key}`);
|
||||
const path = cachePath(key);
|
||||
mkdirSync(path.split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
writeFileSync(path, data);
|
||||
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
8
scraper/dev.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Scraper } from "./scraper";
|
||||
|
||||
const main = async () => {
|
||||
const scraper = new Scraper();
|
||||
await scraper.scrape();
|
||||
};
|
||||
|
||||
main();
|
||||
@ -1,26 +1,76 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import eslint from "@eslint/js";
|
||||
import perfectionist from "eslint-plugin-perfectionist";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
|
||||
export default [
|
||||
export default defineConfig(
|
||||
globalIgnores(["./dist/**", "./node_modules/**"]),
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
sourceType: "commonjs",
|
||||
plugins: {
|
||||
perfectionist,
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"@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
120
scraper/ics-generator.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
118
scraper/index.ts
118
scraper/index.ts
@ -1,118 +1,12 @@
|
||||
import Debug from "debug";
|
||||
import nodeCron from "node-cron";
|
||||
|
||||
import * as cache from "./cache";
|
||||
|
||||
const baseUrl = "https://www.olympics.com";
|
||||
const basePath = "/milano-cortina-2026/schedule/overview";
|
||||
|
||||
const debug = Debug(`olympics-calendar:index`);
|
||||
|
||||
const getScheduleOverview = async (language: string) => {
|
||||
debug(`getScheduleOverview: language=${language}`);
|
||||
|
||||
const scheduleOverviewKey = `${language}/schedule-overview`;
|
||||
|
||||
if (!cache.has(scheduleOverviewKey)) {
|
||||
debug(`Fetching ${baseUrl}/${language}${basePath}`);
|
||||
const response = await fetch(`${baseUrl}/${language}/${basePath}`);
|
||||
const page = await response.text();
|
||||
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");
|
||||
}
|
||||
const data = dataMatch[1];
|
||||
cache.set(scheduleOverviewKey, JSON.stringify(JSON.parse(data), null, 2));
|
||||
}
|
||||
|
||||
const scheduleOverview = JSON.parse(cache.get(scheduleOverviewKey)!);
|
||||
return scheduleOverview;
|
||||
};
|
||||
|
||||
const getScheduleSport = async (language: string, sportCode: string) => {
|
||||
debug(`getScheduleSport: language=${language}, sportCode=${sportCode}`);
|
||||
|
||||
const scheduleSportKey = `${language}/${sportCode}`;
|
||||
|
||||
if (!cache.has(scheduleSportKey)) {
|
||||
debug(`Fetching ${baseUrl}/${language}/milano-cortina-2026/schedule/${sportCode}`);
|
||||
const response = await fetch(`${baseUrl}/${language}/milano-cortina-2026/schedule/${sportCode}`);
|
||||
const page = await response.text();
|
||||
const dataMatch = page.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
if (!dataMatch) {
|
||||
return null;
|
||||
debug(`No data found for sportCode=${sportCode} in language=${language}`);
|
||||
}
|
||||
const data = dataMatch[1];
|
||||
cache.set(scheduleSportKey, JSON.stringify(JSON.parse(data), null, 2));
|
||||
}
|
||||
|
||||
const scheduleSport = JSON.parse(cache.get(scheduleSportKey)!);
|
||||
return scheduleSport;
|
||||
}
|
||||
import { Scraper } from "./scraper";
|
||||
|
||||
const main = async () => {
|
||||
const overview = await getScheduleOverview("en");
|
||||
const languages = overview.props.pageProps.page.template.properties.header.mainNav.languages
|
||||
.filter((lang: any) => lang.link.match(/\/milano-cortina-2026\/schedule\/overview$/))
|
||||
.map((lang: any) => ({
|
||||
code: lang.lang,
|
||||
name: lang.label,
|
||||
}))
|
||||
|
||||
const sports: any = [];
|
||||
const events: any[] = [];
|
||||
|
||||
for (const lang of languages) {
|
||||
const scheduleOverview = await getScheduleOverview(lang.code);
|
||||
|
||||
const disciplines = scheduleOverview.props.pageProps.page.items
|
||||
.find((item: any) => item.type === "module" && item.name === "scheduleGrid")
|
||||
.data.disciplines;
|
||||
|
||||
for (const discipline of disciplines) {
|
||||
const key = discipline.disciplineCode.toLowerCase();
|
||||
if (key !== "cer") {
|
||||
if (sports.find((s: any) => s.key === key) == null) {
|
||||
sports.push({ key, name: {}, order: -1 })
|
||||
}
|
||||
const sport = sports.find((s: any) => s.key === key)
|
||||
sport.name[lang.code] = discipline.description;
|
||||
sport.order = discipline.order;
|
||||
|
||||
const scheduleSport = await getScheduleSport(lang.code, sport.key);
|
||||
|
||||
const scheduleList = scheduleSport.props.pageProps.page.items.find((item: any) => item.type === "module" && item.name === "scheduleList").data.schedules.map((schedule: any) => schedule.units).flat()
|
||||
|
||||
for (const scheduleListElement of scheduleList) {
|
||||
if (events.find(e => e.key === scheduleListElement.unitCode) == null) {
|
||||
events.push({
|
||||
key: scheduleListElement.unitCode,
|
||||
start: scheduleListElement.startDateTimeUtc,
|
||||
end: scheduleListElement.endDateTimeUtc,
|
||||
isTraining: scheduleListElement.isTraining,
|
||||
medal: scheduleListElement.medal,
|
||||
name: {},
|
||||
})
|
||||
}
|
||||
const event = events.find(e => e.key === scheduleListElement.unitCode);
|
||||
event.name[lang.code] = scheduleListElement.description;
|
||||
|
||||
if (scheduleListElement.match) {
|
||||
if (event.match == null) {
|
||||
event.match = {
|
||||
team1: { key: scheduleListElement.match.team1.teamCode.replace(/[^A-Z]/gi, ''), name: {} },
|
||||
team2: { key: scheduleListElement.match.team2.teamCode.replace(/[^A-Z]/gi, ''), name: {} },
|
||||
};
|
||||
}
|
||||
event.match.team1.name[lang.code] = (scheduleListElement.match.team1.description || '').replace(/\,/gi, '');
|
||||
event.match.team2.name[lang.code] = (scheduleListElement.match.team2.description || '').replace(/\,/gi, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.set('calendar.json', JSON.stringify({ languages, sports, events }));
|
||||
nodeCron.schedule("* * * * *", async () => {
|
||||
const scraper = new Scraper();
|
||||
await scraper.scrape();
|
||||
});
|
||||
};
|
||||
|
||||
main();
|
||||
|
||||
212
scraper/nocs.ts
Normal file
212
scraper/nocs.ts
Normal 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()] || "🏳️";
|
||||
};
|
||||
@ -3,7 +3,7 @@
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"cache/**",
|
||||
"docs/**"
|
||||
"output/**"
|
||||
],
|
||||
"ext": "ts,json,html,css"
|
||||
"ext": "ts,json"
|
||||
}
|
||||
|
||||
788
scraper/package-lock.json
generated
788
scraper/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,29 @@
|
||||
{
|
||||
"name": "scraper",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"author": "Fabrice Lamant",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "DEBUG=olympics-calendar* nodemon index.ts"
|
||||
"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",
|
||||
"eslint": "^9.39.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemon": "^3.1.11",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
"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
195
scraper/scraper.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -1,108 +1,41 @@
|
||||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
// File Layout
|
||||
// "rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "nodenext",
|
||||
"lib": ["esnext"],
|
||||
"types": ["node"],
|
||||
// and npm install -D @types/node
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
// 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
99
scraper/types.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
43
ui/.gitignore
vendored
Normal file
43
ui/.gitignore
vendored
Normal 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
21
ui/Dockerfile
Normal 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"]
|
||||
24
ui/app/api/data/[[...slug]]/route.ts
Normal file
24
ui/app/api/data/[[...slug]]/route.ts
Normal 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
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
267
ui/app/flag.tsx
Normal 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
69
ui/app/globals.css
Normal 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
38
ui/app/layout.tsx
Normal 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
395
ui/app/page.tsx
Normal 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
18
ui/eslint.config.mjs
Normal 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
5
ui/lib/data.ts
Normal 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
30
ui/lib/local-storage.ts
Normal 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
84
ui/lib/text.ts
Normal 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
7
ui/next.config.ts
Normal 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
6650
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
ui/package.json
Normal file
28
ui/package.json
Normal 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
7
ui/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
ui/public/img/icon-apple.svg
Normal file
21
ui/public/img/icon-apple.svg
Normal 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 |
23
ui/public/img/icon-google.svg
Normal file
23
ui/public/img/icon-google.svg
Normal 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 |
1
ui/public/img/icon-office365.svg
Normal file
1
ui/public/img/icon-office365.svg
Normal 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 |
29
ui/public/img/icon-outlookcom.svg
Normal file
29
ui/public/img/icon-outlookcom.svg
Normal 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 |
21
ui/public/img/icon-yahoo.svg
Normal file
21
ui/public/img/icon-yahoo.svg
Normal 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
34
ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user