diff --git a/.gitignore b/.gitignore index 11ee758..4c49bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -.env.local +.env diff --git a/.idea/runConfigurations/Backend.xml b/.idea/runConfigurations/Backend.xml index adc1c75..cfded65 100644 --- a/.idea/runConfigurations/Backend.xml +++ b/.idea/runConfigurations/Backend.xml @@ -3,7 +3,9 @@ - + + + diff --git a/Caddyfile b/Caddyfile index aed68ba..829d0d3 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,4 +1,5 @@ :30002 { reverse_proxy http://localhost:30000 - reverse_proxy /api http://localhost:30001 + reverse_proxy /api/* http://localhost:30001 + reverse_proxy /telegram/webhook http://localhost:30001 } diff --git a/holycow_backend/Cargo.lock b/holycow_backend/Cargo.lock index dffa95a..e7e7847 100644 --- a/holycow_backend/Cargo.lock +++ b/holycow_backend/Cargo.lock @@ -664,6 +664,7 @@ dependencies = [ "micronfig", "pretty_env_logger", "serde", + "serde_json", "skillratings", "teloxide", "tokio", diff --git a/holycow_backend/Cargo.toml b/holycow_backend/Cargo.toml index bd9ae20..d873b55 100644 --- a/holycow_backend/Cargo.toml +++ b/holycow_backend/Cargo.toml @@ -17,3 +17,4 @@ tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] } serde = { version = "1.0.215", features = ["derive"] } chrono = { version = "0.4.38", features = ["serde"] } url = { version = "2.5.4", features = ["serde"] } +serde_json = "1.0.133" diff --git a/holycow_backend/migrations/2024-11-29-145528_readd usernames/down.sql b/holycow_backend/migrations/2024-11-29-145528_readd usernames/down.sql new file mode 100644 index 0000000..2485ea5 --- /dev/null +++ b/holycow_backend/migrations/2024-11-29-145528_readd usernames/down.sql @@ -0,0 +1 @@ +ALTER TABLE players DROP COLUMN IF EXISTS username; diff --git a/holycow_backend/migrations/2024-11-29-145528_readd usernames/up.sql b/holycow_backend/migrations/2024-11-29-145528_readd usernames/up.sql new file mode 100644 index 0000000..752e60e --- /dev/null +++ b/holycow_backend/migrations/2024-11-29-145528_readd usernames/up.sql @@ -0,0 +1 @@ +ALTER TABLE players ADD COLUMN username BPCHAR UNIQUE NOT NULL; diff --git a/holycow_backend/migrations/2024-11-29-151839_ununique matchnames/down.sql b/holycow_backend/migrations/2024-11-29-151839_ununique matchnames/down.sql new file mode 100644 index 0000000..04f6698 --- /dev/null +++ b/holycow_backend/migrations/2024-11-29-151839_ununique matchnames/down.sql @@ -0,0 +1 @@ +ALTER TABLE matches ADD CONSTRAINT match_unique_name UNIQUE (name); diff --git a/holycow_backend/migrations/2024-11-29-151839_ununique matchnames/up.sql b/holycow_backend/migrations/2024-11-29-151839_ununique matchnames/up.sql new file mode 100644 index 0000000..e129724 --- /dev/null +++ b/holycow_backend/migrations/2024-11-29-151839_ununique matchnames/up.sql @@ -0,0 +1 @@ +ALTER TABLE matches DROP CONSTRAINT match_unique_name; diff --git a/holycow_backend/src/config.rs b/holycow_backend/src/config.rs index a66a9d6..5bb1257 100644 --- a/holycow_backend/src/config.rs +++ b/holycow_backend/src/config.rs @@ -3,4 +3,6 @@ micronfig::config! { BACKEND_BIND_ADDRESS: String > std::net::SocketAddr, TELEGRAM_API_KEY: String, TELEGRAM_WEBHOOK_URL: String > url::Url, + TELEGRAM_NOTIFICATION_CHAT_ID: String > i64, + TELEGRAM_NOTIFICATION_TOPIC_ID?: String > i32, } diff --git a/holycow_backend/src/database/model.rs b/holycow_backend/src/database/model.rs index f22fe98..3f98a01 100644 --- a/holycow_backend/src/database/model.rs +++ b/holycow_backend/src/database/model.rs @@ -5,7 +5,7 @@ use diesel::backend::Backend; use diesel::deserialize::FromSql; use diesel::dsl::insert_into; use diesel::pg::Pg; -use diesel::serialize::ToSql; +use diesel::serialize::{IsNull, ToSql}; use diesel::sql_types as sql; use diesel::serialize::Output as DieselOutput; use diesel::ExpressionMethods; @@ -32,6 +32,7 @@ pub struct Player { pub wenglin: WengLinRating, pub telegram_id: Option, pub competitive: bool, + pub username: String, } #[derive(Debug, Clone, Insertable, Serialize, Deserialize)] @@ -39,8 +40,9 @@ pub struct Player { #[diesel(check_for_backend(Pg))] pub struct PlayerI { pub wenglin: WengLinRating, - pub telegram_id: TelegramId, + pub telegram_id: Option, pub competitive: bool, + pub username: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, Serialize, Deserialize)] @@ -71,7 +73,6 @@ pub struct Match { #[diesel(table_name = schema::matches)] #[diesel(check_for_backend(Pg))] pub struct MatchI { - pub instant: DateTime, pub name: Option, pub player_a_id: i32, pub player_a_wenglin_before: WengLinRating, @@ -92,8 +93,7 @@ impl FromSql for TelegramId { impl FromSql for WengLinRating { fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { - let rating = >::from_sql(bytes)?; - let uncertainty = >::from_sql(bytes)?; + let (rating, uncertainty) = <(f64, f64) as FromSql, Pg>>::from_sql(bytes)?; let rating = skillratings::weng_lin::WengLinRating::from((rating, uncertainty)); Ok(Self(rating)) @@ -121,10 +121,10 @@ impl ToSql for TelegramId { impl ToSql for WengLinRating { fn to_sql<'b>(&'b self, out: &mut DieselOutput<'b, '_, Pg>) -> diesel::serialize::Result { - >::to_sql(&self.0.rating, out)?; - >::to_sql(&self.0.uncertainty, out)?; - - Ok(diesel::serialize::IsNull::No) + diesel::serialize::WriteTuple::<(sql::Double, sql::Double)>::write_tuple( + &(self.0.rating, self.0.uncertainty), + out + ) } } @@ -138,24 +138,54 @@ impl ToSql for Outcome { } )?; - Ok(diesel::serialize::IsNull::No) + Ok(IsNull::No) + } +} + +impl From for skillratings::Outcomes { + fn from(value: Outcome) -> Self { + match value { + Outcome::AWins => Self::WIN, + Outcome::BWins => Self::LOSS, + Outcome::Tie => Self::DRAW, + } + } +} + +impl WengLinRating { + pub fn human_score(&self) -> i64 { + let rating = self.0.rating; + let uncertainty = self.0.uncertainty; + log::debug!("Getting human score for: {rating:?}±{uncertainty:?}"); + let uncertain = self.0.rating - self.0.uncertainty; + log::trace!("Minimum score is: {uncertain:?}"); + let multiplied = uncertain * 100.0; + log::trace!("Multiplied score is: {multiplied:?}"); + let floored: f64 = multiplied.floor(); + log::trace!("Floored score is: {floored:?}"); + let converted: i64 = floored as i64; + log::debug!("Human score for {rating:?}±{uncertainty:?} is {converted:?}"); + converted } } impl Player { pub fn total(conn: &mut PgConnection) -> QueryResult { + log::debug!("Querying total amount of players..."); schema::players::table .select(diesel::dsl::count_star()) .get_result::(conn) } pub fn all(conn: &mut PgConnection) -> QueryResult> { + log::debug!("Querying all players..."); schema::players::table .select(Self::as_select()) .get_results::(conn) } pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult> { + log::debug!("Querying player with id: {player_id:?}"); schema::players::table .select(Self::as_select()) .filter(schema::players::id.eq(player_id)) @@ -164,6 +194,7 @@ impl Player { } pub fn get_by_telegram_id(conn: &mut PgConnection, telegram_id: TelegramId) -> QueryResult> { + log::debug!("Querying player with telegram id: {telegram_id:?}"); schema::players::table .select(Self::as_select()) .filter(schema::players::telegram_id.eq(telegram_id)) @@ -178,6 +209,12 @@ impl Player { pub fn won_count(&self, conn: &mut PgConnection) -> QueryResult { Match::won_by_count(conn, self.id) } + + pub fn update_wenglin(self, conn: &mut PgConnection, value: &WengLinRating) -> QueryResult { + diesel::update(schema::players::table.find(self.id)) + .set(schema::players::wenglin.eq(value)) + .get_result(conn) + } } impl Match { @@ -214,9 +251,9 @@ impl Match { } impl PlayerI { - pub fn insert(self, conn: &mut PgConnection) -> QueryResult { + pub fn insert(&self, conn: &mut PgConnection) -> QueryResult { insert_into(schema::players::table) - .values(&[self]) + .values(self) .get_result::(conn) } } @@ -224,7 +261,7 @@ impl PlayerI { impl MatchI { pub fn insert(self, conn: &mut PgConnection) -> QueryResult { insert_into(schema::matches::table) - .values(&[self]) + .values(self) .get_result::(conn) } } diff --git a/holycow_backend/src/database/schema.rs b/holycow_backend/src/database/schema.rs index 8c18f1f..0836ff4 100644 --- a/holycow_backend/src/database/schema.rs +++ b/holycow_backend/src/database/schema.rs @@ -38,6 +38,7 @@ diesel::table! { wenglin -> WenglinT, telegram_id -> Nullable, competitive -> Bool, + username -> Bpchar, } } diff --git a/holycow_backend/src/main.rs b/holycow_backend/src/main.rs index ead9de8..e3cc964 100644 --- a/holycow_backend/src/main.rs +++ b/holycow_backend/src/main.rs @@ -1,11 +1,11 @@ use std::convert::Infallible; use std::process::exit; use anyhow::Context; +use axum::Extension; use diesel::{Connection, PgConnection}; use diesel_migrations::MigrationHarness; -use teloxide::dispatching::{DefaultKey, MessageFilterExt}; +use teloxide::dispatching::DefaultKey; use teloxide::error_handlers::LoggingErrorHandler; -use teloxide::types::Message; use teloxide::update_listeners::webhooks::Options; mod config; @@ -72,19 +72,22 @@ async fn main() -> anyhow::Result { .route("/api/matches/", axum::routing::get(routes::matches::get_all) ) + .route("/api/matches/", + axum::routing::post(routes::matches::post_match) + ) .nest("/telegram/webhook", telegram_router ) + .layer(Extension(bot.clone())) ; log::trace!("Setting up Telegram dispatcher..."); let mut telegram_dispatcher = teloxide::dispatching::Dispatcher::::builder( bot.clone(), - Message::filter_web_app_data() - .endpoint(telegram::webapp::process_data) + teloxide::dptree::entry() ) .default_handler(|u| async move { - log::trace!("Unhandled update: {u:#?}") + log::trace!("Unhandled update: {u:?}") }) .build(); diff --git a/holycow_backend/src/routes/matches.rs b/holycow_backend/src/routes/matches.rs index a139095..e33d1ff 100644 --- a/holycow_backend/src/routes/matches.rs +++ b/holycow_backend/src/routes/matches.rs @@ -1,8 +1,13 @@ use axum::http::StatusCode; -use axum::Json; +use axum::{Extension, Json}; use diesel::{Connection, PgConnection}; +use serde::Deserialize; +use skillratings::weng_lin::WengLinConfig; +use teloxide::Bot; +use teloxide::requests::Requester; +use teloxide::types::{ChatId, MessageId, ThreadId}; use crate::config; -use crate::database::model::Match; +use crate::database::model::{Match, MatchI, Outcome, Player, WengLinRating}; #[axum::debug_handler] pub async fn get_all() @@ -16,3 +21,136 @@ pub async fn get_all() Ok(Json(matches)) } + +#[derive(Debug, Clone, Deserialize)] +pub struct MatchII { + name: Option, + player_a: i32, + player_b: i32, + outcome: Outcome, +} + +fn player_to_text(player: &Player, before: &WengLinRating, after: &WengLinRating) -> String { + let name = &player.username; + let competitive = &player.competitive; + + match competitive { + false => { + format!("{name}") + }, + true => { + let before = before.human_score(); + let after = after.human_score(); + let change = after - before; + + format!("{name} ({change})") + }, + } +} + +fn match_to_text(r#match: &Match, player_a: &Player, player_b: &Player) -> String { + let player_a = player_to_text(player_a, &r#match.player_a_wenglin_before, &r#match.player_a_wenglin_after); + let player_b = player_to_text(player_b, &r#match.player_b_wenglin_before, &r#match.player_b_wenglin_after); + + match r#match.outcome { + Outcome::AWins => match &r#match.name { + Some(name) => format!("🔵 {player_a} ha trionfato su {player_b} in {name}!"), + None => format!("🔵 {player_a} ha trionfato su {player_b}!"), + }, + Outcome::BWins => match &r#match.name { + Some(name) => format!("⚪️ {player_a} è stato sconfitto da {player_b} in {name}!"), + None => format!("⚪️ {player_a} è stato sconfitto da {player_b}!"), + }, + Outcome::Tie => match &r#match.name { + Some(name) => format!("🟠 {player_a} e {player_b} hanno pareggiato in {name}!"), + None => format!("🟠 {player_a} e {player_b} hanno pareggiato!"), + }, + } +} + +pub async fn post_match( + Extension(bot): Extension, + Json(matchii): Json, +) + -> Result, StatusCode> +{ + log::debug!("New MatchII just dropped: {matchii:#?}"); + let name = matchii.name; + let outcome = matchii.outcome; + + log::trace!("Establishing database connection..."); + let mut conn = PgConnection::establish(config::DATABASE_URL()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + log::trace!("Finding player A's info..."); + let player_a = Player::get_by_id(&mut conn, matchii.player_a) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or_else(|| StatusCode::NOT_FOUND)?; + let player_a_id = player_a.id; + let player_a_wenglin_before = player_a.wenglin.clone(); + + log::trace!("Finding player B's info..."); + let player_b = Player::get_by_id(&mut conn, matchii.player_b) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or_else(|| StatusCode::NOT_FOUND)?; + let player_b_id = player_b.id; + let player_b_wenglin_before = player_b.wenglin.clone(); + + log::trace!("Calculating rating changes..."); + let (player_a_wenglin_after, player_b_wenglin_after) = skillratings::weng_lin::weng_lin( + &player_a_wenglin_before.0, + &player_b_wenglin_before.0, + &outcome.into(), + &WengLinConfig::default(), + ); + let player_a_wenglin_after = WengLinRating(player_a_wenglin_after); + log::trace!("A's new rating is: {player_a_wenglin_after:?}"); + let player_b_wenglin_after = WengLinRating(player_b_wenglin_after); + log::trace!("B's new rating is: {player_b_wenglin_after:?}"); + + log::trace!("Starting database transaction..."); + let (r#match, player_a, player_b) = conn.transaction(|tx| { + log::trace!("Updating A's rating..."); + let player_a = player_a.update_wenglin(tx, &player_a_wenglin_after) + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + log::trace!("Updating B's rating..."); + let player_b = player_b.update_wenglin(tx, &player_b_wenglin_after) + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + log::trace!("Inserting match..."); + let matchi = MatchI { + name, + player_a_id, + player_a_wenglin_before, + player_a_wenglin_after, + player_b_id, + player_b_wenglin_before, + player_b_wenglin_after, + outcome, + }; + let r#match = matchi.insert(tx) + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + Ok::<(Match, Player, Player), anyhow::Error>((r#match, player_a, player_b)) + }) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + log::trace!("Preparing send message future..."); + + let chat = config::TELEGRAM_NOTIFICATION_CHAT_ID(); + let chat = ChatId(*chat); + let mut send_message_future = bot.send_message(chat, match_to_text(&r#match, &player_a, &player_b)); + + let topic = config::TELEGRAM_NOTIFICATION_TOPIC_ID(); + if let Some(topic) = topic { + let topic = MessageId(*topic); + let topic = ThreadId(topic); + send_message_future.message_thread_id = Some(topic); + } + + log::trace!("Sending message..."); + let _message = send_message_future.await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(r#match)) +} diff --git a/holycow_backend/src/routes/results.rs b/holycow_backend/src/routes/results.rs index 0744752..f3567e8 100644 --- a/holycow_backend/src/routes/results.rs +++ b/holycow_backend/src/routes/results.rs @@ -2,14 +2,37 @@ use axum::extract::Path; use axum::http::StatusCode; use axum::Json; use diesel::{Connection, PgConnection}; +use serde::{Deserialize, Serialize}; use model::Player; use crate::config; use crate::database::model; use crate::database::model::TelegramId; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerO { + id: i32, + telegram_id: Option, + username: String, + human_score: Option, +} + +impl From for PlayerO { + fn from(value: Player) -> Self { + Self { + id: value.id, + telegram_id: value.telegram_id, + username: value.username, + human_score: match value.competitive { + true => Some(value.wenglin.human_score()), + false => None + }, + } + } +} + #[axum::debug_handler] pub async fn get_all() - -> Result>, StatusCode> + -> Result>, StatusCode> { let mut conn = PgConnection::establish(config::DATABASE_URL()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -17,14 +40,14 @@ pub async fn get_all() let players = Player::all(&mut conn) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(players)) + Ok(Json(players.into_iter().map(Into::into).collect())) } #[axum::debug_handler] pub async fn get_by_id( Path(player_id): Path, ) - -> Result, StatusCode> + -> Result, StatusCode> { let mut conn = PgConnection::establish(config::DATABASE_URL()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -33,14 +56,14 @@ pub async fn get_by_id( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; - Ok(Json(player)) + Ok(Json(player.into())) } #[axum::debug_handler] pub async fn get_by_telegram_id( Path(telegram_id): Path, ) - -> Result, StatusCode> + -> Result, StatusCode> { let mut conn = PgConnection::establish(config::DATABASE_URL()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -49,5 +72,5 @@ pub async fn get_by_telegram_id( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; - Ok(Json(player)) + Ok(Json(player.into())) } diff --git a/holycow_backend/src/telegram/mod.rs b/holycow_backend/src/telegram/mod.rs index 56b418a..e69de29 100644 --- a/holycow_backend/src/telegram/mod.rs +++ b/holycow_backend/src/telegram/mod.rs @@ -1 +0,0 @@ -pub mod webapp; \ No newline at end of file diff --git a/holycow_backend/src/telegram/webapp.rs b/holycow_backend/src/telegram/webapp.rs deleted file mode 100644 index a221885..0000000 --- a/holycow_backend/src/telegram/webapp.rs +++ /dev/null @@ -1,8 +0,0 @@ -use teloxide::types::WebAppData; - -pub async fn process_data( - web_app_data: WebAppData, -) -> anyhow::Result<()> { - log::trace!("{web_app_data:#?}"); - Ok(()) -} diff --git a/holycow_frontend/next.config.ts b/holycow_frontend/next.config.ts index 7625ced..cdf4b3c 100644 --- a/holycow_frontend/next.config.ts +++ b/holycow_frontend/next.config.ts @@ -1,7 +1,5 @@ import {NextConfig} from "next" -const nextConfig: NextConfig = { - output: "export", -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/holycow_frontend/src/app/[telegramId]/profile/page.tsx b/holycow_frontend/src/app/[telegramId]/profile/page.tsx new file mode 100644 index 0000000..8e4f877 --- /dev/null +++ b/holycow_frontend/src/app/[telegramId]/profile/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + +} \ No newline at end of file diff --git a/holycow_frontend/src/app/[telegramId]/report/page.tsx b/holycow_frontend/src/app/[telegramId]/report/page.tsx new file mode 100644 index 0000000..ce3ab82 --- /dev/null +++ b/holycow_frontend/src/app/[telegramId]/report/page.tsx @@ -0,0 +1,16 @@ +import {ReportBox} from "@/components/ReportBox" +import {ReportBoxInteractive} from "@/components/ReportBoxInteractive" +import {PlayerO} from "@/holycow" + +export default async function Page({params: {telegramId}}) { + const playersResponse = await fetch(`${process.env.BASE_URL}/api/results/`) + const players: PlayerO[] = await playersResponse.json() + const playerA: PlayerO = players.find(p => p.telegram_id == telegramId) + + return ( + + ) +} \ No newline at end of file diff --git a/holycow_frontend/src/app/page.tsx b/holycow_frontend/src/app/page.tsx index a68b446..63c34de 100644 --- a/holycow_frontend/src/app/page.tsx +++ b/holycow_frontend/src/app/page.tsx @@ -1,18 +1,38 @@ "use client"; -import { useTelegram } from "@/components/useTelegram"; -import {useEffect, useMemo} from "react" +import {LoadingBox} from "@/components/LoadingBox" +import {useTelegram} from "@/components/useTelegram" +import {useEffect} from "react" +import {useRouter} from "next/navigation" export default function Page() { + const router = useRouter() const telegram = useTelegram() - const telegramData = telegram?.WebApp?.initDataUnsafe - const userId = telegramData?.user?.id - const userName = telegramData?.user?.first_name - const startParam = telegramData?.start_param + const data = telegram?.WebApp?.initDataUnsafe + const startParam = data?.start_param - const resultsData = undefined - const resultsError = undefined + useEffect( + () => { + switch(startParam) { + case undefined: + return + case "profile": + router.replace(`/${data.user.id}/profile`) + return + case "report": + router.replace(`/${data.user.id}/report`) + return + default: + router.replace(`/error404`) + return + } + }, + [startParam] + ) - return - + return ( + + Connecting to Telegram... + + ) } diff --git a/holycow_frontend/src/app/report/page.tsx b/holycow_frontend/src/app/report/page.tsx deleted file mode 100644 index a0518d1..0000000 --- a/holycow_frontend/src/app/report/page.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { useTelegram } from "@/components/useTelegram"; -import classNames from "classnames"; -import {FormEvent, useCallback, useMemo, useState} from "react" - -export default function Page() { - const telegram = useTelegram() - const telegramData = telegram?.WebApp?.initDataUnsafe - const userId = telegramData?.user?.id - const userName = telegramData?.user?.first_name ?? "???" - - const [opponent, setOpponent] = useState("") - const [result, setResult] = useState(null) - - const onSubmit = useCallback( - (e: FormEvent) => { - telegram?.WebApp?.sendData?.(`${result} ${opponent}`) - }, - [telegram, result, opponent] - ) - - const contents = useMemo(() => { - return ( - - - - Registra risultato - - - Tu - - {userName} - - - - - Avversario - setOpponent(e.target.value)} value={opponent}> - - @AleCose - @Alleander - @catstolker - @CookieSin - @druidsfluid - @Francesco_Cuoghi - @GioOmbra - @GoodBalu - @Malbyx - @Mallllco - @MaxBubblegum - @SnowyCoder - @Spaggia - @Steffo - @xZefyr - @zezelda - - - - - Risultato - - - setResult(e.target.value)} checked={result === "W"}/> Vittoria - - - setResult(e.target.value)} checked={result === "T"}/> Pareggio - - - setResult(e.target.value)} checked={result === "L"}/> Sconfitta - - - - - - - - ) - }, [onSubmit, userName, opponent, result]) - - return <> - - {contents} - - > -} diff --git a/holycow_frontend/src/components/LoadingBox.module.css b/holycow_frontend/src/components/LoadingBox.module.css new file mode 100644 index 0000000..389cb49 --- /dev/null +++ b/holycow_frontend/src/components/LoadingBox.module.css @@ -0,0 +1,13 @@ +@keyframes loading { + 0% { + opacity: 0.25; + } + + 100% { + opacity: 1.00; + } +} + +.loading { + animation: loading 0.5s infinite alternate ease-in-out; +} diff --git a/holycow_frontend/src/components/LoadingBox.tsx b/holycow_frontend/src/components/LoadingBox.tsx new file mode 100644 index 0000000..b7d75b1 --- /dev/null +++ b/holycow_frontend/src/components/LoadingBox.tsx @@ -0,0 +1,19 @@ +import classNames from "classnames" +import {ReactNode} from "react" +import style from "./LoadingBox.module.css" + +export type LoadingBoxProps = { + children?: ReactNode, +} + +export function LoadingBox({children = "Loading..."}: LoadingBoxProps) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/holycow_frontend/src/components/ReportBox.tsx b/holycow_frontend/src/components/ReportBox.tsx new file mode 100644 index 0000000..51e1389 --- /dev/null +++ b/holycow_frontend/src/components/ReportBox.tsx @@ -0,0 +1,97 @@ +import {Outcome, PlayerO} from "@/holycow" +import classNames from "classnames" +import {FormEvent} from "react" + +export type ReportBoxProps = { + players: PlayerO[], + + playerA: PlayerO, + playerB?: PlayerO, + setPlayerB: (player?: PlayerO) => void, + + outcome: Outcome, + setOutcome: (outcome: Outcome) => void, + + name: string, + setName: (name: string) => void, + + onSubmit: (e: FormEvent) => void, +} + +// TODO +export function ReportBox({players, playerA, playerB, setPlayerB, outcome, setOutcome, name, setName, onSubmit}: ReportBoxProps) { + return ( + { + e.preventDefault() + onSubmit(e) + }} + > + + Registra risultato + + + + Tu + + {playerA.username} + + {playerA.human_score && `★ ${playerA.human_score}`} + + + Avversario + setPlayerB(players.find(p => p.id == Number.parseInt(e.target.value)))} value={playerB?.id}> + + {players + .filter(player => player.id !== playerA.id) + .map(player => ( + {player.username} + )) + } + + {playerB && playerB.human_score && `★ ${playerB.human_score}`} + + + + Risultato + + + e.target.checked && setOutcome(Outcome.AWins)}/> Vittoria + + + e.target.checked && setOutcome(Outcome.Tie)}/> Pareggio + + + e.target.checked && setOutcome(Outcome.BWins)}/> Sconfitta + + + + + + + Titolo sfida + setName(e.target.value)} value={name}/> + (opzionale) + + + + + ) +} \ No newline at end of file diff --git a/holycow_frontend/src/components/ReportBoxInteractive.tsx b/holycow_frontend/src/components/ReportBoxInteractive.tsx new file mode 100644 index 0000000..0cd5855 --- /dev/null +++ b/holycow_frontend/src/components/ReportBoxInteractive.tsx @@ -0,0 +1,56 @@ +"use client"; + +import {ReportBox} from "@/components/ReportBox" +import {useTelegram} from "@/components/useTelegram" +import {Outcome, PlayerO} from "@/holycow" +import {useCallback, useState} from "react" + +export type ReportBoxInteractiveProps = { + players: PlayerO[], + playerA: PlayerO, +} + +export function ReportBoxInteractive({players, playerA}: ReportBoxInteractiveProps) { + const [playerB, setPlayerB] = useState(undefined) + const [outcome, setOutcome] = useState(undefined) + const [name, setName] = useState("") + const [running, setRunning] = useState(false) + const telegram = useTelegram() + + const onSubmit = useCallback( + () => { + if(!telegram) return + setRunning(true) + const body = JSON.stringify({ + name: name === "" ? null : name, + player_a: playerA.id, + player_b: playerB.id, + outcome: outcome.toString(), + }) + fetch("/api/matches/", { + method: "POST", + body, + headers: { + "Content-Type": "application/json" + } + }).finally(() => { + telegram?.WebApp?.close?.() + }) + }, + [telegram, name, playerA, playerB, outcome] + ) + + return ( + {} : onSubmit} + /> + ) +} \ No newline at end of file diff --git a/holycow_frontend/src/holycow.ts b/holycow_frontend/src/holycow.ts new file mode 100644 index 0000000..97ff8da --- /dev/null +++ b/holycow_frontend/src/holycow.ts @@ -0,0 +1,12 @@ +export enum Outcome { + AWins = "AWins", + BWins = "BWins", + Tie = "Tie", +} + +export type PlayerO = { + id: number, + telegram_id: number, + username: string, + human_score: null | number, +} diff --git a/holycow_frontend/src/telegram.ts b/holycow_frontend/src/telegram.ts index 6d5c063..fa8a135 100644 --- a/holycow_frontend/src/telegram.ts +++ b/holycow_frontend/src/telegram.ts @@ -5,6 +5,7 @@ interface Telegram { interface TelegramWebApp { initDataUnsafe?: TelegramWebAppInitData sendData?: (data: string) => void, + close?: () => void, } interface TelegramWebAppInitData {