preliminary port of werewolves to leptos
includes an account system and reworks of both the client and host views
|
|
@ -7,3 +7,6 @@ werewolves/img/icons.svg
|
|||
license_headers.fish
|
||||
util/
|
||||
werewolves/Trunk-local.toml
|
||||
|
||||
werewolves-old-client/
|
||||
werewolves-old-server/
|
||||
|
|
|
|||
129
Cargo.toml
|
|
@ -1,8 +1,133 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = [
|
||||
"werewolves",
|
||||
# "werewolves-old-client",
|
||||
"werewolves-macros",
|
||||
"werewolves-proto",
|
||||
"werewolves-server",
|
||||
# "werewolves-server",
|
||||
"werewolves",
|
||||
"api",
|
||||
]
|
||||
|
||||
[[workspace.metadata.leptos]]
|
||||
watch-additional-files = ["werewolves", "api", "style", "public"]
|
||||
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "werewolves"
|
||||
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||
assets-dir = "public"
|
||||
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# site-addr = "192.168.1.3:3000"
|
||||
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||
# This binary name can be checked in Powershell with Get-Command npx
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
|
||||
# The profile to use for the lib target when compiling for release
|
||||
#
|
||||
# Optional. Defaults to "release".
|
||||
lib-profile-release = "wasm-release"
|
||||
name = "werewolves"
|
||||
bin-package = "werewolves"
|
||||
lib-package = "werewolves"
|
||||
|
||||
[workspace.dependencies]
|
||||
axum = "0.8.1"
|
||||
axum-extra = { version = "0.12", features = ["typed-header"] }
|
||||
cfg-if = "1.0.0"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
console_log = "1.0.0"
|
||||
http = "1.3.1"
|
||||
log = "0.4.27"
|
||||
simple_logger = "5.0.0"
|
||||
thiserror = "2.0.12"
|
||||
wasm-bindgen = "0.2.106"
|
||||
leptos-use = { version = "0.18" }
|
||||
# leptos-use = { path = "../repos/leptos-use" }
|
||||
werewolves-macros = { path = "werewolves-macros" }
|
||||
werewolves-proto = { path = "werewolves-proto" }
|
||||
serde_json = { version = "1" }
|
||||
futures = { version = "*" }
|
||||
codee = { version = "0.3", features = ["msgpack_serde"] }
|
||||
bytes = { version = "1.10" }
|
||||
convert_case = { version = "0.11" }
|
||||
fast_qr = { version = "0.13", features = ["svg"] }
|
||||
anyhow = { version = "1" }
|
||||
uuid = { version = "1.18" }
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio",
|
||||
"postgres",
|
||||
"derive",
|
||||
"macros",
|
||||
"uuid",
|
||||
"chrono",
|
||||
] }
|
||||
argon2 = { version = "0.5" }
|
||||
async-trait = { version = "0.1" }
|
||||
chrono = { version = "0.4" }
|
||||
leptos = { version = "0.8.2" }
|
||||
leptos_axum = { version = "0.8.2" }
|
||||
leptos_meta = { version = "0.8.2" }
|
||||
leptos_router = { version = "0.8.2" }
|
||||
rand = { version = "*" }
|
||||
serde = { version = "1.0.228" }
|
||||
tokio = { version = "1.45.0", features = ["full"] }
|
||||
tower = { version = "0.5.2", features = ["full"] }
|
||||
tower-http = { version = "0.6.4", features = ["full"] }
|
||||
api = { path = "api" }
|
||||
ciborium = { version = "0.2" }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = "full"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bytes = { version = "1.10.1", features = ["serde"] }
|
||||
rand = { version = "0.9", optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
sqlx = { workspace = true, optional = true }
|
||||
axum = { workspace = true, optional = true, features = ["macros"] }
|
||||
axum-extra = { workspace = true, optional = true, features = ["typed-header"] }
|
||||
argon2 = { workspace = true, optional = true }
|
||||
ciborium = { workspace = true }
|
||||
async-trait = { workspace = true, optional = true }
|
||||
werewolves-proto = { workspace = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
|
||||
log.workspace = true
|
||||
leptos.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[features]
|
||||
ssr = [
|
||||
"dep:sqlx",
|
||||
"dep:axum",
|
||||
"dep:axum-extra",
|
||||
"dep:argon2",
|
||||
"dep:rand",
|
||||
"dep:async-trait",
|
||||
"dep:serde_json",
|
||||
"dep:futures",
|
||||
]
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRequest, Request, rejection::BytesRejection},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::headers::Mime;
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use core::fmt::Display;
|
||||
use leptos::server_fn::{
|
||||
ContentType, Decodes, Encodes, Format, FormatType,
|
||||
codec::{Post, Put},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
const CBOR_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/cbor");
|
||||
const PLAIN_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("text/plain");
|
||||
|
||||
#[must_use]
|
||||
pub struct Cbor<T>(pub T);
|
||||
|
||||
impl<T> Cbor<T> {
|
||||
pub const fn new(t: T) -> Self {
|
||||
Self(t)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, S> FromRequest<S> for Cbor<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = CborRejection;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
if !cbor_content_type(req.headers()) {
|
||||
return Err(CborRejection::MissingCborContentType);
|
||||
}
|
||||
|
||||
let bytes = Bytes::from_request(req, state).await?;
|
||||
Ok(Self(ciborium::from_reader::<T, _>(&*bytes)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for Cbor<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
// Extracted into separate fn so it's only compiled once for all T.
|
||||
fn make_response(buf: BytesMut, ser_result: Result<(), CborRejection>) -> Response {
|
||||
match ser_result {
|
||||
Ok(()) => {
|
||||
([(header::CONTENT_TYPE, CBOR_CONTENT_TYPE)], buf.freeze()).into_response()
|
||||
}
|
||||
Err(err) => err.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Use a small initial capacity of 128 bytes like serde_json::to_vec
|
||||
// https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189
|
||||
let mut buf = BytesMut::with_capacity(128).writer();
|
||||
let res = ciborium::into_writer(&self.0, &mut buf)
|
||||
.map_err(|err| CborRejection::SerdeRejection(err.to_string()));
|
||||
make_response(buf.into_inner(), res)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CborRejection {
|
||||
MissingCborContentType,
|
||||
BytesRejection(BytesRejection),
|
||||
DeserializeRejection(String),
|
||||
SerdeRejection(String),
|
||||
}
|
||||
impl<T: Display> From<ciborium::de::Error<T>> for CborRejection {
|
||||
fn from(value: ciborium::de::Error<T>) -> Self {
|
||||
Self::SerdeRejection(match value {
|
||||
ciborium::de::Error::Io(err) => format!("i/o: {err}"),
|
||||
ciborium::de::Error::Syntax(offset) => format!("syntax error at {offset}"),
|
||||
ciborium::de::Error::Semantic(offset, err) => format!(
|
||||
"semantic parse: {err}{}",
|
||||
offset
|
||||
.map(|offset| format!(" at {offset}"))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
ciborium::de::Error::RecursionLimitExceeded => {
|
||||
String::from("the input caused serde to recurse too much")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BytesRejection> for CborRejection {
|
||||
fn from(value: BytesRejection) -> Self {
|
||||
Self::BytesRejection(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for CborRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
CborRejection::MissingCborContentType => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
|
||||
String::from("missing cbor content type"),
|
||||
),
|
||||
CborRejection::BytesRejection(err) => (
|
||||
err.status(),
|
||||
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
|
||||
format!("bytes rejection: {}", err.body_text()),
|
||||
),
|
||||
CborRejection::SerdeRejection(err) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
|
||||
err,
|
||||
),
|
||||
CborRejection::DeserializeRejection(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
|
||||
err,
|
||||
),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn cbor_content_type(headers: &HeaderMap) -> bool {
|
||||
let Some(content_type) = headers.get(header::CONTENT_TYPE) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(content_type) = content_type.to_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(mime) = content_type.parse::<Mime>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
mime.type_() == "application"
|
||||
&& (mime.subtype() == "cbor" || mime.suffix().is_some_and(|name| name == "cbor"))
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
use core::marker::PhantomData;
|
||||
use std::io::Read;
|
||||
|
||||
use bytes::Bytes;
|
||||
use leptos::{
|
||||
server::codee::{Decoder, Encoder},
|
||||
server_fn::{
|
||||
ContentType, Decodes, Encodes, Format, FormatType,
|
||||
codec::{Post, Put},
|
||||
},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
pub type CborPost = Post<CborEncoding>;
|
||||
pub type CborPut = Put<CborEncoding>;
|
||||
|
||||
/// Serializes and deserializes JSON with [`serde_json`].
|
||||
pub struct CborEncoding;
|
||||
|
||||
impl<T> Decoder<T> for CborEncoding
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Error = ciborium::de::Error<std::io::Error>;
|
||||
|
||||
type Encoded = [u8];
|
||||
|
||||
fn decode(val: &Self::Encoded) -> Result<T, Self::Error> {
|
||||
ciborium::from_reader::<T, _>(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Encoder<T> for CborEncoding
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
type Error = ciborium::ser::Error<std::io::Error>;
|
||||
|
||||
type Encoded = Vec<u8>;
|
||||
|
||||
fn encode(val: &T) -> Result<Self::Encoded, Self::Error> {
|
||||
let mut encoded = vec![];
|
||||
ciborium::into_writer(val, &mut encoded)?;
|
||||
Ok(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentType for CborEncoding {
|
||||
const CONTENT_TYPE: &'static str = "application/cbor";
|
||||
}
|
||||
|
||||
impl FormatType for CborEncoding {
|
||||
const FORMAT_TYPE: Format = Format::Binary;
|
||||
}
|
||||
|
||||
impl<T> Encodes<T> for CborEncoding
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
type Error = ciborium::ser::Error<std::io::Error>;
|
||||
|
||||
fn encode(output: &T) -> Result<Bytes, Self::Error> {
|
||||
let mut bytes = Vec::new();
|
||||
ciborium::into_writer(output, &mut bytes)?;
|
||||
Ok(Bytes::from_owner(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Decodes<T> for CborEncoding
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Error = ciborium::de::Error<std::io::Error>;
|
||||
|
||||
fn decode(bytes: Bytes) -> Result<T, Self::Error> {
|
||||
ciborium::from_reader::<T, _>(&*bytes)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
use core::num::NonZeroU8;
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::executor;
|
||||
use sqlx::{Pool, Postgres, query, query_as};
|
||||
use werewolves_proto::{
|
||||
character::CharacterId,
|
||||
error::GameError,
|
||||
game::{Game, GameSettings},
|
||||
message::{CharacterIdentity, Identification, PublicIdentity, dead::DeadChatMessage},
|
||||
player::PlayerId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ServerResult,
|
||||
db::{DatabaseResult, IntoDatabaseResult},
|
||||
error::{DatabaseError, ServerError},
|
||||
game::{GameId, GameRecord, GameRecordState},
|
||||
identity::PlayerIdentity,
|
||||
user::UserId,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameDatabase {
|
||||
pub(super) pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
impl GameDatabase {
|
||||
pub const fn new(pool: Pool<Postgres>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn new_game(&self, host: UserId) -> DatabaseResult<GameRecord> {
|
||||
let record = GameRecord {
|
||||
host,
|
||||
id: GameId::new(),
|
||||
created_at: Utc::now(),
|
||||
game_state: crate::game::GameRecordState::Lobby(GameSettings::default()),
|
||||
};
|
||||
let game_state_json = serde_json::to_value(&record.game_state)
|
||||
.map_err(|err| DatabaseError::Serialization(err.to_string()))?;
|
||||
query!(
|
||||
r#"
|
||||
insert into
|
||||
games (id, host, created_at, game_state)
|
||||
values
|
||||
($1, $2, $3, $4)
|
||||
"#,
|
||||
record.id.into_uuid(),
|
||||
record.host.into_uuid(),
|
||||
record.created_at,
|
||||
game_state_json
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn start_game(&self, game: GameId) -> ServerResult<GameRecord> {
|
||||
let mut tx = self.pool.begin().await.into_db_result()?;
|
||||
let mut record = self.get_game_inner(game, &mut *tx).await?;
|
||||
let game_players = self.get_joined_players_inner(record.id, &mut *tx).await?;
|
||||
|
||||
match &record.game_state {
|
||||
GameRecordState::Lobby(settings) => {
|
||||
let idents = game_players
|
||||
.iter()
|
||||
.map(|p| Identification {
|
||||
player_id: p.player_id,
|
||||
public: p.public.clone(),
|
||||
})
|
||||
.collect::<Box<_>>();
|
||||
settings.check_with_player_list(&idents)?;
|
||||
let with_char_ids = game_players
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
Identification {
|
||||
player_id: p.player_id,
|
||||
public: p.public.clone(),
|
||||
},
|
||||
p.character_id,
|
||||
)
|
||||
})
|
||||
.collect::<Box<_>>();
|
||||
let new_game =
|
||||
Game::new_with_assigned_character_ids(&with_char_ids, settings.clone())?;
|
||||
|
||||
record.game_state = GameRecordState::Started(new_game);
|
||||
}
|
||||
GameRecordState::GameOver(_)
|
||||
| GameRecordState::RoleReveal(_)
|
||||
| GameRecordState::Started(_) => {
|
||||
return Err(GameError::GameAlreadyStarted.into());
|
||||
}
|
||||
}
|
||||
self.store_game_state_inner(&record, &mut *tx).await?;
|
||||
tx.commit().await.into_db_result()?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn get_game(&self, game: GameId) -> DatabaseResult<GameRecord> {
|
||||
self.get_game_inner(game, &self.pool).await
|
||||
}
|
||||
|
||||
async fn get_game_inner<'a, E>(&self, game: GameId, executor: E) -> DatabaseResult<GameRecord>
|
||||
where
|
||||
E: ::sqlx::Executor<'a, Database = Postgres>,
|
||||
{
|
||||
let r = query!(
|
||||
r#"
|
||||
select
|
||||
id, host, created_at, game_state
|
||||
from
|
||||
games
|
||||
where
|
||||
id = $1
|
||||
"#,
|
||||
game.into_uuid(),
|
||||
)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
Ok(GameRecord {
|
||||
id: GameId::from_uuid(r.id),
|
||||
host: UserId::from_uuid(r.host),
|
||||
created_at: r.created_at,
|
||||
game_state: serde_json::from_value(r.game_state)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn store_game_state(&self, record: &GameRecord) -> DatabaseResult<()> {
|
||||
self.store_game_state_inner(record, &self.pool).await
|
||||
}
|
||||
|
||||
async fn store_game_state_inner<'a, E>(
|
||||
&self,
|
||||
record: &GameRecord,
|
||||
executor: E,
|
||||
) -> DatabaseResult<()>
|
||||
where
|
||||
E: ::sqlx::Executor<'a, Database = Postgres>,
|
||||
{
|
||||
let game_state_json = serde_json::to_value(&record.game_state)
|
||||
.map_err(|err| DatabaseError::Serialization(err.to_string()))?;
|
||||
let game_status = match &record.game_state {
|
||||
GameRecordState::Lobby(_) => "Lobby",
|
||||
GameRecordState::RoleReveal(_) => "RoleReveal",
|
||||
GameRecordState::Started(_) => "Started",
|
||||
GameRecordState::GameOver(_) => "GameOver",
|
||||
};
|
||||
query!(
|
||||
r#"
|
||||
update
|
||||
games
|
||||
set
|
||||
game_state = $2,
|
||||
game_status = $3::game_status
|
||||
where
|
||||
id = $1
|
||||
"#,
|
||||
record.id.into_uuid(),
|
||||
game_state_json,
|
||||
game_status as _,
|
||||
)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_player_number(
|
||||
&self,
|
||||
game: GameId,
|
||||
player: UserId,
|
||||
) -> DatabaseResult<Option<NonZeroU8>> {
|
||||
Ok(query!(
|
||||
r#"
|
||||
select
|
||||
number
|
||||
from
|
||||
game_characters
|
||||
where
|
||||
game_id = $1 and player_id = $2
|
||||
"#,
|
||||
game.into_uuid(),
|
||||
player.into_uuid(),
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.number
|
||||
.and_then(|n| NonZeroU8::new(n as u8)))
|
||||
}
|
||||
|
||||
pub async fn set_player_number(
|
||||
&self,
|
||||
game: GameId,
|
||||
player: UserId,
|
||||
number: Option<NonZeroU8>,
|
||||
) -> DatabaseResult<()> {
|
||||
query!(
|
||||
r#"
|
||||
update
|
||||
game_characters
|
||||
set
|
||||
number = $3
|
||||
where
|
||||
game_id = $1 and player_id = $2
|
||||
"#,
|
||||
game.into_uuid(),
|
||||
player.into_uuid(),
|
||||
number.map(|n| n.get() as i32),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_joined_active_game(&self, player: UserId) -> DatabaseResult<Option<GameId>> {
|
||||
Ok(query!(
|
||||
r#"
|
||||
select
|
||||
g.id
|
||||
from
|
||||
game_characters gc
|
||||
join
|
||||
games g on g.id = gc.game_id
|
||||
where
|
||||
gc.player_id = $1 and g.game_status in ('Lobby', 'RoleReveal', 'Started')"#,
|
||||
player.into_uuid(),
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.map(|d| GameId::from_uuid(d.id)))
|
||||
}
|
||||
|
||||
pub async fn join_game(
|
||||
&self,
|
||||
game: GameId,
|
||||
player: UserId,
|
||||
number: Option<NonZeroU8>,
|
||||
) -> ServerResult<()> {
|
||||
let game = self.get_game(game).await?;
|
||||
if game.host == player {
|
||||
return Err(GameError::CannotJoinOwnGame.into());
|
||||
}
|
||||
if !matches!(game.game_state, GameRecordState::Lobby(_)) {
|
||||
return Err(GameError::CannotJoinStartedGame.into());
|
||||
}
|
||||
if let Some(active) = self.get_joined_active_game(player).await? {
|
||||
return Err(ServerError::AlreadyInActiveGame(active));
|
||||
};
|
||||
query!(
|
||||
r#"
|
||||
insert into
|
||||
game_characters (character_id, game_id, player_id, number)
|
||||
values
|
||||
($1, $2, $3, $4)
|
||||
"#,
|
||||
CharacterId::new().into_uuid(),
|
||||
game.id.into_uuid(),
|
||||
player.into_uuid(),
|
||||
number.map(|n| n.get() as i32)
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_db_result()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn leave_game(&self, game: GameId, player: UserId) -> ServerResult<()> {
|
||||
query!(
|
||||
r#"
|
||||
delete from
|
||||
game_characters
|
||||
where
|
||||
game_id = $1 and player_id = $2
|
||||
"#,
|
||||
game.into_uuid(),
|
||||
player.into_uuid(),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_db_result()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_joined_players(&self, game_id: GameId) -> ServerResult<Box<[PlayerIdentity]>> {
|
||||
self.get_joined_players_inner(game_id, &self.pool).await
|
||||
}
|
||||
|
||||
async fn get_joined_players_inner<'a, E>(
|
||||
&self,
|
||||
game_id: GameId,
|
||||
executor: E,
|
||||
) -> ServerResult<Box<[PlayerIdentity]>>
|
||||
where
|
||||
E: ::sqlx::Executor<'a, Database = Postgres>,
|
||||
{
|
||||
Ok(query!(
|
||||
r#"
|
||||
select
|
||||
gc.character_id, gc.player_id, u.pronouns,
|
||||
case when u.display_name is not null then
|
||||
u.display_name
|
||||
else
|
||||
u.username
|
||||
end as name,
|
||||
case when gc.number > 0 then
|
||||
gc.number
|
||||
else
|
||||
null
|
||||
end as "number: i16"
|
||||
from
|
||||
game_characters gc
|
||||
join
|
||||
users u on u.id = gc.player_id
|
||||
where
|
||||
gc.game_id = $1
|
||||
"#,
|
||||
game_id.into_uuid()
|
||||
)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.into_db_result()?
|
||||
.into_iter()
|
||||
.map(|r| PlayerIdentity {
|
||||
player_id: PlayerId::from_uuid(r.player_id),
|
||||
character_id: CharacterId::from_uuid(r.character_id),
|
||||
public: PublicIdentity {
|
||||
name: r.name.unwrap_or_default(),
|
||||
pronouns: r.pronouns,
|
||||
number: r.number.map(|r| r as u8).and_then(NonZeroU8::new),
|
||||
},
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn record_dead_chat_message(
|
||||
&self,
|
||||
game_id: GameId,
|
||||
message: DeadChatMessage,
|
||||
) -> ServerResult<()> {
|
||||
let content = serde_json::to_value(&message.message).into_db_result()?;
|
||||
query!(
|
||||
r#"
|
||||
insert into
|
||||
dead_chat (message_id, game_id, created_at, message)
|
||||
values
|
||||
($1, $2, $3, $4)
|
||||
"#,
|
||||
message.id,
|
||||
game_id.into_uuid(),
|
||||
message.timestamp,
|
||||
content,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_db_result()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_dead_chat(&self, game_id: GameId) -> ServerResult<Box<[DeadChatMessage]>> {
|
||||
Ok(query!(
|
||||
r#"
|
||||
select
|
||||
message_id, created_at, message
|
||||
from
|
||||
dead_chat
|
||||
where
|
||||
game_id = $1
|
||||
order by
|
||||
created_at asc
|
||||
"#,
|
||||
game_id.into_uuid(),
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_db_result()?
|
||||
.into_iter()
|
||||
.map(|r| -> DatabaseResult<_> {
|
||||
Ok(DeadChatMessage {
|
||||
id: r.message_id,
|
||||
timestamp: r.created_at,
|
||||
message: serde_json::from_value(r.message).into_db_result()?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Box<[_]>, _>>()?)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
pub mod game;
|
||||
pub mod user;
|
||||
|
||||
use futures::{future::BoxFuture, stream::BoxStream};
|
||||
use sqlx::{Executor, Pool, Postgres, Transaction};
|
||||
|
||||
use crate::{
|
||||
db::{game::GameDatabase, user::UserDatabase},
|
||||
error::DatabaseError,
|
||||
};
|
||||
|
||||
pub(crate) type DatabaseResult<T> = core::result::Result<T, DatabaseError>;
|
||||
|
||||
trait IntoDatabaseResult<T> {
|
||||
fn into_db_result(self) -> DatabaseResult<T>;
|
||||
}
|
||||
|
||||
impl<T> IntoDatabaseResult<T> for Result<T, ::sqlx::Error> {
|
||||
fn into_db_result(self) -> DatabaseResult<T> {
|
||||
self.map_err(Into::<DatabaseError>::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoDatabaseResult<T> for Result<T, ::serde_json::Error> {
|
||||
fn into_db_result(self) -> DatabaseResult<T> {
|
||||
self.map_err(Into::<DatabaseError>::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Database {
|
||||
pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub const fn new(pool: Pool<Postgres>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub fn user(&self) -> UserDatabase {
|
||||
UserDatabase {
|
||||
pool: self.pool.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn game(&self) -> GameDatabase {
|
||||
GameDatabase {
|
||||
pool: self.pool.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) {
|
||||
log::info!("running migrations");
|
||||
sqlx::migrate!("../migrations")
|
||||
.run(&self.pool)
|
||||
.await
|
||||
.expect("run migrations");
|
||||
log::info!("migrations done");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordVerifier,
|
||||
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
|
||||
use rand::distr::SampleString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::DatabaseResult,
|
||||
error::{DatabaseError, ServerError},
|
||||
limited::FixedLenString,
|
||||
token::{self, TOKEN_LEN},
|
||||
user::{Password, ProfileUpdate, Session, UserId},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserDatabase {
|
||||
pub(super) pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct LoginToken {
|
||||
pub token: String,
|
||||
pub user_id: UserId,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl LoginToken {
|
||||
pub const TOKEN_LONGEVITY: TimeDelta = TimeDelta::days(30);
|
||||
|
||||
pub fn new(user_id: UserId) -> Self {
|
||||
let created_at = Utc::now();
|
||||
let expires_at = created_at
|
||||
.checked_add_signed(Self::TOKEN_LONGEVITY)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"could not add {} time to {created_at}",
|
||||
Self::TOKEN_LONGEVITY
|
||||
)
|
||||
});
|
||||
|
||||
let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), token::TOKEN_LEN);
|
||||
|
||||
Self {
|
||||
token,
|
||||
user_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum GetUserBy<'a> {
|
||||
Username(&'a str),
|
||||
Id(UserId),
|
||||
}
|
||||
|
||||
impl UserDatabase {
|
||||
pub const fn new(pool: Pool<Postgres>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn create_dummy_user(
|
||||
&self,
|
||||
display_name: &str,
|
||||
pronouns: Option<String>,
|
||||
) -> DatabaseResult<User> {
|
||||
let username = Uuid::new_v4().to_string();
|
||||
let id = Uuid::new_v4();
|
||||
let password_hash = String::new();
|
||||
let created_at = Utc::now();
|
||||
let updated_at = created_at;
|
||||
query!(
|
||||
r#"
|
||||
insert into
|
||||
users (id, username, display_name, pronouns, password_hash,
|
||||
dummy, created_at, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4, $5, true, $6, $6)
|
||||
"#,
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
pronouns,
|
||||
password_hash,
|
||||
created_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(User {
|
||||
username,
|
||||
pronouns,
|
||||
created_at,
|
||||
updated_at,
|
||||
password_hash,
|
||||
id: UserId::from_uuid(id),
|
||||
display_name: Some(display_name.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
pronouns: Option<String>,
|
||||
) -> DatabaseResult<User> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
|
||||
let now = chrono::offset::Utc::now();
|
||||
|
||||
let user = User {
|
||||
pronouns,
|
||||
id: UserId::new(),
|
||||
username: username.into(),
|
||||
password_hash,
|
||||
display_name: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
query!(
|
||||
r#"insert into users
|
||||
(id, username, password_hash, pronouns, created_at, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4, $5, $6)"#,
|
||||
user.id.into_uuid(),
|
||||
user.username,
|
||||
user.password_hash,
|
||||
user.pronouns,
|
||||
user.created_at,
|
||||
user.updated_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let sqlx::Error::Database(db_err) = &err
|
||||
&& let Some(constraint) = db_err.constraint()
|
||||
&& constraint == "users_username_unique"
|
||||
{
|
||||
DatabaseError::UserAlreadyExists
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn delete_user(&self, user: User) -> DatabaseResult<()> {
|
||||
query!(
|
||||
r#"
|
||||
delete from
|
||||
users
|
||||
where
|
||||
id = $1
|
||||
"#,
|
||||
user.id.into_uuid(),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
update: ProfileUpdate,
|
||||
) -> DatabaseResult<()> {
|
||||
query!(
|
||||
r#"
|
||||
update
|
||||
users
|
||||
set
|
||||
display_name = $2,
|
||||
pronouns = $3
|
||||
where
|
||||
id = $1
|
||||
"#,
|
||||
user_id.into_uuid(),
|
||||
update.display_name,
|
||||
update.pronouns,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn change_password(&self, user: User, password: Password) -> DatabaseResult<User> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
|
||||
let updated_at = chrono::Utc::now();
|
||||
query!(
|
||||
r#"
|
||||
update
|
||||
users
|
||||
set
|
||||
password_hash = $1,
|
||||
updated_at = $2
|
||||
where
|
||||
id = $3
|
||||
"#,
|
||||
password_hash,
|
||||
updated_at,
|
||||
user.id.into_uuid(),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(User {
|
||||
password_hash,
|
||||
updated_at,
|
||||
..user
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_session(&self, token: &str) -> DatabaseResult<Session> {
|
||||
let rec = query!(
|
||||
r#"
|
||||
select
|
||||
u.id, u.username, u.display_name, u.pronouns,
|
||||
u.created_at, u.updated_at, t.token,
|
||||
t.created_at as token_created_at, t.expires_at
|
||||
from
|
||||
users u
|
||||
join
|
||||
login_tokens t on t.user_id = u.id
|
||||
where
|
||||
t.token = $1
|
||||
limit 1
|
||||
"#,
|
||||
token,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(Session {
|
||||
username: rec.username,
|
||||
display_name: rec.display_name,
|
||||
pronouns: rec.pronouns,
|
||||
user_created_at: rec.created_at,
|
||||
user_updated_at: rec.updated_at,
|
||||
token_created_at: rec.token_created_at,
|
||||
token_expires_at: rec.expires_at,
|
||||
token: unsafe { FixedLenString::new_unchecked(rec.token) },
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, get_user_by: GetUserBy<'_>) -> DatabaseResult<User> {
|
||||
Ok(match get_user_by {
|
||||
GetUserBy::Username(username) => {
|
||||
query_as!(
|
||||
User,
|
||||
r#"
|
||||
select
|
||||
id, username, password_hash,
|
||||
display_name, pronouns,
|
||||
created_at, updated_at
|
||||
from
|
||||
users
|
||||
where
|
||||
username = $1"#,
|
||||
username
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
}
|
||||
GetUserBy::Id(id) => {
|
||||
query_as!(
|
||||
User,
|
||||
r#"
|
||||
select
|
||||
id, username, password_hash,
|
||||
display_name, pronouns,
|
||||
created_at, updated_at
|
||||
from
|
||||
users
|
||||
where
|
||||
id = $1"#,
|
||||
id.into_uuid()
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn verify_login(&self, username: &str, password: &str) -> Result<User, ServerError> {
|
||||
let user = self.get_user(GetUserBy::Username(username)).await?;
|
||||
|
||||
let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.map_err(|err| match err {
|
||||
argon2::password_hash::Error::Password => ServerError::InvalidCredentials,
|
||||
err => {
|
||||
let db_err: DatabaseError = err.into();
|
||||
db_err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn replace_token(&self, token: &str) -> Result<LoginToken, ServerError> {
|
||||
let user = self.check_token(token).await?;
|
||||
let mut tx = self.pool.begin().await.map_err(|err| {
|
||||
log::error!("begin transaction: {err}");
|
||||
ServerError::InternalServerError
|
||||
})?;
|
||||
let new_token = LoginToken::new(user.id);
|
||||
query!(
|
||||
r#" insert into login_tokens
|
||||
(token, user_id, created_at, expires_at)
|
||||
values
|
||||
($1, $2, $3, $4)"#,
|
||||
new_token.token,
|
||||
new_token.user_id.into_uuid(),
|
||||
new_token.created_at,
|
||||
new_token.expires_at
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Into::<DatabaseError>::into)?;
|
||||
query!(
|
||||
r#"
|
||||
delete from
|
||||
login_tokens
|
||||
where
|
||||
token = $1
|
||||
"#,
|
||||
token
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Into::<DatabaseError>::into)?;
|
||||
|
||||
tx.commit().await.map_err(|err| {
|
||||
log::error!("commit transaction: {err}");
|
||||
ServerError::InternalServerError
|
||||
})?;
|
||||
Ok(new_token)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> core::result::Result<Session, ServerError> {
|
||||
let user = self.verify_login(username, password).await?;
|
||||
|
||||
let token = LoginToken::new(user.id);
|
||||
|
||||
query!(
|
||||
r#" insert into login_tokens
|
||||
(token, user_id, created_at, expires_at)
|
||||
values
|
||||
($1, $2, $3, $4)"#,
|
||||
token.token,
|
||||
token.user_id.into_uuid(),
|
||||
token.created_at,
|
||||
token.expires_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Into::<DatabaseError>::into)?;
|
||||
|
||||
Ok(Session {
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
pronouns: user.pronouns,
|
||||
user_created_at: user.created_at,
|
||||
user_updated_at: user.updated_at,
|
||||
token_created_at: token.created_at,
|
||||
token_expires_at: token.expires_at,
|
||||
token: unsafe { FixedLenString::new_unchecked(token.token) },
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_token(&self, token: &str) -> core::result::Result<User, ServerError> {
|
||||
let token: LoginToken = query_as!(
|
||||
LoginToken,
|
||||
r#" select
|
||||
token, user_id, created_at, expires_at
|
||||
from
|
||||
login_tokens
|
||||
where
|
||||
token = $1
|
||||
and
|
||||
expires_at > now()
|
||||
"#,
|
||||
token
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::<DatabaseError>::into)
|
||||
.map_err(|err| match err {
|
||||
DatabaseError::NotFound => ServerError::ExpiredToken,
|
||||
_ => err.into(),
|
||||
})?;
|
||||
|
||||
if Utc::now() >= token.expires_at {
|
||||
return Err(ServerError::ExpiredToken);
|
||||
}
|
||||
|
||||
Ok(self.get_user(GetUserBy::Id(token.user_id)).await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Encode, Decode)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub pronouns: Option<String>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
use leptos::prelude::{ServerFnError, ServerFnErrorErr};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use werewolves_proto::error::GameError;
|
||||
|
||||
use crate::game::GameId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
|
||||
pub enum ServerError {
|
||||
#[error("internal server error")]
|
||||
InternalServerError,
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("user already exists")]
|
||||
UserAlreadyExists,
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("token expired")]
|
||||
ExpiredToken,
|
||||
#[error("connection error")]
|
||||
ConnectionError,
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("you're already in an active game: {0}")]
|
||||
AlreadyInActiveGame(GameId),
|
||||
#[error("{0}")]
|
||||
GameError(#[from] GameError),
|
||||
}
|
||||
|
||||
impl leptos::prelude::FromServerFnError for ServerError {
|
||||
type Encoder = leptos::server_fn::codec::JsonEncoding;
|
||||
|
||||
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
|
||||
match value {
|
||||
ServerFnErrorErr::ServerError(err) => {
|
||||
log::error!("server error: {err}; truncating to ServerError::InternalServerError");
|
||||
ServerError::InternalServerError
|
||||
}
|
||||
ServerFnErrorErr::MiddlewareError(err) => {
|
||||
log::error!(
|
||||
"middleware error: {err}; truncating to ServerError::InternalServerError"
|
||||
);
|
||||
ServerError::InternalServerError
|
||||
}
|
||||
ServerFnErrorErr::Request(err) => {
|
||||
const CONN_ERR: &str = "TypeError: NetworkError when attempting to fetch resource.";
|
||||
if err == CONN_ERR {
|
||||
Self::ConnectionError
|
||||
} else {
|
||||
Self::InvalidRequest(err)
|
||||
}
|
||||
}
|
||||
err => {
|
||||
let t = match &err {
|
||||
ServerFnErrorErr::Registration(_) => "Registration",
|
||||
ServerFnErrorErr::UnsupportedRequestMethod(_) => "UnsupportedRequestMethod",
|
||||
ServerFnErrorErr::Request(_) => "Request",
|
||||
ServerFnErrorErr::ServerError(_) => "ServerError",
|
||||
ServerFnErrorErr::MiddlewareError(_) => "MiddlewareError",
|
||||
ServerFnErrorErr::Deserialization(_) => "Deserialization",
|
||||
ServerFnErrorErr::Serialization(_) => "Serialization",
|
||||
ServerFnErrorErr::Args(_) => "Args",
|
||||
ServerFnErrorErr::MissingArg(_) => "MissingArg",
|
||||
ServerFnErrorErr::Response(_) => "Response",
|
||||
};
|
||||
Self::InvalidRequest(format!("[{t}]: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// impl core::str::FromStr for ServerError {
|
||||
// type Err = core::convert::Infallible;
|
||||
|
||||
// fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// panic!("ServerError::FromStr({s})")
|
||||
// }
|
||||
// }
|
||||
// impl From<ServerFnError> for ServerError {
|
||||
// fn from(err: ServerFnError) -> Self {
|
||||
// match err {
|
||||
// ServerFnError::ServerError(err) => {
|
||||
// log::error!("server error: {err}; truncating to ServerError::InternalServerError");
|
||||
// ServerError::InternalServerError
|
||||
// }
|
||||
// ServerFnError::MiddlewareError(err) => {
|
||||
// log::error!(
|
||||
// "middleware error: {err}; truncating to ServerError::InternalServerError"
|
||||
// );
|
||||
// ServerError::InternalServerError
|
||||
// }
|
||||
// ServerFnError::Request(err) => {
|
||||
// log::error!("[{err}]");
|
||||
// Self::ConnectionError
|
||||
// }
|
||||
// err => {
|
||||
// let t = match &err {
|
||||
// ServerFnError::Registration(_) => "Registration",
|
||||
// ServerFnError::Request(_) => "Request",
|
||||
// ServerFnError::ServerError(_) => "ServerError",
|
||||
// ServerFnError::MiddlewareError(_) => "MiddlewareError",
|
||||
// ServerFnError::Deserialization(_) => "Deserialization",
|
||||
// ServerFnError::Serialization(_) => "Serialization",
|
||||
// ServerFnError::Args(_) => "Args",
|
||||
// ServerFnError::MissingArg(_) => "MissingArg",
|
||||
// ServerFnError::Response(_) => "Response",
|
||||
// ServerFnError::WrappedServerError(_) => "WrappedServerError",
|
||||
// };
|
||||
// Self::InvalidRequest(format!("[{t}]: {err}"))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
impl From<DatabaseError> for ServerError {
|
||||
fn from(err: DatabaseError) -> Self {
|
||||
match err {
|
||||
DatabaseError::NotFound => ServerError::NotFound,
|
||||
DatabaseError::UserAlreadyExists => ServerError::UserAlreadyExists,
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {
|
||||
log::error!(
|
||||
"converting database error into ServerError::InternalServerError: {err}"
|
||||
);
|
||||
ServerError::InternalServerError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("user already exists")]
|
||||
UserAlreadyExists,
|
||||
#[error("password hashing error: {0}")]
|
||||
PasswordHashError(String),
|
||||
#[error("sqlx error: {0}")]
|
||||
SqlxError(String),
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("(de)serialization error: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<serde_json::Error> for DatabaseError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Serialization(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl axum::response::IntoResponse for ServerError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
use axum::{Json, http::StatusCode};
|
||||
|
||||
use crate::cbor::Cbor;
|
||||
|
||||
(
|
||||
match self {
|
||||
ServerError::AlreadyInActiveGame(_)
|
||||
| ServerError::GameError(_)
|
||||
| ServerError::InvalidCredentials
|
||||
| ServerError::InvalidRequest(_)
|
||||
| ServerError::UserAlreadyExists => StatusCode::BAD_REQUEST,
|
||||
ServerError::NotFound => StatusCode::NOT_FOUND,
|
||||
ServerError::ConnectionError | ServerError::InternalServerError => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
ServerError::ExpiredToken => StatusCode::UNAUTHORIZED,
|
||||
},
|
||||
Json(self),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<sqlx::Error> for DatabaseError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
match err {
|
||||
sqlx::Error::RowNotFound => Self::NotFound,
|
||||
_ => Self::SqlxError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<argon2::password_hash::Error> for DatabaseError {
|
||||
fn from(err: argon2::password_hash::Error) -> Self {
|
||||
Self::PasswordHashError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use werewolves_proto::game::{Game, GameSettings, story::GameStory};
|
||||
|
||||
use crate::{id_impl, user::UserId};
|
||||
|
||||
id_impl!(GameId);
|
||||
|
||||
#[cfg_attr(feature = "ssr", derive(::sqlx::FromRow))]
|
||||
#[derive(Debug)]
|
||||
pub struct GameRecord {
|
||||
pub id: GameId,
|
||||
pub host: UserId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub game_state: GameRecordState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GameRecordState {
|
||||
Lobby(GameSettings),
|
||||
RoleReveal(Game),
|
||||
Started(Game),
|
||||
GameOver(GameStory),
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use werewolves_proto::{character::CharacterId, message::PublicIdentity, player::PlayerId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlayerIdentity {
|
||||
pub player_id: PlayerId,
|
||||
pub character_id: CharacterId,
|
||||
pub public: PublicIdentity,
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
#[cfg(feature = "ssr")]
|
||||
pub mod cbor;
|
||||
pub mod cbor_leptos;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod limited;
|
||||
// pub mod routes;
|
||||
pub mod game;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod identity;
|
||||
pub mod message;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod state;
|
||||
pub mod token;
|
||||
pub mod user;
|
||||
|
||||
pub type ServerResult<T> = core::result::Result<T, error::ServerError>;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! id_impl {
|
||||
($name:ident) => {
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize,
|
||||
)]
|
||||
pub struct $name(uuid::Uuid);
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl sqlx::TypeInfo for $name {
|
||||
fn is_null(&self) -> bool {
|
||||
self.0 == uuid::Uuid::nil()
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"uuid"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl sqlx::Type<sqlx::Postgres> for $name {
|
||||
fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
|
||||
<uuid::Uuid as sqlx::Type<sqlx::Postgres>>::type_info()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $name {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer<'q>,
|
||||
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
|
||||
self.0.encode_by_ref(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $name {
|
||||
fn decode(
|
||||
value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
|
||||
) -> Result<Self, sqlx::error::BoxDynError> {
|
||||
Ok(Self(uuid::Uuid::decode(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Uuid> for $name {
|
||||
fn from(value: uuid::Uuid) -> Self {
|
||||
Self::from_uuid(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for uuid::Uuid {
|
||||
fn from(value: $name) -> Self {
|
||||
value.into_uuid()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub const fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
pub const fn into_uuid(self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::str::FromStr for $name {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(uuid::Uuid::from_str(s)?))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
use core::{
|
||||
fmt::Display,
|
||||
ops::{Deref, RangeInclusive},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct FixedLenString<const LEN: usize>(String);
|
||||
|
||||
impl<const LEN: usize> Display for FixedLenString<LEN> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const LEN: usize> FixedLenString<LEN> {
|
||||
pub fn new(s: String) -> Option<Self> {
|
||||
(s.chars().take(LEN + 1).count() == LEN).then_some(Self(s))
|
||||
}
|
||||
pub unsafe fn new_unchecked(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const LEN: usize> Deref for FixedLenString<LEN> {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, const LEN: usize> Deserialize<'de> for FixedLenString<LEN> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ExpectedLen(usize);
|
||||
impl serde::de::Expected for ExpectedLen {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "a string exactly {} characters long", self.0)
|
||||
}
|
||||
}
|
||||
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
|
||||
let char_count = s.chars().take(LEN.saturating_add(1)).count();
|
||||
if char_count != LEN {
|
||||
Err(serde::de::Error::invalid_length(
|
||||
char_count,
|
||||
&ExpectedLen(LEN),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<const LEN: usize> Serialize for FixedLenString<LEN> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ClampedString<const MIN: usize, const MAX: usize>(String);
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> ClampedString<MIN, MAX> {
|
||||
pub const MIN_LEN: usize = MIN;
|
||||
pub const MAX_LEN: usize = MAX;
|
||||
|
||||
pub fn new(s: String) -> Result<Self, RangeInclusive<usize>> {
|
||||
let str_len = s.chars().take(MAX.saturating_add(1)).count();
|
||||
(str_len >= MIN && str_len <= MAX)
|
||||
.then_some(Self(s))
|
||||
.ok_or(MIN..=MAX)
|
||||
}
|
||||
|
||||
pub unsafe fn new_unchecked(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> Display for ClampedString<MIN, MAX> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> Deref for ClampedString<MIN, MAX> {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, const MIN: usize, const MAX: usize> Deserialize<'de> for ClampedString<MIN, MAX> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ExpectedLen(usize, usize);
|
||||
impl serde::de::Expected for ExpectedLen {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"a string between {} and {} characters long",
|
||||
self.0, self.1
|
||||
)
|
||||
}
|
||||
}
|
||||
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
|
||||
let char_count = s.chars().take(MAX.saturating_add(1)).count();
|
||||
if char_count < MIN || char_count > MAX {
|
||||
Err(serde::de::Error::invalid_length(
|
||||
char_count,
|
||||
&ExpectedLen(MIN, MAX),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> Serialize for ClampedString<MIN, MAX> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use werewolves_proto::message::{
|
||||
ClientMessage, ServerToClientMessage,
|
||||
host::{HostMessage, ServerToHostMessage},
|
||||
};
|
||||
|
||||
use crate::token::TokenString;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WrappedServerMessage {
|
||||
Authentication(TokenString),
|
||||
HostMessage(HostMessage),
|
||||
ClientMessage(ClientMessage),
|
||||
}
|
||||
|
||||
impl From<TokenString> for WrappedServerMessage {
|
||||
fn from(value: TokenString) -> Self {
|
||||
Self::Authentication(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HostMessage> for WrappedServerMessage {
|
||||
fn from(value: HostMessage) -> Self {
|
||||
Self::HostMessage(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientMessage> for WrappedServerMessage {
|
||||
fn from(value: ClientMessage) -> Self {
|
||||
Self::ClientMessage(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum IntoClientResponse {
|
||||
Player(ServerToClientMessage),
|
||||
Host(ServerToHostMessage),
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
Options,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Head,
|
||||
Trace,
|
||||
Connect,
|
||||
Patch,
|
||||
}
|
||||
|
||||
pub struct Route {
|
||||
pub method: Method,
|
||||
pub path: &'static str,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
const fn new(method: Method, path: &'static str, authenticated: bool) -> Self {
|
||||
Self {
|
||||
method,
|
||||
path,
|
||||
authenticated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Routes {
|
||||
pub create_user: Route,
|
||||
}
|
||||
|
||||
pub const ROUTES: Routes = Routes {
|
||||
create_user: Route::new(Method::Post, "/api/users", false),
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use futures::lock::Mutex;
|
||||
use leptos::config::LeptosOptions;
|
||||
|
||||
use crate::{db::Database, game::GameId};
|
||||
|
||||
#[cfg_attr(feature = "ssr", derive(axum::extract::FromRef))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
pub leptos_options: LeptosOptions,
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
use crate::{limited::FixedLenString, user::Username};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const TOKEN_LEN: usize = 0x20;
|
||||
|
||||
pub type TokenString = FixedLenString<TOKEN_LEN>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Token {
|
||||
pub token: TokenString,
|
||||
pub username: Username,
|
||||
pub display_name: Option<String>,
|
||||
pub pronouns: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
pub fn login_token(&self) -> TokenLogin {
|
||||
TokenLogin(self.token.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TokenLogin(pub FixedLenString<TOKEN_LEN>);
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl axum_extra::headers::authorization::Credentials for TokenLogin {
|
||||
const SCHEME: &'static str = "Bearer";
|
||||
|
||||
fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|v| FixedLenString::new(v.strip_prefix("Bearer ").unwrap_or(v).to_string()))
|
||||
.map(Self)
|
||||
}
|
||||
|
||||
fn encode(&self) -> axum::http::HeaderValue {
|
||||
axum::http::HeaderValue::from_str(self.0.as_str()).expect("bearer token encode")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use werewolves_proto::player::PlayerId;
|
||||
|
||||
use crate::{limited::ClampedString, token::TokenString};
|
||||
|
||||
pub type Username = ClampedString<1, 0x40>;
|
||||
pub type Password = ClampedString<6, 0x100>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct UserLogin {
|
||||
pub username: Username,
|
||||
pub password: Password,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct ChangePassword {
|
||||
pub current: Password,
|
||||
pub new: Password,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct DeleteUserRequest {
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "ssr", derive(::sqlx::FromRow))]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub pronouns: Option<String>,
|
||||
|
||||
pub user_created_at: DateTime<Utc>,
|
||||
pub user_updated_at: DateTime<Utc>,
|
||||
pub token_created_at: DateTime<Utc>,
|
||||
pub token_expires_at: DateTime<Utc>,
|
||||
pub token: TokenString,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileUpdate {
|
||||
pub display_name: Option<String>,
|
||||
pub pronouns: Option<String>,
|
||||
}
|
||||
|
||||
crate::id_impl!(UserId);
|
||||
|
||||
impl From<PlayerId> for UserId {
|
||||
fn from(value: PlayerId) -> Self {
|
||||
UserId(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserId> for PlayerId {
|
||||
fn from(value: UserId) -> Self {
|
||||
PlayerId::from_uuid(value.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
drop table if exists users cascade;
|
||||
create table users (
|
||||
id uuid not null default gen_random_uuid() primary key,
|
||||
username text not null,
|
||||
display_name text,
|
||||
pronouns text,
|
||||
password_hash text not null,
|
||||
dummy boolean not null default false,
|
||||
|
||||
created_at timestamp with time zone not null,
|
||||
updated_at timestamp with time zone not null,
|
||||
|
||||
check (created_at <= updated_at)
|
||||
);
|
||||
drop index if exists users_username_idx;
|
||||
create index users_username_idx on users (username);
|
||||
drop index if exists users_username_unique;
|
||||
create unique index users_username_unique on users (lower(username));
|
||||
|
||||
drop table if exists login_tokens cascade;
|
||||
create table login_tokens (
|
||||
token text not null primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
created_at timestamp with time zone not null,
|
||||
expires_at timestamp with time zone not null,
|
||||
|
||||
check (created_at < expires_at)
|
||||
);
|
||||
|
||||
|
||||
drop type if exists game_status cascade;
|
||||
create type game_status as enum (
|
||||
'Lobby',
|
||||
'RoleReveal',
|
||||
'Started',
|
||||
'GameOver',
|
||||
'Cancelled'
|
||||
);
|
||||
|
||||
drop table if exists games cascade;
|
||||
create table games (
|
||||
id uuid not null primary key,
|
||||
host uuid not null references users(id) on delete cascade,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
game_state jsonb not null,
|
||||
game_status game_status not null default 'Lobby'
|
||||
);
|
||||
|
||||
drop table if exists game_characters cascade;
|
||||
create table game_characters (
|
||||
character_id uuid not null primary key,
|
||||
game_id uuid not null references games(id) on delete cascade,
|
||||
player_id uuid not null references users(id) on delete cascade,
|
||||
number integer
|
||||
);
|
||||
|
||||
drop index if exists game_characters_player_id_game_id_unique;
|
||||
create unique index game_characters_player_id_game_id_unique on game_characters (player_id, game_id);
|
||||
|
||||
|
||||
drop table if exists dead_chat cascade;
|
||||
create table dead_chat (
|
||||
message_id uuid not null primary key,
|
||||
game_id uuid not null references games(id) on delete cascade,
|
||||
created_at timestamp with time zone not null,
|
||||
message jsonb not null
|
||||
);
|
||||
|
||||
drop index if exists dead_chat_created_at;
|
||||
create index dead_chat_created_at on dead_chat(created_at);
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 979 B After Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 866 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 776 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
|
@ -0,0 +1,409 @@
|
|||
|
||||
.village {
|
||||
--faction-color: $village_color;
|
||||
--faction-border: $village_border;
|
||||
--faction-color-faint: $village_color_faint;
|
||||
--faction-border-faint: $village_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $village_color;
|
||||
border: 1px solid $village_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $village_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $village_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $village_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $village_border_faint;
|
||||
background-color: $village_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $village_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $village_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $village_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $village_border;
|
||||
|
||||
&.faint {
|
||||
color: $village_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wolves {
|
||||
--faction-color: $wolves_color;
|
||||
--faction-border: $wolves_border;
|
||||
--faction-color-faint: $wolves_color_faint;
|
||||
--faction-border-faint: $wolves_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $wolves_color;
|
||||
border: 1px solid $wolves_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $wolves_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $wolves_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $wolves_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $wolves_border_faint;
|
||||
background-color: $wolves_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $wolves_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $wolves_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $wolves_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $wolves_border;
|
||||
|
||||
&.faint {
|
||||
color: $wolves_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.offensive {
|
||||
--faction-color: $offensive_color;
|
||||
--faction-border: $offensive_border;
|
||||
--faction-color-faint: $offensive_color_faint;
|
||||
--faction-border-faint: $offensive_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $offensive_color;
|
||||
border: 1px solid $offensive_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $offensive_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $offensive_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $offensive_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $offensive_border_faint;
|
||||
background-color: $offensive_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $offensive_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $offensive_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $offensive_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $offensive_border;
|
||||
|
||||
&.faint {
|
||||
color: $offensive_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.defensive {
|
||||
--faction-color: $defensive_color;
|
||||
--faction-border: $defensive_border;
|
||||
--faction-color-faint: $defensive_color_faint;
|
||||
--faction-border-faint: $defensive_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $defensive_color;
|
||||
border: 1px solid $defensive_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $defensive_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $defensive_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $defensive_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $defensive_border_faint;
|
||||
background-color: $defensive_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $defensive_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $defensive_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $defensive_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $defensive_border;
|
||||
|
||||
&.faint {
|
||||
color: $defensive_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.intel {
|
||||
--faction-color: $intel_color;
|
||||
--faction-border: $intel_border;
|
||||
--faction-color-faint: $intel_color_faint;
|
||||
--faction-border-faint: $intel_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $intel_color;
|
||||
border: 1px solid $intel_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $intel_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $intel_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $intel_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $intel_border_faint;
|
||||
background-color: $intel_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $intel_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $intel_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $intel_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $intel_border;
|
||||
|
||||
&.faint {
|
||||
color: $intel_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.starts-as-villager {
|
||||
--faction-color: $starts_as_villager_color;
|
||||
--faction-border: $starts_as_villager_border;
|
||||
--faction-color-faint: $starts_as_villager_color_faint;
|
||||
--faction-border-faint: $starts_as_villager_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $starts_as_villager_color;
|
||||
border: 1px solid $starts_as_villager_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $starts_as_villager_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $starts_as_villager_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $starts_as_villager_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $starts_as_villager_border_faint;
|
||||
background-color: $starts_as_villager_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $starts_as_villager_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $starts_as_villager_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $starts_as_villager_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $starts_as_villager_border;
|
||||
|
||||
&.faint {
|
||||
color: $starts_as_villager_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.damned {
|
||||
--faction-color: $damned_color;
|
||||
--faction-border: $damned_border;
|
||||
--faction-color-faint: $damned_color_faint;
|
||||
--faction-border-faint: $damned_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $damned_color;
|
||||
border: 1px solid $damned_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $damned_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $damned_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $damned_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $damned_border_faint;
|
||||
background-color: $damned_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $damned_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $damned_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $damned_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $damned_border;
|
||||
|
||||
&.faint {
|
||||
color: $damned_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drunk {
|
||||
--faction-color: $drunk_color;
|
||||
--faction-border: $drunk_border;
|
||||
--faction-color-faint: $drunk_color_faint;
|
||||
--faction-border-faint: $drunk_border_faint;
|
||||
|
||||
&.box {
|
||||
background-color: $drunk_color;
|
||||
border: 1px solid $drunk_border;
|
||||
|
||||
.selected:not(.faint) {
|
||||
color: white;
|
||||
background-color: $drunk_border;
|
||||
}
|
||||
.selected.faint {
|
||||
color: white;
|
||||
background-color: $drunk_border_faint;
|
||||
}
|
||||
|
||||
&.hover:not(.selected):hover {
|
||||
color: white;
|
||||
background-color: $drunk_border;
|
||||
}
|
||||
|
||||
&.faint:not(.selected) {
|
||||
border: 1px solid $drunk_border_faint;
|
||||
background-color: $drunk_color_faint;
|
||||
|
||||
&.hover:hover {
|
||||
background-color: $drunk_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.underline {
|
||||
text-decoration: $drunk_color underline;
|
||||
|
||||
&.faint {
|
||||
text-decoration: $drunk_color_faint underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-color {
|
||||
color: $drunk_border;
|
||||
|
||||
&.faint {
|
||||
color: $drunk_border_faint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
@use 'sass:color';
|
||||
|
||||
$host_nav_height: 36px;
|
||||
$host_nav_top_pad: 10px;
|
||||
$host_nav_bottom_pad: 10px;
|
||||
$host_nav_total_height: $host_nav_height + $host_nav_top_pad + $host_nav_bottom_pad;
|
||||
|
||||
$wolves_color: rgba(255, 0, 0, 0.7);
|
||||
$village_color: rgba(0, 0, 255, 0.7);
|
||||
$village_border: color.change($village_color, $alpha: 1.0);
|
||||
$wolves_border: color.change($wolves_color, $alpha: 1.0);
|
||||
$intel_color: color.adjust($village_color, $hue: -30deg);
|
||||
$intel_border: color.change($intel_color, $alpha: 1.0);
|
||||
$defensive_color: color.adjust($village_color, $hue: -60deg);
|
||||
$defensive_border: color.change($defensive_color, $alpha: 1.0);
|
||||
$offensive_color: color.adjust($village_color, $hue: 30deg);
|
||||
$offensive_border: color.change($offensive_color, $alpha: 1.0);
|
||||
$starts_as_villager_color: color.adjust($village_color, $hue: 60deg);
|
||||
$starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0);
|
||||
$damned_color: color.adjust($village_color, $hue: 45deg);
|
||||
$damned_border: color.change($damned_color, $alpha: 1.0);
|
||||
$drunk_color: color.adjust($village_color, $hue: 150deg);
|
||||
$drunk_border: color.change($drunk_color, $alpha: 1.0);
|
||||
|
||||
$wolves_border_faint: color.change($wolves_border, $alpha: 0.3);
|
||||
$village_border_faint: color.change($village_border, $alpha: 0.3);
|
||||
$offensive_border_faint: color.change($offensive_border, $alpha: 0.3);
|
||||
$defensive_border_faint: color.change($defensive_border, $alpha: 0.3);
|
||||
$intel_border_faint: color.change($intel_border, $alpha: 0.3);
|
||||
$starts_as_villager_border_faint: color.change($starts_as_villager_border, $alpha: 0.3);
|
||||
$damned_border_faint: color.change($damned_border, $alpha: 0.3);
|
||||
$drunk_border_faint: color.change($drunk_border, $alpha: 0.3);
|
||||
|
||||
$wolves_color_faint: color.change($wolves_color, $alpha: 0.1);
|
||||
$village_color_faint: color.change($village_color, $alpha: 0.1);
|
||||
$offensive_color_faint: color.change($offensive_color, $alpha: 0.1);
|
||||
$defensive_color_faint: color.change($defensive_color, $alpha: 0.1);
|
||||
$intel_color_faint: color.change($intel_color, $alpha: 0.1);
|
||||
$starts_as_villager_color_faint: color.change($starts_as_villager_color, $alpha: 0.1);
|
||||
$damned_color_faint: color.change($damned_color, $alpha: 0.1);
|
||||
$drunk_color_faint: color.change($drunk_color, $alpha: 0.1);
|
||||
|
||||
@import 'faction';
|
||||
|
||||
@mixin flexbox() {
|
||||
display: -webkit-box;
|
||||
display: -moz-box;
|
||||
display: -ms-flexbox;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error_container {
|
||||
position: fixed;
|
||||
top: 3vh;
|
||||
left: 37vw;
|
||||
width: 60vw;
|
||||
height: 94vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
h5,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1ch;
|
||||
width: fit-content;
|
||||
padding: 1ch;
|
||||
|
||||
border: 1px solid red;
|
||||
background-color: rgba(128, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
|
||||
&:focus {
|
||||
outline: 1px solid white;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
|
||||
color: white;
|
||||
// border: 1px solid white;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid rgba(255, 0, 255, 0.5) 2px;
|
||||
}
|
||||
|
||||
&:not(:disabled, .no-hover, .selected):hover {
|
||||
color: black;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: black;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
$disabled_color: rgba(255, 255, 255, 0.7);
|
||||
cursor: default;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid $disabled_color;
|
||||
color: $disabled_color;
|
||||
|
||||
&.current {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup,
|
||||
.signin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1ch;
|
||||
|
||||
font-size: 2em;
|
||||
|
||||
.optional {
|
||||
margin-left: 1ch;
|
||||
font-size: 0.5em;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
nav.header {
|
||||
position: sticky;
|
||||
backdrop-filter: brightness(70%);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
height: $host_nav_height;
|
||||
overflow-x: scroll;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
|
||||
font-size: 1.5em;
|
||||
|
||||
.username {
|
||||
font-size: 0.5em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-left: -1ch;
|
||||
}
|
||||
|
||||
.right-side {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.click-backdrop {
|
||||
z-index: 4;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
z-index: 5;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(1em);
|
||||
|
||||
.dialog-box {
|
||||
border-top: 1px solid white;
|
||||
background-color: black;
|
||||
font-size: 1.5em;
|
||||
|
||||
input {
|
||||
font-size: 1em;
|
||||
width: 60vw;
|
||||
}
|
||||
|
||||
|
||||
.dialog-main {
|
||||
border-left: 1px solid white;
|
||||
border-right: 1px solid white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 40px;
|
||||
gap: 5px;
|
||||
color: white;
|
||||
|
||||
&.full {
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
|
||||
&>button {
|
||||
min-width: 4cm;
|
||||
}
|
||||
|
||||
$close_color: rgba(255, 0, 0, 1);
|
||||
|
||||
.close {
|
||||
border: 1px solid $close_color;
|
||||
color: $close_color;
|
||||
background-color: change-color($color: $close_color, $alpha: 0.1);
|
||||
|
||||
&:hover {
|
||||
background-color: change-color($color: $close_color, $alpha: 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#change-password,
|
||||
#update-profile {
|
||||
.pwless-notice {
|
||||
font-size: 0.7em;
|
||||
word-wrap: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-box {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
form {
|
||||
font-size: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1ch;
|
||||
align-items: center;
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
// width: 100%;
|
||||
gap: 1ch;
|
||||
}
|
||||
|
||||
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-settings-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1ch;
|
||||
height: calc(100vh - $host_nav_total_height - 3ch);
|
||||
|
||||
li {
|
||||
width: 60%;
|
||||
|
||||
margin-left: 0;
|
||||
|
||||
.dialog-modal>button,
|
||||
&>button {
|
||||
font-size: 2em;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logout {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
dialog {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-columns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 89%;
|
||||
gap: 3ch;
|
||||
|
||||
&>* {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
padding: 0 5vw 0 5vw;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background-color: black;
|
||||
border: 1px solid white;
|
||||
padding: 1ch;
|
||||
margin: 5ch;
|
||||
user-select: none;
|
||||
|
||||
&.disconnected {
|
||||
border: 1px solid red;
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.debug-marker {
|
||||
$debug_color: rgba(255, 255, 255, 0.5);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
padding: 1px;
|
||||
user-select: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: 1px solid $debug_color;
|
||||
color: $debug_color;
|
||||
background-color: color.change($debug_color, $alpha: 0.1);
|
||||
}
|
||||
|
||||
.game-settings {
|
||||
padding: 0px 3ch 3ch 3ch;
|
||||
|
||||
.top-bar {
|
||||
font-size: 1.5em;
|
||||
padding: 1ch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
background-color: rgba(255, 0, 255, 0.05);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.role-add-list {
|
||||
margin: 1ch 0 1ch 0;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 3ch;
|
||||
justify-content: space-between;
|
||||
row-gap: 0.5ch;
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5ch;
|
||||
|
||||
.title {
|
||||
padding: 0.5ch;
|
||||
text-align: center;
|
||||
min-width: 15ch;
|
||||
filter: saturate(70%) grayscale(10%);
|
||||
}
|
||||
|
||||
.roles {
|
||||
min-width: 15ch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5ch;
|
||||
|
||||
.add-role {
|
||||
padding: 0.5ch;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setup-slots {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
row-gap: 0.5ch;
|
||||
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setup-slot-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25ch;
|
||||
align-items: flex-start;
|
||||
|
||||
.setup-slot {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.aura,
|
||||
.assignment {
|
||||
margin: 0px;
|
||||
font-size: 0.8em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
word-break: normal;
|
||||
gap: 0.5ch;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-title {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
max-width: 80vw;
|
||||
align-items: flex-start;
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.detail {
|
||||
margin: 0px;
|
||||
font-size: 0.5em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
word-break: normal;
|
||||
gap: 0.5ch;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog .tab-content {
|
||||
max-width: 80vw;
|
||||
min-height: 40vh;
|
||||
|
||||
}
|
||||
|
||||
.toggle-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
row-gap: 0.5ch;
|
||||
gap: 0.5ch;
|
||||
}
|
||||
|
||||
.player-select {
|
||||
flex-grow: 1;
|
||||
|
||||
.identity {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.identity,
|
||||
.identity .name,
|
||||
.identity .pronouns {
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5ch;
|
||||
|
||||
.number {
|
||||
text-shadow: yellow 1px 1px;
|
||||
}
|
||||
|
||||
.pronouns {
|
||||
font-size: 0.5em;
|
||||
opacity: 50%;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.lobby-players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25ch;
|
||||
|
||||
.player-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5ch;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.player {
|
||||
font-size: 1.25em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
height: fit-content;
|
||||
|
||||
&.connected:not(:hover) {
|
||||
background-color: rgba(0, 128, 0, 0.1);
|
||||
border: 1px solid rgba(0, 64, 0, 0.7);
|
||||
|
||||
.number:not(.red) {
|
||||
color: green;
|
||||
text-shadow: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.number.red {
|
||||
text-shadow: rgb(64, 0, 0) 1px 1px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.red {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.player-lobby {
|
||||
margin: 5vh 15vw 5vh 15vw;
|
||||
padding: 3ch;
|
||||
font-size: 1.5em;
|
||||
border: 1px solid rgba(128, 0, 0, 0.7);
|
||||
background-color: rgba(64, 0, 0, 0.3);
|
||||
|
||||
&.joined {
|
||||
border: 1px solid rgba(0, 128, 0, 0.7);
|
||||
background-color: rgba(0, 64, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
margin: 2ch;
|
||||
width: fit-content;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide,
|
||||
.show {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.show {
|
||||
&:hover {
|
||||
&::after {
|
||||
content: "show tutorial";
|
||||
background-color: black;
|
||||
color: white;
|
||||
position: fixed;
|
||||
font-size: 1em;
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
margin-left: 2ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial {
|
||||
font-size: 1.25em;
|
||||
padding: 1ch;
|
||||
border: 1px solid white;
|
||||
background-color: rgba(0, 0, 64, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.sample {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5ch;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.equals {
|
||||
font-size: 2em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ok {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
|
||||
|
|
@ -3,45 +3,80 @@ name = "werewolves"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[dependencies]
|
||||
web-sys = { version = "0.3", features = [
|
||||
"HtmlTableCellElement",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"HtmlImageElement",
|
||||
"HtmlDivElement",
|
||||
"HtmlSelectElement",
|
||||
"HtmlDialogElement",
|
||||
"DomRect",
|
||||
"WheelEvent",
|
||||
] }
|
||||
wasm-bindgen = { version = "=0.2.100" }
|
||||
log = "0.4"
|
||||
rand = { version = "0.9", features = ["small_rng"] }
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
uuid = { version = "*", features = ["js"] }
|
||||
yew = { version = "0.22", features = ["csr"] }
|
||||
yew-router = "0.19"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
gloo = "0.11"
|
||||
wasm-logger = "0.2"
|
||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||
once_cell = "1"
|
||||
chrono = { version = "0.4" }
|
||||
werewolves-macros = { path = "../werewolves-macros" }
|
||||
werewolves-proto = { path = "../werewolves-proto" }
|
||||
futures = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
thiserror = { version = "2" }
|
||||
convert_case = { version = "0.10" }
|
||||
ciborium = { version = "0.2", optional = true }
|
||||
chrono-humanize = { version = "0.2.3", features = ["wasmbind"] }
|
||||
leptos = { workspace = true }
|
||||
leptos_router = { workspace = true }
|
||||
axum = { workspace = true, optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
leptos_axum = { workspace = true, optional = true }
|
||||
leptos_meta = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
wasm-bindgen = { workspace = true, optional = true }
|
||||
getrandom = { version = "=0.3.4", optional = true }
|
||||
colored = { version = "3.0", optional = true }
|
||||
pretty_env_logger = { version = "0.5", optional = true }
|
||||
sqlx = { workspace = true, optional = true }
|
||||
tower-http = { workspace = true, optional = true }
|
||||
mime-sniffer = { version = "0.1", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
wasm-logger = { version = "0.2" }
|
||||
gloo = { version = "0.11" }
|
||||
leptos-use = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
rand = { workspace = true }
|
||||
reactive_stores = { version = "0.3" }
|
||||
axum-extra = { workspace = true, optional = true }
|
||||
anyhow = { workspace = true, optional = true }
|
||||
bytes = { workspace = true, optional = true }
|
||||
fast_qr = { workspace = true, optional = true }
|
||||
log.workspace = true
|
||||
api.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
werewolves-macros.workspace = true
|
||||
werewolves-proto.workspace = true
|
||||
codee.workspace = true
|
||||
convert_case.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["cbor"]
|
||||
# default = ["json"]
|
||||
cbor = ["dep:ciborium"]
|
||||
json = ["dep:serde_json"]
|
||||
hydrate = [
|
||||
"leptos/hydrate",
|
||||
"dep:console_error_panic_hook",
|
||||
"dep:wasm-bindgen",
|
||||
"uuid/js",
|
||||
"dep:getrandom",
|
||||
"getrandom/wasm_js",
|
||||
]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tokio",
|
||||
"dep:leptos_axum",
|
||||
"dep:colored",
|
||||
"dep:pretty_env_logger",
|
||||
"dep:sqlx",
|
||||
"dep:tower-http",
|
||||
"dep:mime-sniffer",
|
||||
"dep:futures",
|
||||
"dep:axum-extra",
|
||||
"dep:anyhow",
|
||||
"dep:bytes",
|
||||
"dep:fast_qr",
|
||||
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"leptos-use/ssr",
|
||||
"leptos-use/axum",
|
||||
"api/ssr",
|
||||
]
|
||||
|
||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
[build]
|
||||
target = "index.html" # The index HTML file to drive the bundling process.
|
||||
html_output = "index.html" # The name of the output HTML file.
|
||||
release = true # Build in release mode.
|
||||
dist = "dist" # The output dir for all final assets.
|
||||
public_url = "/" # The public URL from which assets are to be served.
|
||||
filehash = true # Whether to include hash values in the output file names.
|
||||
inject_scripts = true # Whether to inject scripts (and module preloads) into the finalized output.
|
||||
offline = false # Run without network access
|
||||
frozen = false # Require Cargo.lock and cache are up to date
|
||||
locked = false # Require Cargo.lock is up to date
|
||||
# minify = "on_release" # Control minification: can be one of: never, on_release, always
|
||||
minify = "always" # Control minification: can be one of: never, on_release, always
|
||||
no_sri = false # Allow disabling sub-resource integrity (SRI)
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
};
|
||||
|
||||
const STYLESHEET_PATH: &str = "../style/faction.scss";
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../style/main.scss");
|
||||
println!("cargo::rerun-if-changed=../target/site/pkg/werewolves.wasm");
|
||||
|
||||
let mut sheet_file = File::create(STYLESHEET_PATH).unwrap();
|
||||
let mut out = String::new();
|
||||
for faction in [
|
||||
"village",
|
||||
"wolves",
|
||||
"offensive",
|
||||
"defensive",
|
||||
"intel",
|
||||
"starts-as-villager",
|
||||
"damned",
|
||||
"drunk",
|
||||
] {
|
||||
let name = faction.replace("-", "_");
|
||||
out += format!(
|
||||
r#"
|
||||
.{faction} {{
|
||||
--faction-color: ${name}_color;
|
||||
--faction-border: ${name}_border;
|
||||
--faction-color-faint: ${name}_color_faint;
|
||||
--faction-border-faint: ${name}_border_faint;
|
||||
|
||||
&.box {{
|
||||
background-color: ${name}_color;
|
||||
border: 1px solid ${name}_border;
|
||||
|
||||
.selected:not(.faint) {{
|
||||
color: white;
|
||||
background-color: ${name}_border;
|
||||
}}
|
||||
.selected.faint {{
|
||||
color: white;
|
||||
background-color: ${name}_border_faint;
|
||||
}}
|
||||
|
||||
&.hover:not(.selected):hover {{
|
||||
color: white;
|
||||
background-color: ${name}_border;
|
||||
}}
|
||||
|
||||
&.faint:not(.selected) {{
|
||||
border: 1px solid ${name}_border_faint;
|
||||
background-color: ${name}_color_faint;
|
||||
|
||||
&.hover:hover {{
|
||||
background-color: ${name}_border_faint;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
&.underline {{
|
||||
text-decoration: ${name}_color underline;
|
||||
|
||||
&.faint {{
|
||||
text-decoration: ${name}_color_faint underline;
|
||||
}}
|
||||
}}
|
||||
|
||||
&.text-color {{
|
||||
color: ${name}_border;
|
||||
|
||||
&.faint {{
|
||||
color: ${name}_border_faint;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.as_str();
|
||||
}
|
||||
sheet_file.write_all(out.as_bytes()).unwrap();
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>werewolves</title>
|
||||
<link rel="icon" href="/img/wolf.svg" />
|
||||
<link rel="stylesheet" href="/assets/fonts/liberation-serif.css" />
|
||||
<link data-trunk rel="sass" href="index.scss" />
|
||||
<link data-trunk rel="copy-dir" href="img">
|
||||
<link data-trunk rel="copy-dir" href="assets">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app></app>
|
||||
<error></error>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
pub mod pages {
|
||||
werewolves_macros::include_path!("werewolves/src/app/pages");
|
||||
}
|
||||
|
||||
pub mod components {
|
||||
werewolves_macros::include_path!("werewolves/src/app/components");
|
||||
pub mod input {
|
||||
werewolves_macros::include_path!("werewolves/src/app/components/input");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod class;
|
||||
pub mod storage;
|
||||
|
||||
use codee::string::JsonSerdeCodec;
|
||||
use gloo::storage::Storage;
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||
use leptos_router::{
|
||||
components::{ProtectedRoute, Route, Router, Routes},
|
||||
path,
|
||||
};
|
||||
use leptos_use::storage::{UseStorageOptions, use_local_storage, use_local_storage_with_options};
|
||||
use reactive_stores::Store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
components::Nav,
|
||||
pages::{GamePage, Main, NotFound, Signin, Signup, UserSettings},
|
||||
storage::{
|
||||
LocalStorage, SessionStorage, Stored,
|
||||
user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserSession, UserToken},
|
||||
},
|
||||
},
|
||||
state::{InitOrUpdateStore, SessionState},
|
||||
};
|
||||
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<AutoReload options=options.clone() />
|
||||
<HydrationScripts options />
|
||||
<MetaTags />
|
||||
</head>
|
||||
<body>
|
||||
<App />
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
|
||||
pub struct TutorialSettings {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for TutorialSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Stored for TutorialSettings {
|
||||
const STORAGE_KEY: &str = "tutorial-settings";
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
let auth_store = Store::new(AuthContext::new());
|
||||
provide_context(auth_store);
|
||||
let session_store = Store::new(SessionState::new());
|
||||
provide_context(session_store);
|
||||
Effect::new(move || auth_store.init_or_update());
|
||||
Effect::new(move || session_store.init_or_update());
|
||||
|
||||
let (tut_read, tut_write, _) =
|
||||
use_local_storage::<TutorialSettings, JsonSerdeCodec>(TutorialSettings::STORAGE_KEY);
|
||||
provide_context((tut_read, tut_write));
|
||||
|
||||
let is_logged_in = move || {
|
||||
auth_store
|
||||
.initialized()
|
||||
.get()
|
||||
.then_some(auth_store.session().get().is_some())
|
||||
};
|
||||
let not_logged_in = move || Some(auth_store.session().get().is_none());
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/werewolves.css" />
|
||||
// sets the document title
|
||||
<Title text="werewolves" />
|
||||
|
||||
// content for this welcome page
|
||||
<Router>
|
||||
<main>
|
||||
<Nav />
|
||||
<Routes fallback=NotFound>
|
||||
<Route path=path!("/") view=Main />
|
||||
<ProtectedRoute
|
||||
path=path!("/signin")
|
||||
view=|| view! { <Signin /> }
|
||||
condition=not_logged_in
|
||||
redirect_path=|| "/"
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path=path!("/signup")
|
||||
view=|| view! { <Signup /> }
|
||||
condition=not_logged_in
|
||||
redirect_path=|| "/"
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path=path!("/user/settings")
|
||||
view=UserSettings
|
||||
condition=is_logged_in
|
||||
redirect_path=|| "/"
|
||||
/>
|
||||
<Route path=path!("/games/:id") view=|| view! { <GamePage /> } />
|
||||
</Routes>
|
||||
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
// Copyright (C) 2026 Emilis Bliūdžius
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
use werewolves_proto::{
|
||||
aura::AuraTitle, character::Character, game::Category, role::RoleTitle, team::Team,
|
||||
};
|
||||
|
||||
pub trait PartialClass {
|
||||
fn partial_class(&self) -> Option<&'static str>;
|
||||
}
|
||||
|
||||
impl PartialClass for AuraTitle {
|
||||
fn partial_class(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
AuraTitle::Damned => Some("damned"),
|
||||
AuraTitle::Drunk => Some("drunk"),
|
||||
AuraTitle::Insane
|
||||
| AuraTitle::Bloodlet
|
||||
| AuraTitle::Scapegoat
|
||||
| AuraTitle::RedeemableScapegoat
|
||||
| AuraTitle::VindictiveScapegoat
|
||||
| AuraTitle::SpitefulScapegoat
|
||||
| AuraTitle::InevitableScapegoat => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Class {
|
||||
fn class(&self) -> &'static str;
|
||||
}
|
||||
|
||||
impl Class for Character {
|
||||
fn class(&self) -> &'static str {
|
||||
if let Team::AnyEvil = self.team() {
|
||||
return "damned";
|
||||
}
|
||||
|
||||
self.role_title().category().class()
|
||||
}
|
||||
}
|
||||
|
||||
impl Class for RoleTitle {
|
||||
fn class(&self) -> &'static str {
|
||||
self.category().class()
|
||||
}
|
||||
}
|
||||
|
||||
impl Class for Category {
|
||||
fn class(&self) -> &'static str {
|
||||
match self {
|
||||
Category::Wolves => "wolves",
|
||||
Category::Villager => "village",
|
||||
Category::Intel => "intel",
|
||||
Category::Defensive => "defensive",
|
||||
Category::Offensive => "offensive",
|
||||
Category::StartsAsVillager => "starts-as-villager",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Classes(Vec<String>);
|
||||
impl<I> From<I> for Classes
|
||||
where
|
||||
I: Into<Vec<String>>,
|
||||
{
|
||||
fn from(value: I) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
}
|
||||
impl core::fmt::Display for Classes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0.join(" ").as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AsClasses {
|
||||
fn as_classes(&self) -> Classes;
|
||||
}
|
||||
|
||||
impl AsClasses for [&str] {
|
||||
fn as_classes(&self) -> Classes {
|
||||
Classes(self.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl leptos::tachys::html::class::IntoClass for Classes {
|
||||
type AsyncOutput = Self;
|
||||
type State = (leptos::tachys::renderer::types::Element, Self);
|
||||
type Cloneable = Self;
|
||||
type CloneableOwned = Self;
|
||||
|
||||
fn html_len(&self) -> usize {
|
||||
let len = self.0.len();
|
||||
self.0.iter().map(|c| c.len()).sum::<usize>()
|
||||
+ if len == 2 {
|
||||
1
|
||||
} else if len > 2 {
|
||||
len - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn to_html(self, class: &mut String) {
|
||||
class.push_str(self.0.join(" ").as_str());
|
||||
}
|
||||
|
||||
fn should_overwrite(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn hydrate<const FROM_SERVER: bool>(
|
||||
self,
|
||||
el: &leptos::tachys::renderer::types::Element,
|
||||
) -> Self::State {
|
||||
if !FROM_SERVER {
|
||||
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
|
||||
}
|
||||
(el.clone(), self)
|
||||
}
|
||||
|
||||
fn build(self, el: &leptos::tachys::renderer::types::Element) -> Self::State {
|
||||
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
|
||||
(el.clone(), self)
|
||||
}
|
||||
|
||||
fn rebuild(self, state: &mut Self::State) {
|
||||
let (el, prev) = state;
|
||||
if self != *prev {
|
||||
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
|
||||
}
|
||||
*prev = self;
|
||||
}
|
||||
|
||||
fn into_cloneable(self) -> Self::Cloneable {
|
||||
self
|
||||
}
|
||||
|
||||
fn into_cloneable_owned(self) -> Self::CloneableOwned {
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn dry_resolve(&mut self) {}
|
||||
|
||||
async fn resolve(self) -> Self::AsyncOutput {
|
||||
self
|
||||
}
|
||||
|
||||
fn reset(state: &mut Self::State) {
|
||||
let (el, _prev) = state;
|
||||
leptos::tachys::renderer::Rndr::remove_attribute(el, "class");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn DebugMarker() -> impl IntoView {
|
||||
option_env!("LOCAL_DEBUG").map(|_| {
|
||||
view! { <div class="debug-marker">"DEBUG"</div> }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
use api::error::ServerError;
|
||||
use leptos::{html::Div, prelude::*};
|
||||
use leptos_use::{
|
||||
UseDraggableOptions, UseDraggableReturn, core::Position, use_draggable_with_options,
|
||||
};
|
||||
|
||||
pub trait ViewError: core::error::Error {
|
||||
fn view(&self) -> impl IntoView;
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ErrorBox(msg: RwSignal<Option<String>>) -> impl IntoView {
|
||||
let el = NodeRef::<Div>::new();
|
||||
|
||||
// `style` is a helper string "left: {x}px; top: {y}px;"
|
||||
let UseDraggableReturn { style, .. } = use_draggable_with_options(
|
||||
el,
|
||||
UseDraggableOptions::default().initial_value(Position { x: 40.0, y: 40.0 }),
|
||||
);
|
||||
let content = move || {
|
||||
msg.get().map(|text| {
|
||||
view! {
|
||||
<div class="error_container" hidden=move || msg.get().is_none()>
|
||||
<div node_ref=el style=move || style.get() class="error">
|
||||
<h5>"error"</h5>
|
||||
<p>{text.to_string()}</p>
|
||||
<button on:click=move |ev| {
|
||||
ev.prevent_default();
|
||||
msg.set(None);
|
||||
}>"close"</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
};
|
||||
view! { {content} }
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
use leptos::prelude::*;
|
||||
use werewolves_proto::message::{Identification, PublicIdentity};
|
||||
|
||||
#[component]
|
||||
pub fn IdentityInline(ident: ReadSignal<PublicIdentity>) -> impl IntoView {
|
||||
let number = move || {
|
||||
ident
|
||||
.read()
|
||||
.number
|
||||
.as_ref()
|
||||
.map(|num| view! { <span class="number">{num.get()}</span> }.into_any())
|
||||
.unwrap_or_else(|| {
|
||||
view! { <span class="number red">"?"</span> }
|
||||
.into_any()
|
||||
})
|
||||
};
|
||||
let pronouns = move || {
|
||||
ident.read().pronouns.as_ref().map(|p| {
|
||||
view! { <span class="pronouns">"("{p.clone()}")"</span> }
|
||||
})
|
||||
};
|
||||
view! {
|
||||
<span class="identity">
|
||||
{number} <span class="name">{move || ident.read().name.clone()}</span> {pronouns}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn IdentificationInline(ident: Identification) -> impl IntoView {
|
||||
if !ident.public.name.trim().is_empty() {
|
||||
return view! { <IdentityInline ident=RwSignal::new(ident.public).read_only() /> }
|
||||
.into_any();
|
||||
}
|
||||
view! {
|
||||
<span class="identity">
|
||||
<span class="player-id">{ident.player_id.to_string()}</span>
|
||||
</span>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
use core::fmt::Display;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InputType {
|
||||
#[default]
|
||||
Text,
|
||||
Password,
|
||||
}
|
||||
|
||||
impl Display for InputType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
InputType::Text => f.write_str("text"),
|
||||
InputType::Password => f.write_str("password"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TextInput(
|
||||
#[prop(optional)] label: Option<String>,
|
||||
value: RwSignal<String>,
|
||||
#[prop(optional)] autocomplete: bool,
|
||||
#[prop(optional)] r#type: InputType,
|
||||
) -> impl IntoView {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let label = label.map(|label| {
|
||||
view! { <label for=id.clone()>{label}</label> }
|
||||
});
|
||||
let initial_value = move || value.read().to_string();
|
||||
view! {
|
||||
{label}
|
||||
<input
|
||||
type=r#type.to_string()
|
||||
id=id
|
||||
value=initial_value
|
||||
on:input:target=move |ev| value.set(ev.target().value())
|
||||
autocomplete=autocomplete
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_url;
|
||||
|
||||
#[component]
|
||||
pub fn LinkButton(href: String, mut children: ChildrenFnMut) -> impl IntoView {
|
||||
let url = use_url();
|
||||
let link = move || {
|
||||
let already_open = url.get().path() == href;
|
||||
match already_open {
|
||||
true => view! {
|
||||
<button
|
||||
class:current=already_open
|
||||
disabled=already_open
|
||||
class:no-hover=already_open
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
.into_any(),
|
||||
false => view! {
|
||||
<a href=href.clone()>
|
||||
<button>{children()}</button>
|
||||
</a>
|
||||
}
|
||||
.into_any(),
|
||||
}
|
||||
};
|
||||
view! { {link} }
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
use core::ops::Not;
|
||||
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum DialogMode {
|
||||
#[default]
|
||||
Close,
|
||||
Box,
|
||||
ConfirmOrClose(Callback<()>),
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DialogModal(
|
||||
text: String,
|
||||
#[prop(optional)] open: Option<RwSignal<bool>>,
|
||||
#[prop(optional)] button_class: String,
|
||||
#[prop(default = Box::new(|| ().into_any()))] mut children: ChildrenFnMut,
|
||||
#[prop(optional)] mode: DialogMode,
|
||||
#[prop(default = true)] close_backdrop: bool,
|
||||
) -> impl IntoView {
|
||||
// use generated id if not supplied
|
||||
let open = open.unwrap_or_else(|| RwSignal::new(false));
|
||||
|
||||
let close_cb = {
|
||||
move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
open.set(false);
|
||||
}
|
||||
};
|
||||
let close = {
|
||||
view! {
|
||||
<button on:click=close_cb.clone() class="close">
|
||||
"close"
|
||||
</button>
|
||||
}
|
||||
};
|
||||
let mode_buttons = match mode {
|
||||
DialogMode::Box => ().into_any(),
|
||||
DialogMode::Close => view! { <div class="options">{close}</div> }.into_any(),
|
||||
DialogMode::ConfirmOrClose(on_confirm) => view! {
|
||||
<div class="options">
|
||||
<button on:click=move |_| on_confirm.run(())>{"confirm"}</button>
|
||||
{close}
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
};
|
||||
let dialog_element: NodeRef<leptos::html::Dialog> = NodeRef::new();
|
||||
let on_backdrop_click = {
|
||||
let close_cb = close_cb.clone();
|
||||
move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
if !close_backdrop {
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let Some(dialog) = dialog_element.get() else {
|
||||
log::error!("dialog_element is None");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(Some(dialog_box)) = dialog.query_selector(".dialog-box") else {
|
||||
log::error!(".dialog-box is None");
|
||||
return;
|
||||
};
|
||||
let rect: leptos::web_sys::DomRect = dialog_box.get_bounding_client_rect();
|
||||
|
||||
let is_in_dialog = rect.top() as i32 <= ev.client_y()
|
||||
&& ev.client_y() <= rect.top() as i32 + rect.height() as i32
|
||||
&& rect.left() as i32 <= ev.client_x()
|
||||
&& ev.client_x() <= rect.left() as i32 + rect.width() as i32;
|
||||
|
||||
if !is_in_dialog {
|
||||
close_cb(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let is_open = move || open.get();
|
||||
let modal = view! {
|
||||
<dialog on:click=on_backdrop_click open=is_open node_ref=dialog_element>
|
||||
<div class="dialog">
|
||||
<div class="dialog-box">
|
||||
<div class="dialog-main" class:full=matches!(mode, DialogMode::Box)>
|
||||
{children()}
|
||||
</div>
|
||||
{mode_buttons}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
};
|
||||
|
||||
let on_click = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
open.set(true);
|
||||
};
|
||||
let button = text.is_empty().not().then_some(view! {
|
||||
<button class=button_class on:click=on_click>
|
||||
{text.clone()}
|
||||
</button>
|
||||
});
|
||||
|
||||
view! { <div class="dialog-modal">{button} {modal}</div> }
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
use core::ops::Not;
|
||||
|
||||
use api::{error::ServerError, game::GameId, token::TokenString};
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use leptos_router::hooks::use_url;
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::app::{
|
||||
components::LinkButton,
|
||||
storage::user::{AuthContext, AuthContextStoreFields},
|
||||
};
|
||||
|
||||
#[server]
|
||||
pub async fn get_active_game(token: TokenString) -> Result<Option<GameId>, ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db;
|
||||
let user = db.user().check_token(&token).await?;
|
||||
Ok(db.game().get_joined_active_game(user.id).await?)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn new_game(token: TokenString) -> Result<GameId, ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db;
|
||||
let user = db.user().check_token(&token).await?;
|
||||
Ok(db.game().new_game(user.id).await?.id)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Nav() -> impl IntoView {
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
let home_button = move || view! { <LinkButton href="/".into()>"home"</LinkButton> }.into_any();
|
||||
let active_game = ServerAction::<GetActiveGame>::new();
|
||||
let url = use_url();
|
||||
|
||||
let default_view = move || {
|
||||
view! {
|
||||
{home_button}
|
||||
<LinkButton href="/signin".into()>"signin"</LinkButton>
|
||||
<LinkButton href="/signup".into()>"signup"</LinkButton>
|
||||
}
|
||||
};
|
||||
let buttons = move || {
|
||||
auth.session().get().map(|session| {
|
||||
let username = session.display_name.is_some().then_some(
|
||||
view! { <span class="username">"("{session.username.clone()}")"</span> },
|
||||
);
|
||||
let new_game_action = ServerAction::<NewGame>::new();
|
||||
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(game_id)) = new_game_action.value().get()
|
||||
&& let Err(err) = gloo::utils::window()
|
||||
.location()
|
||||
.replace(format!("/games/{game_id}").as_str())
|
||||
{
|
||||
log::error!("setting window href to [/]: {err:?}");
|
||||
}
|
||||
});
|
||||
let new_game_button = move || {
|
||||
url.read()
|
||||
.path()
|
||||
.strip_prefix("/games/")
|
||||
.map(|url| !url.trim().is_empty())
|
||||
.unwrap_or_default()
|
||||
.not()
|
||||
.then_some({
|
||||
let create_game = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
if let Some(session) = auth.session().get() {
|
||||
new_game_action.dispatch(NewGame {
|
||||
token: session.token.clone(),
|
||||
});
|
||||
} else {
|
||||
log::error!("not signed in!");
|
||||
}
|
||||
};
|
||||
view! { <button on:click=create_game>"new game"</button> }
|
||||
})
|
||||
};
|
||||
let active_game_button = move || {
|
||||
let Some(session) = auth.session().get() else {
|
||||
return None;
|
||||
};
|
||||
if active_game.value().read_untracked().is_none() {
|
||||
active_game.dispatch(GetActiveGame {
|
||||
token: session.token,
|
||||
});
|
||||
}
|
||||
active_game.value().get().and_then(|resp| match resp {
|
||||
Ok(Some(g)) => {
|
||||
let active_path = format!("/games/{g}");
|
||||
Some(view! { <LinkButton href=active_path>"active game"</LinkButton> })
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
log::error!("getting active game: {err}");
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
view! {
|
||||
{home_button}
|
||||
<span class="display-name">{session.name().to_string()}</span>
|
||||
{username}
|
||||
{active_game_button}
|
||||
{new_game_button}
|
||||
<div class="right-side">
|
||||
<LinkButton href="/user/settings".into()>{"⚙️"}</LinkButton>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
};
|
||||
view! {
|
||||
<nav class="header">
|
||||
<Show when=move || auth.session().get().is_some() fallback=default_view>
|
||||
{buttons}
|
||||
</Show>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
use core::num::NonZeroU8;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use werewolves_proto::{
|
||||
message::{Identification, PublicIdentity},
|
||||
player::PlayerId,
|
||||
};
|
||||
|
||||
use crate::app::TutorialSettings;
|
||||
|
||||
pub trait Sample {
|
||||
fn sample() -> Self;
|
||||
}
|
||||
const NAMES: &[&str] = &[
|
||||
"Jeff",
|
||||
"Baroque",
|
||||
"worm",
|
||||
"Fort",
|
||||
"Bejeweled",
|
||||
"Bedazzled",
|
||||
"Comfort Sus",
|
||||
];
|
||||
const PRONOUNS: &[&str] = &[
|
||||
"he/him",
|
||||
"she/her",
|
||||
"they/them",
|
||||
"she/they",
|
||||
"he/they",
|
||||
"it/its",
|
||||
];
|
||||
|
||||
impl Sample for PublicIdentity {
|
||||
fn sample() -> Self {
|
||||
PublicIdentity {
|
||||
name: NAMES[rand::random_range(0..NAMES.len())].to_string(),
|
||||
pronouns: Some(PRONOUNS[rand::random_range(0..PRONOUNS.len())].to_string()),
|
||||
number: NonZeroU8::new(rand::random_range(1..=20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for Identification {
|
||||
fn sample() -> Self {
|
||||
Identification {
|
||||
player_id: PlayerId::new(),
|
||||
public: PublicIdentity::sample(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TutorialBox(children: Children) -> impl IntoView {
|
||||
let sample_hidden = RwSignal::new(false);
|
||||
let (tut_read, _) =
|
||||
expect_context::<(Signal<TutorialSettings>, WriteSignal<TutorialSettings>)>();
|
||||
|
||||
view! {
|
||||
<div class="tutorial-box" hidden=move || !tut_read.read().enabled>
|
||||
<button
|
||||
class="hide"
|
||||
hidden=move || sample_hidden.get()
|
||||
on:click=move |_| sample_hidden.set(true)
|
||||
>
|
||||
"hide"
|
||||
</button>
|
||||
<button
|
||||
class="show"
|
||||
hidden=move || !sample_hidden.get()
|
||||
on:click=move |_| sample_hidden.set(false)
|
||||
>
|
||||
"show"
|
||||
</button>
|
||||
<div class="tutorial" hidden=move || sample_hidden.get()>
|
||||
{children()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Sample(children: Children) -> impl IntoView {
|
||||
view! { <div class="sample">{children()}</div> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Equals() -> impl IntoView {
|
||||
view! { <span class="equals">{"="}</span> }
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
pub mod big;
|
||||
mod host;
|
||||
mod player;
|
||||
|
||||
use api::{
|
||||
cbor_leptos::CborEncoding,
|
||||
message::{IntoClientResponse, WrappedServerMessage},
|
||||
};
|
||||
use codee::{HybridEncoder, binary::MsgpackSerdeCodec};
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks;
|
||||
use leptos_use::{
|
||||
ReconnectLimit, UseEventSourceOptions, UseEventSourceReturn, UseWebSocketOptions,
|
||||
UseWebSocketReturn, core::ConnectionReadyState, use_event_source,
|
||||
use_event_source_with_options, use_websocket, use_websocket_with_options,
|
||||
};
|
||||
use reactive_stores::Store;
|
||||
use werewolves_proto::message::{
|
||||
ClientMessage,
|
||||
host::{HostMessage, ServerToHostMessage},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ConsoleLogError, LogError,
|
||||
app::{
|
||||
components::DebugMarker,
|
||||
pages::game::{host::HostGamePage, player::PlayerGamePage},
|
||||
storage::user::{AuthContext, AuthContextStoreFields},
|
||||
},
|
||||
state::{RedirectAfterSignin, SessionState, SessionStateStoreFields},
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn GamePage() -> impl IntoView {
|
||||
let params = hooks::use_params_map();
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
let state = expect_context::<Store<SessionState>>();
|
||||
Effect::new(move || {
|
||||
params.read().get("id").unwrap_or_default();
|
||||
});
|
||||
let UseWebSocketReturn {
|
||||
ready_state,
|
||||
message,
|
||||
send,
|
||||
open,
|
||||
close,
|
||||
..
|
||||
} = use_websocket_with_options::<
|
||||
WrappedServerMessage,
|
||||
IntoClientResponse,
|
||||
MsgpackSerdeCodec,
|
||||
_,
|
||||
_,
|
||||
>(
|
||||
format!(
|
||||
"/api/games/{}",
|
||||
params.read_untracked().get("id").unwrap_or_default()
|
||||
)
|
||||
.as_str(),
|
||||
UseWebSocketOptions::default().reconnect_limit(ReconnectLimit::Infinite),
|
||||
);
|
||||
let opened = RwSignal::new(false);
|
||||
let host_message = RwSignal::new(None);
|
||||
let player_message = RwSignal::new(None);
|
||||
let host_reply: RwSignal<Option<HostMessage>> = RwSignal::new(None);
|
||||
let player_reply: RwSignal<Option<ClientMessage>> = RwSignal::new(None);
|
||||
let disconnect = RwSignal::new(false);
|
||||
|
||||
Effect::new({
|
||||
let close = close.clone();
|
||||
move || {
|
||||
if disconnect.get() {
|
||||
close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Effect::watch(
|
||||
move || message.get(),
|
||||
move |message, prev, _| {
|
||||
if let Some(prev) = prev
|
||||
&& message == prev
|
||||
{
|
||||
return;
|
||||
}
|
||||
match message.clone() {
|
||||
Some(IntoClientResponse::Host(host_msg)) => {
|
||||
log::debug!("got host message: {:?}", host_msg.title());
|
||||
host_message.set(Some(host_msg));
|
||||
}
|
||||
Some(IntoClientResponse::Player(player_msg)) => {
|
||||
log::debug!("got player message: {:?}", player_msg.title());
|
||||
player_message.set(Some(player_msg));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
Effect::new({
|
||||
let send = send.clone();
|
||||
move || {
|
||||
let Some(reply) = host_reply.get() else {
|
||||
return;
|
||||
};
|
||||
log::debug!("sending host message: {reply:?}");
|
||||
send(&WrappedServerMessage::HostMessage(reply.clone()));
|
||||
host_reply.write_untracked().take();
|
||||
}
|
||||
});
|
||||
Effect::new({
|
||||
let send = send.clone();
|
||||
move || {
|
||||
let Some(reply) = player_reply.get() else {
|
||||
return;
|
||||
};
|
||||
log::debug!("sending client message: {reply:?}");
|
||||
send(&WrappedServerMessage::ClientMessage(reply.clone()));
|
||||
player_reply.write_untracked().take();
|
||||
}
|
||||
});
|
||||
|
||||
let status = move || match ready_state.get() {
|
||||
ConnectionReadyState::Connecting => view! {
|
||||
<div class="status-bar">
|
||||
<DebugMarker />
|
||||
<h1>"connecting..."</h1>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
ConnectionReadyState::Closing => view! {
|
||||
<div class="status-bar">
|
||||
<DebugMarker />
|
||||
<h1>"closing"</h1>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
ConnectionReadyState::Open => {
|
||||
Effect::new({
|
||||
let send = send.clone();
|
||||
let close = close.clone();
|
||||
move || {
|
||||
let o = opened.get();
|
||||
if o {
|
||||
return;
|
||||
}
|
||||
if let Some(token) = auth.token().get() {
|
||||
let auth_message = WrappedServerMessage::Authentication(token.token);
|
||||
log::debug!("sending auth message: {auth_message:?}");
|
||||
send(&auth_message);
|
||||
opened.set(true);
|
||||
} else {
|
||||
close();
|
||||
if let Ok(href) = gloo::utils::window().location().href() {
|
||||
state
|
||||
.redirect_after_signin()
|
||||
.set(Some(RedirectAfterSignin::new(href)));
|
||||
}
|
||||
#[cfg(feature = "hydrate")]
|
||||
gloo::utils::window()
|
||||
.location()
|
||||
.set_href("/")
|
||||
.console_log_err();
|
||||
}
|
||||
}
|
||||
});
|
||||
().into_any()
|
||||
}
|
||||
ConnectionReadyState::Closed => view! {
|
||||
<div class="status-bar disconnected">
|
||||
<DebugMarker />
|
||||
<h1>"disconnected"</h1>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
};
|
||||
|
||||
let status = option_env!("LOCAL_DEBUG").map(|_| status);
|
||||
|
||||
view! {
|
||||
{status}
|
||||
<HostGamePage message=host_message.into() reply=host_reply.write_only() />
|
||||
<PlayerGamePage
|
||||
message=player_message.into()
|
||||
reply=player_reply.write_only()
|
||||
disconnect=disconnect
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn BigScreen() -> impl IntoView {}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
werewolves_macros::include_path!("werewolves/src/app/pages/game/host");
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use werewolves_proto::{
|
||||
game::{Category, GameSettings},
|
||||
message::{
|
||||
PlayerState,
|
||||
host::{HostLobbyMessage, HostMessage, ServerToHostMessage as Srv2Host},
|
||||
},
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn HostGamePage(
|
||||
message: Signal<Option<Srv2Host>>,
|
||||
reply: WriteSignal<Option<HostMessage>>,
|
||||
) -> impl IntoView {
|
||||
let settings = RwSignal::new(GameSettings::default());
|
||||
let qr_mode = RwSignal::new(false);
|
||||
let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([]));
|
||||
let dialog_open = RwSignal::new(false);
|
||||
let open_categories = RwSignal::new(
|
||||
Category::ALL
|
||||
.into_iter()
|
||||
.map(|c| (c, false))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
);
|
||||
|
||||
Effect::watch(
|
||||
move || settings.get(),
|
||||
move |s: &GameSettings, _, _| {
|
||||
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(
|
||||
s.clone(),
|
||||
))));
|
||||
},
|
||||
false,
|
||||
);
|
||||
Effect::watch(
|
||||
move || qr_mode.get(),
|
||||
move |q, _, _| reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetQrMode(*q)))),
|
||||
false,
|
||||
);
|
||||
let content = move || {
|
||||
if let Some(message) = message.get() {
|
||||
match message {
|
||||
Srv2Host::Lobby {
|
||||
players: p,
|
||||
settings: s,
|
||||
qr_mode: q,
|
||||
} => {
|
||||
log::info!("setting setties");
|
||||
settings.set(s);
|
||||
*qr_mode.write_untracked() = q;
|
||||
players.set(p);
|
||||
|
||||
view! {
|
||||
<Settings
|
||||
open_categories=open_categories
|
||||
settings=settings
|
||||
players=players.read_only()
|
||||
qr_mode=qr_mode
|
||||
dialog_open=dialog_open
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
_ => view! { <h2>{format!("{message:#?}")}</h2> }.into_any(),
|
||||
}
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
};
|
||||
|
||||
view! { {content} }
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use werewolves_proto::{
|
||||
message::{Identification, PlayerState, PublicIdentity},
|
||||
player::PlayerId,
|
||||
};
|
||||
|
||||
use crate::app::components::{Equals, IdentificationInline, Sample, TutorialBox};
|
||||
|
||||
#[component]
|
||||
pub fn HostPlayerList(players: ReadSignal<Box<[PlayerState]>>) -> impl IntoView {
|
||||
let players = move || {
|
||||
players
|
||||
.read()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
view! {
|
||||
<button class="player" class:connected=p.connected>
|
||||
<IdentificationInline ident=p.identification.clone() />
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
};
|
||||
let sample_no_number = {
|
||||
let mut id = Identification::sample();
|
||||
id.public.number.take();
|
||||
id
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="lobby-players">
|
||||
<div class="player-list">{players}</div>
|
||||
</div>
|
||||
<TutorialBox>
|
||||
<Sample>
|
||||
<button class="player">
|
||||
<IdentificationInline ident=Identification::sample() />
|
||||
</button>
|
||||
<Equals />
|
||||
<span class="ok">"doesn't have lobby open on phone"</span>
|
||||
</Sample>
|
||||
<Sample>
|
||||
<button class="player connected">
|
||||
<IdentificationInline ident=Identification::sample() />
|
||||
</button>
|
||||
<Equals />
|
||||
<span class="ok">"lobby open on phone"</span>
|
||||
</Sample>
|
||||
<Sample>
|
||||
<button class="player">
|
||||
<IdentificationInline ident=sample_no_number />
|
||||
</button>
|
||||
<Equals />
|
||||
<span class="ok">"no number assigned"</span>
|
||||
</Sample>
|
||||
</TutorialBox>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
use core::ops::Not;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use convert_case::{Case, Casing};
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use werewolves_proto::{
|
||||
aura::AuraTitle,
|
||||
game::{Category, GameSettings, SetupSlot},
|
||||
message::{PlayerState, host::HostMessage},
|
||||
role::{Role, RoleTitle},
|
||||
};
|
||||
|
||||
use crate::app::{
|
||||
class::{AsClasses, Class, PartialClass},
|
||||
components::{DialogModal, DialogMode, IdentityInline},
|
||||
pages::game::host::HostPlayerList,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Settings(
|
||||
settings: RwSignal<GameSettings>,
|
||||
players: ReadSignal<Box<[PlayerState]>>,
|
||||
qr_mode: RwSignal<bool>,
|
||||
dialog_open: RwSignal<bool>,
|
||||
open_categories: RwSignal<HashMap<Category, bool>>,
|
||||
) -> impl IntoView {
|
||||
let slots = move || {
|
||||
settings
|
||||
.read()
|
||||
.slots()
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(move |s| {
|
||||
let signal = RwSignal::new(s);
|
||||
Effect::watch(
|
||||
move || signal.get(),
|
||||
move |slot_update, _, _| settings.write().update_slot(slot_update.clone()),
|
||||
false,
|
||||
);
|
||||
|
||||
view! { <SettingsSetupSlot setup_slot=signal players=players dialog_open=dialog_open /> }
|
||||
})
|
||||
.collect_view()
|
||||
};
|
||||
let qr_mode_btn = move || {
|
||||
let qr_toggle = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
qr_mode.set(!qr_mode.get());
|
||||
};
|
||||
match qr_mode.get() {
|
||||
true => view! { <button on:click=qr_toggle>"disable qr mode"</button> }.into_any(),
|
||||
false => view! { <button on:click=qr_toggle>"enable qr mode"</button> }.into_any(),
|
||||
}
|
||||
};
|
||||
let roles_by_category = {
|
||||
let mut r: HashMap<Category, Vec<RoleTitle>> = HashMap::new();
|
||||
for role in RoleTitle::ALL {
|
||||
if let Some(existing) = r.get_mut(&role.category()) {
|
||||
existing.push(role);
|
||||
} else {
|
||||
r.insert(role.category(), vec![role]);
|
||||
}
|
||||
}
|
||||
r
|
||||
};
|
||||
let ordered_keys = {
|
||||
let mut k = roles_by_category.keys().copied().collect::<Box<_>>();
|
||||
k.sort();
|
||||
k
|
||||
};
|
||||
Effect::new(|| log::debug!("rendering settings"));
|
||||
let categories = ordered_keys
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let roles_by_category = roles_by_category.clone();
|
||||
let roles = move || {
|
||||
open_categories
|
||||
.with(|open| open.get(&c).copied().unwrap_or_default())
|
||||
.then(|| {
|
||||
roles_by_category
|
||||
.get(&c)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|r| {
|
||||
let add_role = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
settings.write().new_slot(r);
|
||||
};
|
||||
let classes =
|
||||
["add-role", r.class(), "faint", "hover", "box"].as_classes();
|
||||
view! {
|
||||
<button class=classes on:click=add_role>
|
||||
{r.to_string().to_case(Case::Title)}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
let toggle = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
let is_open = open_categories
|
||||
.with_untracked(|open| open.get(&c).copied().unwrap_or_default());
|
||||
open_categories.write().insert(c, !is_open);
|
||||
};
|
||||
let classes = ["title", c.class(), "hover", "box"].as_classes();
|
||||
view! {
|
||||
<div class="category">
|
||||
<button class=classes on:click=toggle>
|
||||
{c.to_string()}
|
||||
</button>
|
||||
<div class="roles">{roles}</div>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
.collect_view();
|
||||
|
||||
view! {
|
||||
<div class="game-settings">
|
||||
<div class="top-bar">{qr_mode_btn}</div>
|
||||
<div class="role-add-list">{categories}</div>
|
||||
<div class="setup-slots">{slots}</div>
|
||||
</div>
|
||||
<HostPlayerList players=players />
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
fn SettingsSetupSlot(
|
||||
setup_slot: RwSignal<SetupSlot>,
|
||||
players: ReadSignal<Box<[PlayerState]>>,
|
||||
dialog_open: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let auras = move || {
|
||||
let slot = setup_slot.read();
|
||||
slot.auras.is_empty().not().then(|| {
|
||||
slot.auras
|
||||
.iter()
|
||||
.map(|a| {
|
||||
view! { <span class="aura">{a.to_string().to_case(Case::Title)}</span> }
|
||||
})
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
let assigned_to = move || {
|
||||
setup_slot.read().assign_to.map(|a| {
|
||||
match players
|
||||
.read()
|
||||
.iter()
|
||||
.find(|p| p.identification.player_id == a)
|
||||
{
|
||||
Some(player) => {
|
||||
let ident = RwSignal::new(player.identification.public.clone());
|
||||
view! {
|
||||
<span class="assignment">
|
||||
<IdentityInline ident=ident.read_only() />
|
||||
</span>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
None => {
|
||||
view! { <span class="missing error">"missing player "{a.to_string()}</span> }
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
move || {
|
||||
view! {
|
||||
<div class="setup-slot-container">
|
||||
<DialogModal
|
||||
open=dialog_open
|
||||
mode=DialogMode::Box
|
||||
button_class=[
|
||||
"setup-slot",
|
||||
setup_slot.read().role.category().class(),
|
||||
"faint",
|
||||
"hover",
|
||||
"box",
|
||||
]
|
||||
.as_classes()
|
||||
.to_string()
|
||||
text=setup_slot.read().role.title().to_string().to_case(Case::Title)
|
||||
close_backdrop=true
|
||||
>
|
||||
<SlotSettingsDialogBody setup_slot=setup_slot players=players />
|
||||
</DialogModal>
|
||||
{assigned_to}
|
||||
{auras}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SlotSettingsDialogBody(
|
||||
setup_slot: RwSignal<SetupSlot>,
|
||||
players: ReadSignal<Box<[PlayerState]>>,
|
||||
) -> impl IntoView {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum OpenTab {
|
||||
#[default]
|
||||
Auras,
|
||||
PlayerAssignment,
|
||||
}
|
||||
let tab = RwSignal::new(OpenTab::default());
|
||||
let tab_view = move || match tab.get() {
|
||||
OpenTab::Auras => view! { <AuraSelection setup_slot=setup_slot /> }.into_any(),
|
||||
OpenTab::PlayerAssignment => {
|
||||
view! { <AssignmentSelection setup_slot=setup_slot players=players /> }.into_any()
|
||||
}
|
||||
};
|
||||
move || {
|
||||
let assigned_to = setup_slot
|
||||
.read()
|
||||
.assign_to
|
||||
.as_ref()
|
||||
.and_then(|pid| {
|
||||
players
|
||||
.read()
|
||||
.iter()
|
||||
.find(|p| p.identification.player_id == *pid)
|
||||
.cloned()
|
||||
})
|
||||
.map(|p| p.identification.public)
|
||||
.map(|id| view! { <IdentityInline ident=RwSignal::new(id).read_only() /> }.into_any())
|
||||
.unwrap_or_else(|| view! { "none" }.into_any());
|
||||
view! {
|
||||
<span class=[
|
||||
"role-title",
|
||||
setup_slot.read().role.category().class(),
|
||||
"underline",
|
||||
"text-color",
|
||||
]
|
||||
.as_classes()>
|
||||
{setup_slot.read().role.title().to_string().to_case(Case::Title)}
|
||||
</span>
|
||||
<div class="tabs">
|
||||
<div class="tab">
|
||||
<button
|
||||
on:click=move |_| tab.set(OpenTab::Auras)
|
||||
class:selected=move || matches!(*tab.read(), OpenTab::Auras)
|
||||
>
|
||||
"auras"
|
||||
</button>
|
||||
<span class="detail">"currently: "{setup_slot.read().auras.len()}</span>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<button
|
||||
on:click=move |_| { tab.set(OpenTab::PlayerAssignment) }
|
||||
class:selected=move || matches!(*tab.read(), OpenTab::PlayerAssignment)
|
||||
>
|
||||
"assignments"
|
||||
</button>
|
||||
<span class="detail">"currently: "{assigned_to}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">{tab_view}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AuraSelection(setup_slot: RwSignal<SetupSlot>) -> impl IntoView {
|
||||
let auras = move || {
|
||||
AuraTitle::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|a| setup_slot.read().role.title().can_assign_aura(*a))
|
||||
.map(|aura| {
|
||||
let toggle = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
let mut slot = setup_slot.write();
|
||||
if slot.auras.contains(&aura) {
|
||||
slot.auras.retain(|a| aura != *a);
|
||||
} else {
|
||||
slot.auras.push(aura);
|
||||
}
|
||||
};
|
||||
view! {
|
||||
<button
|
||||
class=["faint", "box", "hover", aura.partial_class().unwrap_or_default()]
|
||||
.as_classes()
|
||||
class:selected=setup_slot.read().auras.contains(&aura)
|
||||
on:click=toggle
|
||||
>
|
||||
{aura.to_string().to_case(Case::Title)}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
};
|
||||
|
||||
view! { <div class="toggle-list">{auras}</div> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AssignmentSelection(
|
||||
setup_slot: RwSignal<SetupSlot>,
|
||||
players: ReadSignal<Box<[PlayerState]>>,
|
||||
) -> impl IntoView {
|
||||
let players = move || {
|
||||
players
|
||||
.read()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let assigned = setup_slot
|
||||
.read()
|
||||
.assign_to
|
||||
.as_ref()
|
||||
.map(|assigned_id| p.identification.player_id == *assigned_id)
|
||||
.unwrap_or_default();
|
||||
let ident = RwSignal::new(p.identification.public.clone());
|
||||
let pid = p.identification.player_id;
|
||||
let assign = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
setup_slot.write().assign_to = assigned.not().then_some(pid);
|
||||
};
|
||||
|
||||
view! {
|
||||
<button on:click=assign class:selected=assigned class="player-select">
|
||||
<IdentityInline ident=ident.read_only() />
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
};
|
||||
|
||||
view! { <div class="toggle-list">{players}</div> }
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
werewolves_macros::include_path!("werewolves/src/app/pages/game/player");
|
||||
|
||||
use convert_case::{Case, Casing};
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use werewolves_proto::{
|
||||
message::{
|
||||
ClientDeadChat, ClientMessage, ServerToClientMessage as Srv2Client, dead::DeadChatMessage,
|
||||
},
|
||||
role::RoleTitle,
|
||||
};
|
||||
|
||||
use crate::{ConsoleLogError, app::components::ErrorBox};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Page {
|
||||
Lobby { joined: bool },
|
||||
RoleReveal { role: RoleTitle },
|
||||
GameInProgress,
|
||||
Sleep,
|
||||
DeadChat,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PlayerGamePage(
|
||||
message: Signal<Option<Srv2Client>>,
|
||||
reply: WriteSignal<Option<ClientMessage>>,
|
||||
disconnect: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let error = RwSignal::new(None);
|
||||
let dead_chat: RwSignal<Option<Vec<DeadChatMessage>>> = RwSignal::new(None);
|
||||
let page: RwSignal<Option<Page>> = RwSignal::new(None);
|
||||
Effect::new(move || {
|
||||
let Some(message) = message.get() else {
|
||||
return;
|
||||
};
|
||||
match message {
|
||||
Srv2Client::Disconnect => disconnect.set(true),
|
||||
Srv2Client::LobbyInfo { joined, .. } => page.set(Some(Page::Lobby { joined })),
|
||||
Srv2Client::GameInProgress => page.set(Some(Page::GameInProgress)),
|
||||
Srv2Client::GameStart { role } => page.set(Some(Page::RoleReveal { role })),
|
||||
Srv2Client::Story(game_story) => todo!("{game_story:#?}"),
|
||||
Srv2Client::Update(_) => {}
|
||||
Srv2Client::DeadChat(dead_chat_messages) => {
|
||||
dead_chat.set(Some(dead_chat_messages.to_vec()));
|
||||
page.set(Some(Page::DeadChat));
|
||||
}
|
||||
Srv2Client::DeadChatMessage(dead_chat_message) => {
|
||||
if let Some(chat) = dead_chat.write().as_mut() {
|
||||
chat.push(dead_chat_message);
|
||||
page.set(Some(Page::DeadChat));
|
||||
} else {
|
||||
reply.set(Some(ClientMessage::DeadChat(ClientDeadChat::GetHistory)));
|
||||
}
|
||||
}
|
||||
Srv2Client::Sleep => page.set(Some(Page::Sleep)),
|
||||
Srv2Client::Reset => {
|
||||
#[cfg(feature = "hydrate")]
|
||||
gloo::utils::window().location().reload().console_log_err();
|
||||
}
|
||||
Srv2Client::Error(err) => error.set(Some(err.to_string())),
|
||||
}
|
||||
// match message {}
|
||||
});
|
||||
let content = move || {
|
||||
let Some(page) = page.get() else {
|
||||
return ().into_any();
|
||||
};
|
||||
match page {
|
||||
Page::DeadChat => todo!("dead chat"),
|
||||
Page::Sleep => view! { <h1>"go to sleep"</h1> }.into_any(),
|
||||
Page::Lobby { joined } => {
|
||||
let click = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
reply.set(Some(if joined {
|
||||
ClientMessage::Goodbye
|
||||
} else {
|
||||
ClientMessage::Hello
|
||||
}));
|
||||
};
|
||||
let text = match joined {
|
||||
true => view! {
|
||||
<h2>"you are in the lobby"</h2>
|
||||
<p>"you're all good c:"</p>
|
||||
}
|
||||
.into_any(),
|
||||
false => view! {
|
||||
<h2>"you are not in the lobby"</h2>
|
||||
<p>"join if you want to play"</p>
|
||||
}
|
||||
.into_any(),
|
||||
};
|
||||
view! {
|
||||
<div class="player-lobby" class:joined=joined>
|
||||
{text}
|
||||
<button on:click=click>{if joined { "leave" } else { "join" }}</button>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
Page::GameInProgress => view! {
|
||||
<div class="status-box">
|
||||
<h1>"this game is currently in progress"</h1>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
Page::RoleReveal { role } => view! {
|
||||
<div class="role-reveal">
|
||||
<span class="role-text">
|
||||
"your role: "
|
||||
<span class="role">{role.to_string().to_case(Case::Title)}</span>
|
||||
</span>
|
||||
<button on:click=move |_| {
|
||||
reply.set(Some(ClientMessage::RoleAck))
|
||||
}>"got it"</button>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
}
|
||||
};
|
||||
view! {
|
||||
<ErrorBox msg=error />
|
||||
{content}
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn HostLobby() -> impl IntoView {}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
use leptos::prelude::*;
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::app::{
|
||||
pages::{Signin, Signup},
|
||||
storage::user::{AuthContext, AuthContextStoreFields, UserSession},
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Main() -> impl IntoView {
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
move || match auth.session().get() {
|
||||
Some(session) => view! { <SignedInMain session=session /> }.into_any(),
|
||||
None => view! { <SignedOutMain /> }.into_any(),
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
pub fn SignedInMain(session: UserSession) -> impl IntoView {
|
||||
view! { <h1>"welcome "<strong>{session.name().to_string()}</strong></h1> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SignedOutMain() -> impl IntoView {
|
||||
view! {
|
||||
<h1>"welcome"</h1>
|
||||
<div class="welcome-columns">
|
||||
<div>
|
||||
<h2>"create a new account"</h2>
|
||||
<h4>"with just a username"</h4>
|
||||
<Signup redirect=false header=false />
|
||||
</div>
|
||||
<div>
|
||||
<h2>"sign in"</h2>
|
||||
<h4>"with an existing account"</h4>
|
||||
<Signin redirect=false header=false />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_meta::provide_meta_context;
|
||||
|
||||
#[component]
|
||||
pub fn NotFound() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<h2>{"not found"}</h2>
|
||||
<h4>"specifically, this is the 404 page"</h4>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
use core::ops::Not;
|
||||
|
||||
use api::user::{Password, Session, Username};
|
||||
use chrono::Utc;
|
||||
use gloo::history::History;
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
components::ErrorBox,
|
||||
storage::user::{AuthContext, PasswordlessUser, UserSession, UserToken},
|
||||
},
|
||||
auth::Signin,
|
||||
};
|
||||
use reactive_stores::Store;
|
||||
|
||||
#[component]
|
||||
pub fn Signin(
|
||||
#[prop(default = true)] redirect: bool,
|
||||
#[prop(optional)] header: bool,
|
||||
) -> impl IntoView {
|
||||
let submit = ServerAction::<Signin>::new();
|
||||
use crate::app::storage::user::AuthContextStoreFields;
|
||||
let error: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
|
||||
if redirect {
|
||||
Effect::new(move || {
|
||||
if let Some(sess) = auth.session().get()
|
||||
&& sess.expiry > Utc::now()
|
||||
&& let Err(err) = gloo::utils::window().location().replace("/")
|
||||
{
|
||||
log::error!("setting window href to [/]: {err:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let user = RwSignal::new(String::new());
|
||||
|
||||
let pass = RwSignal::new(String::new());
|
||||
|
||||
Effect::new(move || {
|
||||
if let Some(pwless) = auth.passwordless().get() {
|
||||
user.set(pwless.username.to_string());
|
||||
pass.set(pwless.password.to_string());
|
||||
}
|
||||
});
|
||||
let click = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
let username = match Username::new(user.get()) {
|
||||
Ok(u) => u,
|
||||
Err(exp_range) => {
|
||||
error.set(Some(format!(
|
||||
"username must be between {} and {} characters",
|
||||
exp_range.start(),
|
||||
exp_range.end()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let password = match Password::new(pass.get()) {
|
||||
Ok(p) => p,
|
||||
Err(exp_range) => {
|
||||
error.set(Some(format!(
|
||||
"password must be between {} and {} characters",
|
||||
exp_range.start(),
|
||||
exp_range.end()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
submit.dispatch(Signin { username, password });
|
||||
};
|
||||
Effect::new(move || match submit.value().get() {
|
||||
Some(Ok(sess)) => {
|
||||
log::info!("user logged in");
|
||||
auth.token().write().replace(UserToken {
|
||||
token: sess.token.clone(),
|
||||
expiry: sess.token_expires_at,
|
||||
});
|
||||
auth.session().write().replace(sess.into());
|
||||
if let Err(err) = gloo::utils::window().location().set_href("/") {
|
||||
log::error!("setting window href to [/]: {err:?}");
|
||||
submit.clear();
|
||||
}
|
||||
}
|
||||
Some(Err(err)) => error.set(Some(err.to_string())),
|
||||
None => {}
|
||||
});
|
||||
|
||||
let submit = move || {
|
||||
view! { <button on:click=click>"sign in"</button> }
|
||||
};
|
||||
let user_input = move || {
|
||||
view! {
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value=move || user.get()
|
||||
on:input:target=move |ev| user.set(ev.target().value())
|
||||
autocomplete=false
|
||||
/>
|
||||
}
|
||||
};
|
||||
let pass_input = move || {
|
||||
view! {
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value=move || pass.get()
|
||||
on:input:target=move |ev| pass.set(ev.target().value())
|
||||
autocomplete=false
|
||||
/>
|
||||
}
|
||||
};
|
||||
view! {
|
||||
<form class="signin">
|
||||
<h3 hidden=header.not()>"sign in"</h3>
|
||||
<label for="username">"username"</label>
|
||||
{user_input}
|
||||
<label for="password">"password"</label>
|
||||
{pass_input}
|
||||
{submit}
|
||||
<ErrorBox msg=error />
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
use core::ops::Not;
|
||||
|
||||
use api::{
|
||||
cbor_leptos::CborPost,
|
||||
error::ServerError,
|
||||
user::{Password, Session, Username},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gloo::history::History;
|
||||
use leptos::{
|
||||
ev::{Event, MouseEvent},
|
||||
prelude::*,
|
||||
};
|
||||
use leptos_meta::*;
|
||||
use rand::distr::SampleString;
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
components::{ErrorBox, input::TextInput},
|
||||
storage::{
|
||||
LocalStorage,
|
||||
user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserSession, UserToken},
|
||||
},
|
||||
},
|
||||
auth::Signin,
|
||||
};
|
||||
|
||||
#[leptos::server(CreateUser, input = CborPost)]
|
||||
pub async fn create_user(
|
||||
username: Username,
|
||||
password: Password,
|
||||
pronouns: Option<String>,
|
||||
) -> core::result::Result<api::user::UserId, ServerError> {
|
||||
let user = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user()
|
||||
.create(&username, &password, pronouns)
|
||||
.await
|
||||
.map_err(Into::<ServerError>::into)?;
|
||||
log::info!("created user: {user:?}");
|
||||
Ok(user.id)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Signup(
|
||||
#[prop(default = true)] redirect: bool,
|
||||
#[prop(default = true)] header: bool,
|
||||
) -> impl IntoView {
|
||||
move || {
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
let user = RwSignal::new(String::new());
|
||||
let pronouns = RwSignal::new(String::new());
|
||||
|
||||
if redirect {
|
||||
Effect::new(move || {
|
||||
if let Some(sess) = auth.session().get()
|
||||
&& sess.expiry > Utc::now()
|
||||
&& let Err(err) = gloo::utils::window().location().replace("/")
|
||||
{
|
||||
log::error!("setting window href to [/]: {err:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let submit = ServerAction::<CreateUser>::new();
|
||||
let error = RwSignal::new(None);
|
||||
let generated_password = Password::new(
|
||||
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), Password::MAX_LEN),
|
||||
)
|
||||
.expect("generate password max length");
|
||||
|
||||
let click = {
|
||||
let user = user.read_only();
|
||||
let password = generated_password.clone();
|
||||
move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
let username = match Username::new(user.get().clone()) {
|
||||
Ok(u) => u,
|
||||
Err(exp_range) => {
|
||||
error.set(Some(format!(
|
||||
"username must be between {} and {} characters",
|
||||
exp_range.start(),
|
||||
exp_range.end()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let pronouns = {
|
||||
let p = pronouns.get();
|
||||
p.trim().is_empty().not().then_some(p.trim().to_string())
|
||||
};
|
||||
submit.dispatch(CreateUser {
|
||||
username,
|
||||
pronouns,
|
||||
password: password.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let login_action = ServerAction::<Signin>::new();
|
||||
let result = move || {
|
||||
let value = submit.value().get()?;
|
||||
match value {
|
||||
Ok(_) => {
|
||||
let creds = PasswordlessUser {
|
||||
username: unsafe { Username::new_unchecked(user.get_untracked()) },
|
||||
password: generated_password.clone(),
|
||||
};
|
||||
auth.passwordless().write().replace(creds.clone());
|
||||
login_action.dispatch(Signin {
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
Some(view! {
|
||||
<h2>"created user successfully"</h2>
|
||||
<a href="/signin">
|
||||
<button>"sign in"</button>
|
||||
</a>
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
error.write().replace(err.to_string());
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
Effect::new(move || match login_action.value().get() {
|
||||
Some(Ok(sess)) => {
|
||||
auth.token().set(Some(UserToken {
|
||||
token: sess.token.clone(),
|
||||
expiry: sess.token_expires_at,
|
||||
}));
|
||||
auth.session().set(Some(sess.into()));
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
error.set(Some(format!("error signing in after signup: {err}")));
|
||||
}
|
||||
None => {}
|
||||
});
|
||||
|
||||
view! {
|
||||
<form class="signup">
|
||||
<h3 hidden=header.not()>"sign up"</h3>
|
||||
<TextInput value=user label="username".into() />
|
||||
<label for="pronouns">"pronouns"<span class="optional">"(optional)"</span></label>
|
||||
|
||||
<input id="pronouns" on:input:target=move |ev| pronouns.set(ev.target().value()) />
|
||||
{{
|
||||
view! { <button on:click=click.clone()>"sign up"</button> }
|
||||
}}
|
||||
{result}
|
||||
<ErrorBox msg=error />
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
werewolves_macros::include_path!("werewolves/src/app/pages/user_settings");
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::app::{
|
||||
TutorialSettings,
|
||||
storage::user::{AuthContext, AuthContextStoreFields},
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn UserSettings() -> impl IntoView {
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
let log_out = {
|
||||
let click = move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
auth.session().set(None);
|
||||
auth.token().set(None);
|
||||
};
|
||||
view! { <button on:click=click>"log out"</button> }
|
||||
};
|
||||
let (tut_read, tut_write) =
|
||||
expect_context::<(Signal<TutorialSettings>, WriteSignal<TutorialSettings>)>();
|
||||
let tutorial_toggle_button = move || {
|
||||
match tut_read.read().enabled {
|
||||
true => view! { <button on:click=move |_| tut_write.write().enabled = false>"disable tutorials"</button> }
|
||||
.into_any(),
|
||||
false => view! {
|
||||
<button on:click=move |_| tut_write.write().enabled = true>"enable tutorials"</button>
|
||||
}.into_any(),
|
||||
}
|
||||
};
|
||||
view! {
|
||||
<ul class="user-settings-list">
|
||||
<li>
|
||||
<UpdateProfileButton />
|
||||
</li>
|
||||
<li>
|
||||
<ChangePasswordButton />
|
||||
</li>
|
||||
<li>{tutorial_toggle_button}</li>
|
||||
<li class="logout">{log_out}</li>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
use api::{cbor_leptos::CborPost, error::ServerError, user::Password};
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::app::{
|
||||
components::{DialogModal, ErrorBox},
|
||||
storage::user::{AuthContext, AuthContextStoreFields},
|
||||
};
|
||||
#[server(input = CborPost)]
|
||||
pub async fn change_password(
|
||||
token: String,
|
||||
current: Password,
|
||||
new: Password,
|
||||
) -> Result<(), ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user();
|
||||
let user = db.check_token(token.as_str()).await?;
|
||||
db.verify_login(&user.username, ¤t).await?;
|
||||
db.change_password(user.clone(), new).await?;
|
||||
log::info!("changed password for {}", user.username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ChangePasswordButton() -> impl IntoView {
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
let error = RwSignal::new(None);
|
||||
let action = ServerAction::<ChangePassword>::new();
|
||||
let current = RwSignal::new(String::new());
|
||||
Effect::new(move || {
|
||||
if let Some(pwless) = auth.passwordless().get() {
|
||||
current.set(pwless.password.to_string());
|
||||
}
|
||||
});
|
||||
let new = RwSignal::new(String::new());
|
||||
let new_confirm = RwSignal::new(String::new());
|
||||
let submit_click = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
error.set(None);
|
||||
let current = match Password::new(current.get()) {
|
||||
Ok(c) => c,
|
||||
Err(exp_range) => {
|
||||
error.set(Some(format!(
|
||||
"current password must be between {} and {} characters",
|
||||
exp_range.start(),
|
||||
exp_range.end()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if new.get() != new_confirm.get() {
|
||||
error.set(Some("password confirmation does not match".into()));
|
||||
return;
|
||||
}
|
||||
let new = match Password::new(new.get()) {
|
||||
Ok(c) => c,
|
||||
Err(exp_range) => {
|
||||
error.set(Some(format!(
|
||||
"new password must be between {} and {} characters",
|
||||
exp_range.start(),
|
||||
exp_range.end()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
action.dispatch(ChangePassword {
|
||||
new,
|
||||
current,
|
||||
token: auth
|
||||
.token()
|
||||
.get()
|
||||
.map(|t| t.token.to_string())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
log::info!("in submit_click");
|
||||
};
|
||||
Effect::new(move || {
|
||||
let resp = action.value().get();
|
||||
if let Some(resp) = resp.as_ref() {
|
||||
log::debug!("response: {resp:?}");
|
||||
}
|
||||
match resp {
|
||||
Some(Ok(_)) => {
|
||||
log::info!("password changed!");
|
||||
auth.passwordless().set(None);
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
error.set(Some(err.to_string()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
let pwless_info = move || {
|
||||
auth.passwordless().get().map(|_| {
|
||||
view! {
|
||||
<p class="pwless-notice">
|
||||
<strong>"notice:"</strong>
|
||||
<em>
|
||||
" once you change your password for the first time, this site will no longer"
|
||||
" autofill the signin/password fields for you, as you (or your browser) "
|
||||
"will be responsible for managing that infomation"
|
||||
</em>
|
||||
</p>
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<DialogModal text="change password".into() close_backdrop=false>
|
||||
<ErrorBox msg=error />
|
||||
<form>
|
||||
<div class="form-fields">
|
||||
<label for="current-password">"current password"</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
on:input:target=move |ev| current.set(ev.target().value())
|
||||
value=move || current.get()
|
||||
/>
|
||||
<label for="new-password">"new password"</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
on:input:target=move |ev| new.set(ev.target().value())
|
||||
/>
|
||||
<label for="confirm-new-password">"confirm new password"</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
on:input:target=move |ev| new_confirm.set(ev.target().value())
|
||||
/>
|
||||
</div>
|
||||
{pwless_info}
|
||||
<button on:click=submit_click>"submit"</button>
|
||||
</form>
|
||||
</DialogModal>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
use core::ops::Not;
|
||||
|
||||
use api::{
|
||||
cbor_leptos::CborPost,
|
||||
error::ServerError,
|
||||
token::TokenString,
|
||||
user::{ProfileUpdate, Session},
|
||||
};
|
||||
use leptos::{ev::MouseEvent, prelude::*};
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::app::{
|
||||
components::{DialogModal, ErrorBox},
|
||||
storage::user::{AuthContext, AuthContextStoreFields},
|
||||
};
|
||||
|
||||
#[server(input = CborPost)]
|
||||
pub async fn update_profile(token: TokenString, update: ProfileUpdate) -> Result<(), ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user();
|
||||
let user = db.check_token(&token).await?;
|
||||
db.update_profile(user.id, update).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trim_or_none(s: String) -> Option<String> {
|
||||
s.trim().is_empty().not().then_some(s.trim().to_string())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn UpdateProfileButton() -> impl IntoView {
|
||||
let auth = expect_context::<Store<AuthContext>>();
|
||||
let error = RwSignal::new(None);
|
||||
let display_name = RwSignal::new(String::new());
|
||||
let pronouns = RwSignal::new(String::new());
|
||||
Effect::new(move || {
|
||||
if let Some(session) = auth.session().get() {
|
||||
if let Some(d) = session.display_name {
|
||||
display_name.set(d);
|
||||
}
|
||||
if let Some(p) = session.pronouns {
|
||||
pronouns.set(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
let update_req = RwSignal::new(None);
|
||||
let update_action = ServerAction::<UpdateProfile>::new();
|
||||
let submit = move |ev: MouseEvent| {
|
||||
ev.prevent_default();
|
||||
update_action.clear();
|
||||
let Some(session) = auth.session().get() else {
|
||||
return;
|
||||
};
|
||||
let update = UpdateProfile {
|
||||
token: session.token.clone(),
|
||||
update: ProfileUpdate {
|
||||
display_name: trim_or_none(display_name.get()),
|
||||
pronouns: trim_or_none(pronouns.get()),
|
||||
},
|
||||
};
|
||||
update_req.set(Some(update.clone()));
|
||||
update_action.dispatch(update.clone());
|
||||
};
|
||||
Effect::new(move || match update_action.value().get() {
|
||||
Some(Ok(_)) => {
|
||||
if let Some(input) = update_req.get()
|
||||
&& let Some(sess) = auth.session().write().as_mut()
|
||||
{
|
||||
sess.display_name = input.update.display_name;
|
||||
sess.pronouns = input.update.pronouns;
|
||||
}
|
||||
}
|
||||
Some(Err(err)) => error.set(Some(err.to_string())),
|
||||
None => {}
|
||||
});
|
||||
let on_success = move || {
|
||||
update_action
|
||||
.value()
|
||||
.get()
|
||||
.map(|v| v.is_ok())
|
||||
.unwrap_or_default()
|
||||
.then_some(view! { <p>"profile updated"</p> })
|
||||
};
|
||||
view! {
|
||||
<DialogModal text="update profile".into() close_backdrop=false>
|
||||
<ErrorBox msg=error />
|
||||
<form>
|
||||
<label for="display-name">"display name"</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
autocomplete="nickname"
|
||||
value=move || display_name.get()
|
||||
on:input:target=move |ev| display_name.set(ev.target().value())
|
||||
/>
|
||||
<label for="pronouns">"pronouns"</label>
|
||||
<input
|
||||
id="pronouns"
|
||||
type="text"
|
||||
autocomplete="pronouns"
|
||||
value=move || pronouns.get()
|
||||
on:input:target=move |ev| pronouns.set(ev.target().value())
|
||||
/>
|
||||
<button on:click=submit>"update"</button>
|
||||
{on_success}
|
||||
</form>
|
||||
</DialogModal>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
pub mod user;
|
||||
|
||||
use core::fmt::Debug;
|
||||
|
||||
use gloo::storage::Storage;
|
||||
use leptos::{
|
||||
prelude::{ArcReadSignal, ArcWriteSignal, Effect, Signal, WriteSignal},
|
||||
server::codee::string::JsonSerdeCodec,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
pub trait Stored:
|
||||
Debug + Clone + PartialEq + Send + Sync + Serialize + DeserializeOwned + 'static
|
||||
{
|
||||
const STORAGE_KEY: &str;
|
||||
}
|
||||
|
||||
pub trait DeleteFn: Fn() + Clone + Send + Sync {}
|
||||
impl<F: Fn() + Clone + Send + Sync> DeleteFn for F {}
|
||||
|
||||
pub trait LocalStorage: Stored {
|
||||
fn from_local_storage() -> Option<Self> {
|
||||
gloo::storage::LocalStorage::get(Self::STORAGE_KEY).ok()
|
||||
}
|
||||
fn store_in_local_storage(&self) {
|
||||
gloo::storage::LocalStorage::set(Self::STORAGE_KEY, self)
|
||||
.expect(format!("storing object at [{}] in local storage", Self::STORAGE_KEY).as_str())
|
||||
}
|
||||
fn delete_from_local_storage() {
|
||||
gloo::storage::LocalStorage::delete(Self::STORAGE_KEY);
|
||||
}
|
||||
fn set_in_local_storage(value: Option<Self>) {
|
||||
gloo::storage::LocalStorage::set(Self::STORAGE_KEY, value)
|
||||
.expect(format!("storing object at [{}] in local storage", Self::STORAGE_KEY).as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses session storage which ends when the tab closes
|
||||
pub trait SessionStorage: Stored {
|
||||
fn from_session_storage() -> Option<Self> {
|
||||
gloo::storage::SessionStorage::get(Self::STORAGE_KEY).ok()
|
||||
}
|
||||
fn store_in_session_storage(&self) {
|
||||
gloo::storage::SessionStorage::set(Self::STORAGE_KEY, self).expect(
|
||||
format!(
|
||||
"storing object at [{}] in session storage",
|
||||
Self::STORAGE_KEY
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
}
|
||||
fn delete_from_session_storage() {
|
||||
gloo::storage::SessionStorage::delete(Self::STORAGE_KEY);
|
||||
}
|
||||
fn set_in_session_storage(value: Option<Self>) {
|
||||
gloo::storage::SessionStorage::set(Self::STORAGE_KEY, value).expect(
|
||||
format!(
|
||||
"storing object at [{}] in session storage",
|
||||
Self::STORAGE_KEY
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CookiesStorage: Stored {
|
||||
fn from_cookie() -> (Signal<Option<Self>>, WriteSignal<Option<Self>>) {
|
||||
leptos_use::use_cookie::<Self, JsonSerdeCodec>(Self::STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
use api::{
|
||||
limited::FixedLenString,
|
||||
token::TOKEN_LEN,
|
||||
user::{Password, Session, Username},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use leptos::{
|
||||
prelude::{Action, ArcRwSignal, Get, GetUntracked, Set},
|
||||
server::codee::string::JsonSerdeCodec,
|
||||
};
|
||||
use reactive_stores::{Field, Patch, Store};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
app::storage::{LocalStorage, SessionStorage, Stored},
|
||||
state::InitOrUpdateStore,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Store)]
|
||||
pub struct UserToken {
|
||||
pub token: FixedLenString<TOKEN_LEN>,
|
||||
pub expiry: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Stored for UserToken {
|
||||
const STORAGE_KEY: &str = "user-login-token";
|
||||
}
|
||||
|
||||
impl LocalStorage for UserToken {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserSession {
|
||||
pub expiry: DateTime<Utc>,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub pronouns: Option<String>,
|
||||
pub token: FixedLenString<TOKEN_LEN>,
|
||||
}
|
||||
|
||||
impl From<Session> for UserSession {
|
||||
fn from(value: Session) -> Self {
|
||||
Self {
|
||||
expiry: value.token_expires_at,
|
||||
username: value.username,
|
||||
display_name: value.display_name,
|
||||
pronouns: value.pronouns,
|
||||
token: value.token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserSession {
|
||||
pub fn name(&self) -> &str {
|
||||
self.display_name
|
||||
.as_ref()
|
||||
.unwrap_or(&self.username)
|
||||
.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Stored for UserSession {
|
||||
const STORAGE_KEY: &str = "user-session";
|
||||
}
|
||||
|
||||
impl SessionStorage for UserSession {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PasswordlessUser {
|
||||
pub username: Username,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
impl Stored for PasswordlessUser {
|
||||
const STORAGE_KEY: &str = "passwordless-user-details";
|
||||
}
|
||||
|
||||
impl LocalStorage for PasswordlessUser {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Store)]
|
||||
pub struct AuthContext {
|
||||
/// `token` is stored in [LocalStorage] and does not contain
|
||||
/// up-to-date user info that's stored in `session` (using [SessionStorage])
|
||||
token: Option<UserToken>,
|
||||
session: Option<UserSession>,
|
||||
passwordless: Option<PasswordlessUser>,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl Default for AuthContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
token: None,
|
||||
session: None,
|
||||
passwordless: None,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl InitOrUpdateStore for Store<AuthContext> {
|
||||
fn init_or_update(&self) {
|
||||
if !self.initialized().get() {
|
||||
self.session().set(UserSession::from_session_storage());
|
||||
self.token().set(UserToken::from_local_storage());
|
||||
self.passwordless()
|
||||
.set(PasswordlessUser::from_local_storage());
|
||||
self.initialized().set(true);
|
||||
Action::new(move |auth: &Store<AuthContext>| crate::auth::try_auto_signin(*auth))
|
||||
.dispatch(*self);
|
||||
} else {
|
||||
UserSession::set_in_session_storage(self.session().get());
|
||||
UserToken::set_in_local_storage(self.token().get());
|
||||
PasswordlessUser::set_in_local_storage(self.passwordless().get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// Copyright (C) 2025-2026 Emilis Bliūdžius
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
werewolves_macros::static_links!("werewolves/assets" relative to "werewolves/");
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
use api::{
|
||||
cbor_leptos::CborPost,
|
||||
error::ServerError,
|
||||
limited::{ClampedString, FixedLenString},
|
||||
token::{TOKEN_LEN, Token, TokenString},
|
||||
user::{Password, Session, Username},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use leptos::prelude::*;
|
||||
use reactive_stores::Store;
|
||||
|
||||
use crate::app::storage::{
|
||||
LocalStorage,
|
||||
user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserSession, UserToken},
|
||||
};
|
||||
|
||||
pub async fn try_auto_signin(ctx: Store<AuthContext>) -> Result<(), ServerError> {
|
||||
if let Some(sess) = ctx.session().read().as_ref() {
|
||||
if sess.expiry > Utc::now() {
|
||||
return Ok(());
|
||||
} else {
|
||||
ctx.session().write().take();
|
||||
// ctx.token().write().take();
|
||||
}
|
||||
}
|
||||
if let Some(token) = ctx.token().get() {
|
||||
if token.expiry > Utc::now() {
|
||||
match replace_token(ctx, token.token).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(ServerError::ExpiredToken) => {
|
||||
log::warn!("login token expired (though recorded as not?)");
|
||||
ctx.token().write().take();
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.token().write().take();
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(PasswordlessUser { username, password }) = PasswordlessUser::from_local_storage()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let token = signin(username.clone(), password).await?;
|
||||
|
||||
ctx.token().set(Some(UserToken {
|
||||
token: token.token.clone(),
|
||||
expiry: token.token_expires_at,
|
||||
}));
|
||||
ctx.session().set(Some(UserSession {
|
||||
expiry: token.token_expires_at,
|
||||
username: username.to_string(),
|
||||
display_name: token.display_name,
|
||||
pronouns: token.pronouns,
|
||||
token: token.token,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn replace_token(
|
||||
ctx: Store<AuthContext>,
|
||||
token: TokenString,
|
||||
) -> Result<UserSession, ServerError> {
|
||||
let new_token = replace_auth_token(token).await?;
|
||||
let session = UserSession {
|
||||
expiry: new_token.expires_at,
|
||||
username: new_token.username.to_string(),
|
||||
display_name: new_token.display_name,
|
||||
pronouns: new_token.pronouns,
|
||||
token: new_token.token.clone(),
|
||||
};
|
||||
ctx.token().set(Some(UserToken {
|
||||
token: new_token.token,
|
||||
expiry: new_token.expires_at,
|
||||
}));
|
||||
ctx.session().set(Some(session.clone()));
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[leptos::server(Signin, input = CborPost)]
|
||||
pub async fn signin(username: Username, password: Password) -> Result<Session, ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user();
|
||||
let sess = db.login(&username, &password).await?;
|
||||
|
||||
log::info!("user logged in: {}", sess.username);
|
||||
|
||||
Ok(sess)
|
||||
}
|
||||
|
||||
#[server(VerifyToken, input = CborPost)]
|
||||
pub async fn verify_token(token: FixedLenString<TOKEN_LEN>) -> Result<(), ServerError> {
|
||||
use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user()
|
||||
.check_token(token.as_str())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(input = CborPost)]
|
||||
pub async fn get_me(token: FixedLenString<TOKEN_LEN>) -> Result<Session, ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user();
|
||||
db.check_token(&token).await?;
|
||||
let sess = db.get_session(&token).await?;
|
||||
|
||||
Ok(sess)
|
||||
}
|
||||
|
||||
#[server(input = CborPost)]
|
||||
pub async fn replace_auth_token(token: FixedLenString<TOKEN_LEN>) -> Result<Token, ServerError> {
|
||||
let db = use_context::<api::state::AppState>()
|
||||
.expect("no app state")
|
||||
.db
|
||||
.user();
|
||||
let token = db.replace_token(&token).await?;
|
||||
let user = db
|
||||
.get_user(api::db::user::GetUserBy::Id(token.user_id))
|
||||
.await
|
||||
.map_err(Into::<ServerError>::into)?;
|
||||
Ok(Token {
|
||||
token: FixedLenString::new(token.token.clone()).ok_or_else(|| {
|
||||
log::error!(
|
||||
"token [{}] was not of fixed length {TOKEN_LEN}",
|
||||
token.token,
|
||||
);
|
||||
ServerError::InternalServerError
|
||||
})?,
|
||||
username: unsafe { ClampedString::new_unchecked(user.username) },
|
||||
display_name: user.display_name,
|
||||
pronouns: user.pronouns,
|
||||
created_at: token.created_at,
|
||||
expires_at: token.expires_at,
|
||||
})
|
||||
}
|
||||