holy cow it works
This commit is contained in:
parent
c3cf86f6e9
commit
15ad3dfb94
28 changed files with 490 additions and 158 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1 @@
|
|||
.env.local
|
||||
.env
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
<option name="buildProfileId" value="dev" />
|
||||
<option name="command" value="run" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$/holycow_backend" />
|
||||
<envs />
|
||||
<envs>
|
||||
<env name="RUST_LOG" value="trace" />
|
||||
</envs>
|
||||
<option name="emulateTerminal" value="true" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
1
holycow_backend/Cargo.lock
generated
1
holycow_backend/Cargo.lock
generated
|
@ -664,6 +664,7 @@ dependencies = [
|
|||
"micronfig",
|
||||
"pretty_env_logger",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skillratings",
|
||||
"teloxide",
|
||||
"tokio",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE players DROP COLUMN IF EXISTS username;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE players ADD COLUMN username BPCHAR UNIQUE NOT NULL;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE matches ADD CONSTRAINT match_unique_name UNIQUE (name);
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE matches DROP CONSTRAINT match_unique_name;
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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<TelegramId>,
|
||||
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<TelegramId>,
|
||||
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<Utc>,
|
||||
pub name: Option<String>,
|
||||
pub player_a_id: i32,
|
||||
pub player_a_wenglin_before: WengLinRating,
|
||||
|
@ -92,8 +93,7 @@ impl FromSql<sql::BigInt, Pg> for TelegramId {
|
|||
|
||||
impl FromSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
|
||||
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
||||
let rating = <f64 as FromSql<sql::Double, Pg>>::from_sql(bytes)?;
|
||||
let uncertainty = <f64 as FromSql<sql::Double, Pg>>::from_sql(bytes)?;
|
||||
let (rating, uncertainty) = <(f64, f64) as FromSql<sql::Record<(sql::Double, sql::Double)>, Pg>>::from_sql(bytes)?;
|
||||
|
||||
let rating = skillratings::weng_lin::WengLinRating::from((rating, uncertainty));
|
||||
Ok(Self(rating))
|
||||
|
@ -121,10 +121,10 @@ impl ToSql<sql::BigInt, Pg> for TelegramId {
|
|||
|
||||
impl ToSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
|
||||
fn to_sql<'b>(&'b self, out: &mut DieselOutput<'b, '_, Pg>) -> diesel::serialize::Result {
|
||||
<f64 as ToSql<sql::Double, Pg>>::to_sql(&self.0.rating, out)?;
|
||||
<f64 as ToSql<sql::Double, Pg>>::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<schema::sql_types::OutcomeT, Pg> for Outcome {
|
|||
}
|
||||
)?;
|
||||
|
||||
Ok(diesel::serialize::IsNull::No)
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Outcome> 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<i64> {
|
||||
log::debug!("Querying total amount of players...");
|
||||
schema::players::table
|
||||
.select(diesel::dsl::count_star())
|
||||
.get_result::<i64>(conn)
|
||||
}
|
||||
|
||||
pub fn all(conn: &mut PgConnection) -> QueryResult<Vec<Self>> {
|
||||
log::debug!("Querying all players...");
|
||||
schema::players::table
|
||||
.select(Self::as_select())
|
||||
.get_results::<Self>(conn)
|
||||
}
|
||||
|
||||
pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult<Option<Self>> {
|
||||
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<Option<Self>> {
|
||||
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<i64> {
|
||||
Match::won_by_count(conn, self.id)
|
||||
}
|
||||
|
||||
pub fn update_wenglin(self, conn: &mut PgConnection, value: &WengLinRating) -> QueryResult<Self> {
|
||||
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<Player> {
|
||||
pub fn insert(&self, conn: &mut PgConnection) -> QueryResult<Player> {
|
||||
insert_into(schema::players::table)
|
||||
.values(&[self])
|
||||
.values(self)
|
||||
.get_result::<Player>(conn)
|
||||
}
|
||||
}
|
||||
|
@ -224,7 +261,7 @@ impl PlayerI {
|
|||
impl MatchI {
|
||||
pub fn insert(self, conn: &mut PgConnection) -> QueryResult<Match> {
|
||||
insert_into(schema::matches::table)
|
||||
.values(&[self])
|
||||
.values(self)
|
||||
.get_result::<Match>(conn)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ diesel::table! {
|
|||
wenglin -> WenglinT,
|
||||
telegram_id -> Nullable<Int8>,
|
||||
competitive -> Bool,
|
||||
username -> Bpchar,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Infallible> {
|
|||
.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::<teloxide::Bot, anyhow::Error, DefaultKey>::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();
|
||||
|
||||
|
|
|
@ -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<String>,
|
||||
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!("<b>{name}</b> <i>({change})</i>")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 <b>{name}</b>!"),
|
||||
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 <b>{name}</b>!"),
|
||||
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 <b>{name}</b>!"),
|
||||
None => format!("🟠 {player_a} e {player_b} hanno pareggiato!"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_match(
|
||||
Extension(bot): Extension<Bot>,
|
||||
Json(matchii): Json<MatchII>,
|
||||
)
|
||||
-> Result<Json<Match>, 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))
|
||||
}
|
||||
|
|
|
@ -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<TelegramId>,
|
||||
username: String,
|
||||
human_score: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Player> 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<Json<Vec<Player>>, StatusCode>
|
||||
-> Result<Json<Vec<PlayerO>>, 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<i32>,
|
||||
)
|
||||
-> Result<Json<Player>, StatusCode>
|
||||
-> Result<Json<PlayerO>, 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<TelegramId>,
|
||||
)
|
||||
-> Result<Json<Player>, StatusCode>
|
||||
-> Result<Json<PlayerO>, 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()))
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
pub mod webapp;
|
|
@ -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(())
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
import {NextConfig} from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
};
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
3
holycow_frontend/src/app/[telegramId]/profile/page.tsx
Normal file
3
holycow_frontend/src/app/[telegramId]/profile/page.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default async function Page() {
|
||||
|
||||
}
|
16
holycow_frontend/src/app/[telegramId]/report/page.tsx
Normal file
16
holycow_frontend/src/app/[telegramId]/report/page.tsx
Normal file
|
@ -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 (
|
||||
<ReportBoxInteractive
|
||||
players={players}
|
||||
playerA={playerA}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
||||
return <main>
|
||||
</main>
|
||||
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 (
|
||||
<LoadingBox>
|
||||
Connecting to Telegram...
|
||||
</LoadingBox>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div className={"chapter-1"}>
|
||||
<form
|
||||
className={classNames({
|
||||
"panel": true,
|
||||
"box": true,
|
||||
"form-flex": true,
|
||||
"red": result === "L",
|
||||
"yellow": result === "T",
|
||||
"green": result === "W",
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<h3>
|
||||
Registra risultato
|
||||
</h3>
|
||||
<label>
|
||||
<span>Tu</span>
|
||||
<div>
|
||||
{userName}
|
||||
</div>
|
||||
<span></span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Avversario</span>
|
||||
<select onChange={(e) => setOpponent(e.target.value)} value={opponent}>
|
||||
<option value={""}></option>
|
||||
<option value={"34053709"}>@AleCose</option>
|
||||
<option value={"843330513"}>@Alleander</option>
|
||||
<option value={"200821462"}>@catstolker</option>
|
||||
<option value={"454281712"}>@CookieSin</option>
|
||||
<option value={"524944901"}>@druidsfluid</option>
|
||||
<option value={"48371848"}>@Francesco_Cuoghi</option>
|
||||
<option value={"148374774"}>@GioOmbra</option>
|
||||
<option value={"19611986"}>@GoodBalu</option>
|
||||
<option value={"131057096"}>@Malbyx</option>
|
||||
<option value={"488463576"}>@Mallllco</option>
|
||||
<option value={"33523022"}>@MaxBubblegum</option>
|
||||
<option value={"139079908"}>@SnowyCoder</option>
|
||||
<option value={"165792255"}>@Spaggia</option>
|
||||
<option value={"25167391"}>@Steffo</option>
|
||||
<option value={"890339572"}>@xZefyr</option>
|
||||
<option value={"19097832"}>@zezelda</option>
|
||||
</select>
|
||||
<span></span>
|
||||
</label>
|
||||
<div className={"form-flex-choice"}>
|
||||
<span>Risultato</span>
|
||||
<div>
|
||||
<label>
|
||||
<input type={"radio"} name={"result"} value={"W"} onChange={(e) => setResult(e.target.value)} checked={result === "W"}/> Vittoria
|
||||
</label>
|
||||
<label>
|
||||
<input type={"radio"} name={"result"} value={"T"} onChange={(e) => setResult(e.target.value)} checked={result === "T"}/> Pareggio
|
||||
</label>
|
||||
<label>
|
||||
<input type={"radio"} name={"result"} value={"L"} onChange={(e) => setResult(e.target.value)} checked={result === "L"}/> Sconfitta
|
||||
</label>
|
||||
</div>
|
||||
<span></span>
|
||||
</div>
|
||||
<input
|
||||
type={"submit"}
|
||||
value={"Invia"}
|
||||
className={classNames({
|
||||
// TODO: "fade": result === null || opponent === ""
|
||||
"fade": true,
|
||||
})}
|
||||
disabled={
|
||||
// TODO: result === null || opponent === ""
|
||||
true
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}, [onSubmit, userName, opponent, result])
|
||||
|
||||
return <>
|
||||
<main>
|
||||
{contents}
|
||||
</main>
|
||||
</>
|
||||
}
|
13
holycow_frontend/src/components/LoadingBox.module.css
Normal file
13
holycow_frontend/src/components/LoadingBox.module.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
@keyframes loading {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1.00;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: loading 0.5s infinite alternate ease-in-out;
|
||||
}
|
19
holycow_frontend/src/components/LoadingBox.tsx
Normal file
19
holycow_frontend/src/components/LoadingBox.tsx
Normal file
|
@ -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 (
|
||||
<div className={classNames({
|
||||
"panel": true,
|
||||
"box": true,
|
||||
[style.loading]: true
|
||||
})}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
97
holycow_frontend/src/components/ReportBox.tsx
Normal file
97
holycow_frontend/src/components/ReportBox.tsx
Normal file
|
@ -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 (
|
||||
<form
|
||||
className={classNames({
|
||||
"panel": true,
|
||||
"box": true,
|
||||
"form-flex": true,
|
||||
"green": outcome === Outcome.AWins,
|
||||
"red": outcome === Outcome.BWins,
|
||||
"yellow": outcome === Outcome.Tie,
|
||||
})}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
onSubmit(e)
|
||||
}}
|
||||
>
|
||||
<h3>
|
||||
Registra risultato
|
||||
</h3>
|
||||
<p></p>
|
||||
<label>
|
||||
<span>Tu</span>
|
||||
<div>
|
||||
{playerA.username}
|
||||
</div>
|
||||
<span>{playerA.human_score && `★ ${playerA.human_score}`}</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Avversario</span>
|
||||
<select onChange={e => setPlayerB(players.find(p => p.id == Number.parseInt(e.target.value)))} value={playerB?.id}>
|
||||
<option value={undefined}></option>
|
||||
{players
|
||||
.filter(player => player.id !== playerA.id)
|
||||
.map(player => (
|
||||
<option key={player.id} value={player.id}>{player.username}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<span>{playerB && playerB.human_score && `★ ${playerB.human_score}`}</span>
|
||||
</label>
|
||||
<p></p>
|
||||
<div className={"form-flex-choice"}>
|
||||
<span>Risultato</span>
|
||||
<div>
|
||||
<label>
|
||||
<input type={"radio"} name={"result"} value={"AWins"} checked={outcome === Outcome.AWins} onChange={e => e.target.checked && setOutcome(Outcome.AWins)}/> Vittoria
|
||||
</label>
|
||||
<label>
|
||||
<input type={"radio"} name={"result"} value={"Tie"} checked={outcome === Outcome.Tie} onChange={e => e.target.checked && setOutcome(Outcome.Tie)}/> Pareggio
|
||||
</label>
|
||||
<label>
|
||||
<input type={"radio"} name={"result"} value={"BWins"} checked={outcome === Outcome.BWins} onChange={e => e.target.checked && setOutcome(Outcome.BWins)}/> Sconfitta
|
||||
</label>
|
||||
</div>
|
||||
<span></span>
|
||||
</div>
|
||||
<p></p>
|
||||
<label>
|
||||
<span>Titolo sfida</span>
|
||||
<input type={"text"} onChange={e => setName(e.target.value)} value={name}/>
|
||||
<small>(opzionale)</small>
|
||||
</label>
|
||||
<p></p>
|
||||
<input
|
||||
type={"submit"}
|
||||
value={"Invia"}
|
||||
className={classNames({
|
||||
"fade": outcome === undefined || playerB === undefined,
|
||||
})}
|
||||
disabled={
|
||||
outcome === undefined || playerB === undefined
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
56
holycow_frontend/src/components/ReportBoxInteractive.tsx
Normal file
56
holycow_frontend/src/components/ReportBoxInteractive.tsx
Normal file
|
@ -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<PlayerO | undefined>(undefined)
|
||||
const [outcome, setOutcome] = useState<Outcome | undefined>(undefined)
|
||||
const [name, setName] = useState<string>("")
|
||||
const [running, setRunning] = useState<boolean>(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 (
|
||||
<ReportBox
|
||||
players={players}
|
||||
playerA={playerA}
|
||||
playerB={playerB}
|
||||
setPlayerB={setPlayerB}
|
||||
outcome={outcome}
|
||||
setOutcome={setOutcome}
|
||||
name={name}
|
||||
setName={setName}
|
||||
onSubmit={running ? () => {} : onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
12
holycow_frontend/src/holycow.ts
Normal file
12
holycow_frontend/src/holycow.ts
Normal file
|
@ -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,
|
||||
}
|
|
@ -5,6 +5,7 @@ interface Telegram {
|
|||
interface TelegramWebApp {
|
||||
initDataUnsafe?: TelegramWebAppInitData
|
||||
sendData?: (data: string) => void,
|
||||
close?: () => void,
|
||||
}
|
||||
|
||||
interface TelegramWebAppInitData {
|
||||
|
|
Loading…
Reference in a new issue