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
|
license_headers.fish
|
||||||
util/
|
util/
|
||||||
werewolves/Trunk-local.toml
|
werewolves/Trunk-local.toml
|
||||||
|
|
||||||
|
werewolves-old-client/
|
||||||
|
werewolves-old-server/
|
||||||
|
|
|
||||||
129
Cargo.toml
|
|
@ -1,8 +1,133 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
"werewolves",
|
# "werewolves-old-client",
|
||||||
"werewolves-macros",
|
"werewolves-macros",
|
||||||
"werewolves-proto",
|
"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"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
web-sys = { version = "0.3", features = [
|
leptos = { workspace = true }
|
||||||
"HtmlTableCellElement",
|
leptos_router = { workspace = true }
|
||||||
"Event",
|
axum = { workspace = true, optional = true }
|
||||||
"EventTarget",
|
console_error_panic_hook = { version = "0.1", optional = true }
|
||||||
"HtmlImageElement",
|
leptos_axum = { workspace = true, optional = true }
|
||||||
"HtmlDivElement",
|
leptos_meta = { workspace = true }
|
||||||
"HtmlSelectElement",
|
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||||
"HtmlDialogElement",
|
wasm-bindgen = { workspace = true, optional = true }
|
||||||
"DomRect",
|
getrandom = { version = "=0.3.4", optional = true }
|
||||||
"WheelEvent",
|
colored = { version = "3.0", optional = true }
|
||||||
] }
|
pretty_env_logger = { version = "0.5", optional = true }
|
||||||
wasm-bindgen = { version = "=0.2.100" }
|
sqlx = { workspace = true, optional = true }
|
||||||
log = "0.4"
|
tower-http = { workspace = true, optional = true }
|
||||||
rand = { version = "0.9", features = ["small_rng"] }
|
mime-sniffer = { version = "0.1", optional = true }
|
||||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
futures = { version = "0.3", optional = true }
|
||||||
uuid = { version = "*", features = ["js"] }
|
wasm-logger = { version = "0.2" }
|
||||||
yew = { version = "0.22", features = ["csr"] }
|
gloo = { version = "0.11" }
|
||||||
yew-router = "0.19"
|
leptos-use = { workspace = true }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { version = "1.0", optional = true }
|
rand = { workspace = true }
|
||||||
gloo = "0.11"
|
reactive_stores = { version = "0.3" }
|
||||||
wasm-logger = "0.2"
|
axum-extra = { workspace = true, optional = true }
|
||||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
anyhow = { workspace = true, optional = true }
|
||||||
once_cell = "1"
|
bytes = { workspace = true, optional = true }
|
||||||
chrono = { version = "0.4" }
|
fast_qr = { workspace = true, optional = true }
|
||||||
werewolves-macros = { path = "../werewolves-macros" }
|
log.workspace = true
|
||||||
werewolves-proto = { path = "../werewolves-proto" }
|
api.workspace = true
|
||||||
futures = "0.3"
|
uuid.workspace = true
|
||||||
wasm-bindgen-futures = "0.4"
|
chrono.workspace = true
|
||||||
thiserror = { version = "2" }
|
werewolves-macros.workspace = true
|
||||||
convert_case = { version = "0.10" }
|
werewolves-proto.workspace = true
|
||||||
ciborium = { version = "0.2", optional = true }
|
codee.workspace = true
|
||||||
chrono-humanize = { version = "0.2.3", features = ["wasmbind"] }
|
convert_case.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["cbor"]
|
hydrate = [
|
||||||
# default = ["json"]
|
"leptos/hydrate",
|
||||||
cbor = ["dep:ciborium"]
|
"dep:console_error_panic_hook",
|
||||||
json = ["dep:serde_json"]
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||