error enum for errorbox instead of String + setup

This commit is contained in:
emilis 2026-02-20 00:13:44 +00:00
parent d3d2819e12
commit 9bba472917
No known key found for this signature in database
28 changed files with 961 additions and 107 deletions

1
Cargo.lock generated
View File

@ -4061,6 +4061,7 @@ dependencies = [
"serde", "serde",
"sorted-vec", "sorted-vec",
"sqlx", "sqlx",
"thiserror 2.0.17",
"tokio", "tokio",
"tower-http", "tower-http",
"uuid", "uuid",

16
style/icon.scss Normal file
View File

@ -0,0 +1,16 @@
.icon-fit {
height: 1em;
}
.icon {
width: 32px;
height: 32px;
&:hover {
filter: contrast(120%) brightness(120%);
}
}
.icon-shrink {
flex-shrink: 1;
}

View File

@ -5,17 +5,30 @@ $host_nav_top_pad: 10px;
$host_nav_bottom_pad: 10px; $host_nav_bottom_pad: 10px;
$host_nav_total_height: $host_nav_height + $host_nav_top_pad + $host_nav_bottom_pad; $host_nav_total_height: $host_nav_height + $host_nav_top_pad + $host_nav_bottom_pad;
$wolves_color: rgba(255, 0, 0, 0.7); $red1: #f62b5a;
$village_color: rgba(0, 0, 255, 0.7); $red2: #ff4b50;
$green1: #47b413;
$green2: #36d450;
$yellow1: #e3c401;
$yellow2: #e9e836;
$blue1: #24acd4;
$blue2: #5dc5f8;
$magenta1: #f2affd;
$magenta2: #feabf2;
$cyan1: #13c299;
$cyan2: #24dfc4;
$wolves_color: oklch(0.4836 0.1982 28.87);
$village_color: oklch(0.3817 0.261 264.95);
$village_border: color.change($village_color, $alpha: 1.0); $village_border: color.change($village_color, $alpha: 1.0);
$wolves_border: color.change($wolves_color, $alpha: 1.0); $wolves_border: color.change($wolves_color, $alpha: 1.0);
$intel_color: color.adjust($village_color, $hue: -30deg); $intel_color: oklch(0.4556 0.109276 242.5749);
$intel_border: color.change($intel_color, $alpha: 1.0); $intel_border: color.change($intel_color, $alpha: 1.0);
$defensive_color: rgba(0, 128, 32, 0.9); //color.adjust(rgba(0, 16, 128, 0.9), $hue: -60deg); $defensive_color: oklch(0.4509 0.077867 189.9496);
$defensive_border: color.change($defensive_color, $alpha: 1.0); $defensive_border: color.change($defensive_color, $alpha: 1.0);
$offensive_color: color.adjust($village_color, $hue: 30deg); $offensive_color: oklch(0.4342 0.2309 298.01);
$offensive_border: color.change($offensive_color, $alpha: 1.0); $offensive_border: color.change($offensive_color, $alpha: 1.0);
$starts_as_villager_color: color.adjust($village_color, $hue: 60deg); $starts_as_villager_color: oklch(0.4864 0.2059 346.12);
$starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0); $starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0);
$damned_color: color.adjust($village_color, $hue: 45deg); $damned_color: color.adjust($village_color, $hue: 45deg);
$damned_border: color.change($damned_color, $alpha: 1.0); $damned_border: color.change($damned_color, $alpha: 1.0);
@ -41,6 +54,8 @@ $damned_color_faint: color.change($damned_color, $alpha: 0.1);
$drunk_color_faint: color.change($drunk_color, $alpha: 0.1); $drunk_color_faint: color.change($drunk_color, $alpha: 0.1);
@import 'faction'; @import 'faction';
@import 'setup';
@import 'icon';
@mixin flexbox() { @mixin flexbox() {
display: -webkit-box; display: -webkit-box;
@ -57,6 +72,16 @@ body {
color: white; color: white;
} }
.big-screen-wrapper {
margin: 0;
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
user-select: none;
}
.error_container { .error_container {
position: fixed; position: fixed;
top: 3vh; top: 3vh;
@ -861,3 +886,44 @@ form {
} }
} }
} }
.category {
text-align: center;
display: flex;
flex-direction: column;
&.add-list {
@media only screen and (max-width : 599px) {
width: 100%;
}
@media only screen and (min-width : 600px) {
width: 160px;
}
.hidden {
display: none;
}
width: auto;
flex-wrap: wrap;
gap: 1px;
&>.title {
font-size: 0.5em !important;
margin-bottom: 0px !important;
cursor: pointer;
}
.category-list {
gap: 1px;
}
}
& .title {
text-shadow: black 1px 1px;
margin-bottom: 10px;
}
}

108
style/setup.scss Normal file
View File

@ -0,0 +1,108 @@
.setup-screen {
.inactive {
filter: brightness(0%);
}
margin: 2%;
font-size: 1.5em;
.setup {
display: grid;
grid: auto-flow / 1fr 1fr 1fr;
gap: 5vw;
row-gap: 2ch;
}
}
.setup-category {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.25ch;
text-align: left;
align-items: center;
.title {
padding: 0.1ch;
text-align: center;
text-shadow: black 1px 1px;
width: 100%;
flex-grow: 1;
margin-bottom: 0.25ch;
font-size: 1.25em;
}
.count {
padding: 0 0.5ch 0 0.5ch;
&.invisible {
opacity: 0%;
}
}
.slot {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
.attributes {
margin-left: 10px;
align-self: flex-end;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 10px;
}
.role {
flex-grow: 1;
text-shadow: black 1px 1px;
width: 100%;
filter: saturate(40%);
padding: 0.25ch 0 0.25ch 1ch;
}
.wakes {
$wakes_color: oklch(0.9195 0.1839 109.60356514768961);
border: 2px solid $wakes_color;
box-shadow: 0 0 3px $wakes_color;
}
}
}
.qrcode {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
z-index: 100;
position: fixed;
top: 0;
left: 0;
margin: 5vw;
width: 90vw;
height: 90vh;
gap: 1cm;
img {
height: 70%;
width: 100%;
}
.details {
font-size: 5vw;
border: 1px solid $village_border;
background-color: color.change($village_color, $alpha: 0.3);
text-align: center;
&>* {
margin-top: 0.5cm;
margin-bottom: 0.5cm;
}
}
}

View File

@ -125,10 +125,10 @@ macro_rules! id_impl {
} }
impl core::str::FromStr for $name { impl core::str::FromStr for $name {
type Err = uuid::Error; type Err = ::uuid::Error;
fn from_str(s: &str) -> core::result::Result<Self, Self::Err> { fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(Self(uuid::Uuid::from_str(s)?)) uuid::Uuid::parse_str(s).map(Self)
} }
} }
}; };

View File

@ -40,6 +40,7 @@ werewolves-macros.workspace = true
werewolves-proto.workspace = true werewolves-proto.workspace = true
codee.workspace = true codee.workspace = true
convert_case.workspace = true convert_case.workspace = true
thiserror.workspace = true
sorted-vec.workspace = true sorted-vec.workspace = true
[features] [features]

View File

@ -10,10 +10,10 @@ pub mod components {
} }
pub mod class; pub mod class;
pub mod error;
pub mod storage; pub mod storage;
use codee::string::JsonSerdeCodec; use codee::string::JsonSerdeCodec;
use gloo::storage::Storage;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{Link, MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_meta::{Link, MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_router::{ use leptos_router::{
@ -27,7 +27,8 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
app::{ app::{
components::{ErrorBox, Nav}, components::{ErrorBox, Nav},
pages::{GamePage, Main, NotFound, Signin, Signup, UserSettings}, error::WolfError,
pages::{GamePage, Main, NotFound, Signin, Signup, UserSettings, big::BigScreen},
storage::{ storage::{
Stored, Stored,
user::{AuthContext, AuthContextStoreFields}, user::{AuthContext, AuthContextStoreFields},
@ -96,7 +97,7 @@ pub fn App() -> impl IntoView {
.then_some(auth_store.session().get().is_some()) .then_some(auth_store.session().get().is_some())
}; };
let not_logged_in = move || Some(auth_store.session().get().is_none()); let not_logged_in = move || Some(auth_store.session().get().is_none());
let error = RwSignal::new(None); let error: RwSignal<Option<WolfError>> = RwSignal::new(None);
view! { view! {
<Stylesheet id="leptos" href="/pkg/werewolves.css" /> <Stylesheet id="leptos" href="/pkg/werewolves.css" />
@ -109,22 +110,25 @@ pub fn App() -> impl IntoView {
<Nav /> <Nav />
<ErrorBox msg=error /> <ErrorBox msg=error />
<Routes fallback=NotFound> <Routes fallback=NotFound>
<Route path=path!("/") view=Main /> <Route
path=path!("/")
view=move || view! { <Main error=error.write_only() /> }
/>
<ProtectedRoute <ProtectedRoute
path=path!("/signin") path=path!("/signin")
view=|| view! { <Signin /> } view=move || view! { <Signin error=error.write_only() /> }
condition=not_logged_in condition=not_logged_in
redirect_path=|| "/" redirect_path=|| "/"
/> />
<ProtectedRoute <ProtectedRoute
path=path!("/signup") path=path!("/signup")
view=|| view! { <Signup /> } view=move || view! { <Signup error=error.write_only() /> }
condition=not_logged_in condition=not_logged_in
redirect_path=|| "/" redirect_path=|| "/"
/> />
<ProtectedRoute <ProtectedRoute
path=path!("/user/settings") path=path!("/user/settings")
view=UserSettings view=move || view! { <UserSettings error=error.write_only() /> }
condition=is_logged_in condition=is_logged_in
redirect_path=|| "/" redirect_path=|| "/"
/> />
@ -132,6 +136,7 @@ pub fn App() -> impl IntoView {
path=path!("/games/:id") path=path!("/games/:id")
view=move || view! { <GamePage error=error.write_only() /> } view=move || view! { <GamePage error=error.write_only() /> }
/> />
<Route path=path!("/games/:id/big") view=BigScreen />
</Routes> </Routes>
</main> </main>

View File

@ -13,6 +13,8 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::ops::{Deref, DerefMut};
use werewolves_proto::{ use werewolves_proto::{
aura::AuraTitle, character::Character, game::Category, role::RoleTitle, team::Team, aura::AuraTitle, character::Character, game::Category, role::RoleTitle, team::Team,
}; };
@ -70,8 +72,27 @@ impl Class for Category {
} }
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Default)]
pub struct Classes(Vec<String>); pub struct Classes(Vec<String>);
impl Classes {
pub fn push_opt(&mut self, opt: Option<String>) {
if let Some(val) = opt {
self.0.push(val);
}
}
}
impl Deref for Classes {
type Target = Vec<String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Classes {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<I> From<I> for Classes impl<I> From<I> for Classes
where where
I: Into<Vec<String>>, I: Into<Vec<String>>,
@ -96,6 +117,19 @@ impl AsClasses for [&str] {
} }
} }
impl<T> AsClasses for [Option<T>]
where
T: ToString,
{
fn as_classes(&self) -> Classes {
Classes(
self.iter()
.filter_map(|c| c.as_ref().map(|c| c.to_string()))
.collect(),
)
}
}
impl leptos::tachys::html::class::IntoClass for Classes { impl leptos::tachys::html::class::IntoClass for Classes {
type AsyncOutput = Self; type AsyncOutput = Self;
type State = (leptos::tachys::renderer::types::Element, Self); type State = (leptos::tachys::renderer::types::Element, Self);

View File

@ -3,12 +3,10 @@ use leptos_use::{
UseDraggableOptions, UseDraggableReturn, core::Position, use_draggable_with_options, UseDraggableOptions, UseDraggableReturn, core::Position, use_draggable_with_options,
}; };
pub trait ViewError: core::error::Error { use crate::app::error::WolfError;
fn view(&self) -> impl IntoView;
}
#[component] #[component]
pub fn ErrorBox(msg: RwSignal<Option<String>>) -> impl IntoView { pub fn ErrorBox(msg: RwSignal<Option<WolfError>>) -> impl IntoView {
let el = NodeRef::<Div>::new(); let el = NodeRef::<Div>::new();
// `style` is a helper string "left: {x}px; top: {y}px;" // `style` is a helper string "left: {x}px; top: {y}px;"
@ -17,12 +15,12 @@ pub fn ErrorBox(msg: RwSignal<Option<String>>) -> impl IntoView {
UseDraggableOptions::default().initial_value(Position { x: 40.0, y: 40.0 }), UseDraggableOptions::default().initial_value(Position { x: 40.0, y: 40.0 }),
); );
let content = move || { let content = move || {
msg.get().map(|text| { msg.get().map(|err| {
view! { view! {
<div class="error_container" hidden=move || msg.get().is_none()> <div class="error_container" hidden=move || msg.get().is_none()>
<div node_ref=el style=move || style.get() class="error"> <div node_ref=el style=move || style.get() class="error">
<h5>"error"</h5> <h5>"error"</h5>
<p>{text.to_string()}</p> <p>{err.to_string()}</p>
<button on:click=move |ev| { <button on:click=move |ev| {
ev.prevent_default(); ev.prevent_default();
msg.set(None); msg.set(None);

View File

@ -0,0 +1,227 @@
// 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/>.
use leptos::prelude::*;
use werewolves_proto::{
aura::AuraTitle,
diedto::DiedToTitle,
role::{Alignment, Killer, Powerful, RoleTitle},
};
use crate::app::class::{Class, Classes, PartialClass};
macro_rules! decl_icon {
($($name:ident: $path:literal,)*) => {
#[allow(unused)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconSource {
$(
$name,
)*
}
impl IconSource {
pub const fn source(&self) -> &'static str {
match self {
$(
Self::$name => $path,
)*
}
}
}
};
}
decl_icon!(
Village: "/img/village.svg",
Wolves: "/img/wolf.svg",
Killer: "/img/killer.svg",
Powerful: "/img/powerful.svg",
ListItem: "/img/li.svg",
Skull: "/img/skull.svg",
Heart: "/img/heart.svg",
Shield: "/img/shield.svg",
ShieldAndSword: "/img/shield-and-sword.svg",
Seer: "/img/seer.svg",
Hunter: "/img/hunter.svg",
MapleWolf: "/img/maple-wolf.svg",
Gravedigger: "/img/gravedigger.svg",
PowerSeer: "/img/power-seer.svg",
Scapegoat: "/img/scapegoat.svg",
Diseased: "/img/diseased.svg",
Mortician: "/img/mortician.svg",
Pyremaster: "/img/pyremaster.svg",
Sword: "/img/sword.svg",
Roleblock: "/img/roleblock.svg",
Beholder: "/img/beholder.svg",
LoneWolf: "/img/lone-wolf.svg",
Vindicator: "/img/vindicator.svg",
Apprentice: "/img/apprentice.svg",
Elder: "/img/elder.svg",
Shapeshifter: "/img/shapeshifter.svg",
Arcanist: "/img/arcanist.svg",
Adjudicator: "/img/adjudicator.svg",
Weightlifter: "/img/weightlifter.svg",
Insomniac: "/img/insomniac.svg",
BlackKnight: "/img/black-knight.svg",
Mason: "/img/mason.svg",
NotEqual: "/img/not-equal.svg",
Equal: "/img/equal.svg",
RedX: "/img/red-x.svg",
Damned: "/img/damned.svg",
Bloodlet: "/img/bloodlet.svg",
Drunk: "/img/drunk.svg",
Insane: "/img/insane.svg",
);
impl PartialClass for IconSource {
fn partial_class(&self) -> Option<&'static str> {
match self {
IconSource::Killer => Some("killer"),
IconSource::Powerful => Some("powerful"),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum IconType {
Small,
#[default]
Fit,
Shrink,
}
impl Class for IconType {
fn class(&self) -> &'static str {
match self {
IconType::Fit => "icon-fit",
IconType::Small => "icon",
IconType::Shrink => "icon-shrink",
}
}
}
#[component]
pub fn Icon(
source: IconSource,
#[prop(optional)] r#type: IconType,
#[prop(optional)] mut classes: Classes,
) -> impl IntoView {
classes.push_opt(source.partial_class().map(ToString::to_string));
classes.push(r#type.class().to_string());
view! { <img src=source.source() class=classes.to_string() /> }
}
pub trait PartialAssociatedIcon {
fn icon(&self) -> Option<IconSource>;
}
pub trait AssociatedIcon {
fn icon(&self) -> IconSource;
}
impl AssociatedIcon for Alignment {
fn icon(&self) -> IconSource {
match self {
Alignment::Village => IconSource::Village,
Alignment::Wolves => IconSource::Wolves,
Alignment::Damned => IconSource::Damned,
}
}
}
impl AssociatedIcon for Killer {
fn icon(&self) -> IconSource {
IconSource::Killer
}
}
impl AssociatedIcon for Powerful {
fn icon(&self) -> IconSource {
IconSource::Powerful
}
}
impl PartialAssociatedIcon for RoleTitle {
fn icon(&self) -> Option<IconSource> {
Some(match self {
RoleTitle::AlphaWolf | RoleTitle::DireWolf => return None,
RoleTitle::Bloodletter => IconSource::Bloodlet,
RoleTitle::MasonLeader => IconSource::Mason,
RoleTitle::BlackKnight => IconSource::BlackKnight,
RoleTitle::Insomniac => IconSource::Insomniac,
RoleTitle::Weightlifter => IconSource::Weightlifter,
RoleTitle::Adjudicator => IconSource::Adjudicator,
RoleTitle::Arcanist => IconSource::Arcanist,
RoleTitle::Shapeshifter => IconSource::Shapeshifter,
RoleTitle::Elder => IconSource::Elder,
RoleTitle::Apprentice => IconSource::Apprentice,
RoleTitle::LoneWolf => IconSource::LoneWolf,
RoleTitle::Villager => IconSource::Village,
RoleTitle::Beholder => IconSource::Beholder,
RoleTitle::Werewolf => IconSource::Wolves,
RoleTitle::Militia => IconSource::Sword,
RoleTitle::PyreMaster => IconSource::Pyremaster,
RoleTitle::Mortician => IconSource::Mortician,
RoleTitle::Diseased => IconSource::Diseased,
RoleTitle::Scapegoat => IconSource::Scapegoat,
RoleTitle::PowerSeer => IconSource::PowerSeer,
RoleTitle::Gravedigger => IconSource::Gravedigger,
RoleTitle::MapleWolf => IconSource::MapleWolf,
RoleTitle::Hunter => IconSource::Hunter,
RoleTitle::Empath => IconSource::Heart,
RoleTitle::Seer => IconSource::Seer,
RoleTitle::Guardian => IconSource::ShieldAndSword,
RoleTitle::Protector => IconSource::Shield,
RoleTitle::Vindicator => IconSource::Vindicator,
})
}
}
impl PartialAssociatedIcon for DiedToTitle {
fn icon(&self) -> Option<IconSource> {
match self {
DiedToTitle::Execution => Some(IconSource::Skull),
DiedToTitle::MapleWolf | DiedToTitle::MapleWolfStarved => Some(IconSource::MapleWolf),
DiedToTitle::Militia => Some(IconSource::Sword),
DiedToTitle::Wolfpack => None,
DiedToTitle::AlphaWolf => None,
DiedToTitle::Shapeshift => None,
DiedToTitle::Hunter => Some(IconSource::Hunter),
DiedToTitle::GuardianProtecting => Some(IconSource::ShieldAndSword),
DiedToTitle::PyreMaster => Some(IconSource::Pyremaster),
DiedToTitle::PyreMasterLynchMob => None,
DiedToTitle::MasonLeaderRecruitFail => None,
DiedToTitle::LoneWolf => None,
}
}
}
impl PartialAssociatedIcon for AuraTitle {
fn icon(&self) -> Option<IconSource> {
match self {
AuraTitle::Damned => Some(IconSource::Damned),
AuraTitle::Drunk => Some(IconSource::Drunk),
AuraTitle::Insane => Some(IconSource::Insane),
AuraTitle::Bloodlet => Some(IconSource::Bloodlet),
AuraTitle::Scapegoat
| AuraTitle::RedeemableScapegoat
| AuraTitle::VindictiveScapegoat
| AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat => Some(IconSource::Scapegoat),
}
}
}

View File

@ -2,18 +2,16 @@ use core::num::NonZeroU8;
use leptos::{ use leptos::{
ev::{Event, MouseEvent, SubmitEvent, Targeted}, ev::{Event, MouseEvent, SubmitEvent, Targeted},
html::Form,
prelude::*, prelude::*,
tachys::html::node_ref::NodeRefContainer,
web_sys::HtmlInputElement, web_sys::HtmlInputElement,
}; };
use crate::ConsoleLogError; use crate::app::error::WolfError;
#[component] #[component]
pub fn ChangePlayerNumber( pub fn ChangePlayerNumber(
submitted_number: WriteSignal<Option<NonZeroU8>>, submitted_number: WriteSignal<Option<NonZeroU8>>,
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
) -> impl IntoView { ) -> impl IntoView {
let number: RwSignal<Option<NonZeroU8>> = RwSignal::new(None); let number: RwSignal<Option<NonZeroU8>> = RwSignal::new(None);
let update = move |e: Targeted<Event, HtmlInputElement>| { let update = move |e: Targeted<Event, HtmlInputElement>| {
@ -43,7 +41,7 @@ pub fn ChangePlayerNumber(
ev.prevent_default(); ev.prevent_default();
log::warn!("called submit with number: {:?}", number.get()); log::warn!("called submit with number: {:?}", number.get());
let Some(num) = number.get() else { let Some(num) = number.get() else {
error.set(Some("please set a number".into())); error.set(Some(WolfError::NoSeatNumber));
return; return;
}; };
submitted_number.set(Some(num)); submitted_number.set(Some(num));
@ -52,7 +50,7 @@ pub fn ChangePlayerNumber(
ev.prevent_default(); ev.prevent_default();
log::warn!("called submit with number: {:?}", number.get()); log::warn!("called submit with number: {:?}", number.get());
let Some(num) = number.get() else { let Some(num) = number.get() else {
error.set(Some("please set a number".into())); error.set(Some(WolfError::NoSeatNumber));
return; return;
}; };
submitted_number.set(Some(num)); submitted_number.set(Some(num));

View File

@ -3,6 +3,7 @@ use core::ops::Not;
use leptos::{ev::MouseEvent, prelude::*}; use leptos::{ev::MouseEvent, prelude::*};
use leptos_router::hooks::use_url; use leptos_router::hooks::use_url;
use reactive_stores::Store; use reactive_stores::Store;
use uuid::Uuid;
use werewolves_proto::{error::ServerError, game::GameId, token::TokenString}; use werewolves_proto::{error::ServerError, game::GameId, token::TokenString};
use crate::{ use crate::{
@ -113,6 +114,10 @@ pub fn Nav() -> impl IntoView {
} }
}) })
}; };
move || {
if is_big_screen_path(url.get().path()) {
return ().into_any();
}
view! { view! {
<nav class="header"> <nav class="header">
<Show when=move || auth.session().get().is_some() fallback=default_view> <Show when=move || auth.session().get().is_some() fallback=default_view>
@ -120,4 +125,18 @@ pub fn Nav() -> impl IntoView {
</Show> </Show>
</nav> </nav>
} }
.into_any()
}
}
fn is_big_screen_path(path: &str) -> bool {
let parts = path
.split('/')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect::<Box<[_]>>();
parts.len() >= 3
&& parts[0] == "games"
&& Uuid::parse_str(parts[1]).is_ok()
&& parts[2] == "big"
} }

View File

@ -0,0 +1,20 @@
use thiserror::Error;
use werewolves_proto::error::ServerError;
#[derive(Debug, Clone, Error)]
pub enum WolfError {
#[error("{0}")]
Server(#[from] ServerError),
#[error("username must be between {min} and {max} characters")]
UsernameLimits { min: usize, max: usize },
#[error("password must be between {min} and {max} characters")]
PasswordLimits { min: usize, max: usize },
#[error("current password must be between {min} and {max} characters")]
CurrentPasswordLimits { min: usize, max: usize },
#[error("game cancelled")]
GameCancelled,
#[error("password confirmation does not match")]
PasswordConfirmNoMatch,
#[error("please set a seat number")]
NoSeatNumber,
}

View File

@ -14,6 +14,7 @@ use werewolves_proto::message::{ClientMessage, host::HostMessage};
use werewolves_proto::message::{IntoClientResponse, WrappedServerMessage}; use werewolves_proto::message::{IntoClientResponse, WrappedServerMessage};
use crate::app::components::ErrorBox; use crate::app::components::ErrorBox;
use crate::app::error::WolfError;
use crate::{ use crate::{
ConsoleLogError, ConsoleLogError,
app::{ app::{
@ -25,7 +26,7 @@ use crate::{
}; };
#[component] #[component]
pub fn GamePage(error: WriteSignal<Option<String>>) -> impl IntoView { pub fn GamePage(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
move || { move || {
let params = hooks::use_params_map(); let params = hooks::use_params_map();
let auth = expect_context::<Store<AuthContext>>(); let auth = expect_context::<Store<AuthContext>>();

View File

@ -1,4 +1,191 @@
werewolves_macros::include_path!("werewolves/src/app/pages/game/big");
use core::str::FromStr;
use crate::{
ConsoleLogError,
app::{error::WolfError, pages::NotFound, storage::user::AuthContextStoreFields},
};
use codee::binary::MsgpackSerdeCodec;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks;
use leptos_use::{
ReconnectLimit, UseWebSocketOptions, UseWebSocketReturn, core::ConnectionReadyState,
use_websocket_with_options,
};
use reactive_stores::Store;
use werewolves_proto::{
game::GameId,
message::{IntoClientResponse, WrappedServerMessage, host::ServerToHostMessage},
};
use crate::app::storage::user::AuthContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum BigScreenPage {
#[default]
None,
Setup,
RoleReveal,
QrCode,
}
#[component] #[component]
pub fn BigScreen() -> impl IntoView {} pub fn BigScreen() -> impl IntoView {
move || {
let params = hooks::use_params_map();
let auth = expect_context::<Store<AuthContext>>();
let opened = RwSignal::new(false);
let disconnect = RwSignal::new(false);
let players = RwSignal::new(Default::default());
let settings = RwSignal::new(Default::default());
let page = RwSignal::new(BigScreenPage::default());
Effect::new(move || {
params.read().get("id").unwrap_or_default();
});
let Ok(game_id) = GameId::from_str(params.read().get("id").unwrap_or_default().as_str())
else {
return view! {<NotFound />}.into_any();
};
let url = RwSignal::new(format!("/api/games/{game_id}"));
Effect::watch(
move || url.get(),
move |_, _, _| {
#[cfg(feature = "hydrate")]
{
use crate::ConsoleLogError;
gloo::utils::window().location().reload().console_log_warn()
}
},
false,
);
let UseWebSocketReturn {
ready_state,
message,
send,
close,
..
} = use_websocket_with_options::<
WrappedServerMessage,
IntoClientResponse,
MsgpackSerdeCodec,
_,
_,
>(
#[cfg(not(feature = "ssr"))]
url.read().as_str(),
#[cfg(feature = "ssr")]
"",
UseWebSocketOptions::default().reconnect_limit(ReconnectLimit::Infinite),
);
Effect::new({
let close = close.clone();
move || {
if disconnect.get() {
close();
}
}
});
Effect::new(move || match ready_state.get() {
ConnectionReadyState::Connecting => log::debug!("socket connecting"),
ConnectionReadyState::Closing => {
log::debug!("closing socket");
opened.set(false);
}
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();
#[cfg(feature = "hydrate")]
gloo::utils::window()
.location()
.set_href("/")
.console_log_err();
}
}
});
}
ConnectionReadyState::Closed => log::debug!("connection closed"),
});
let _ = send;
Effect::new(move || {
let Some(msg) = message.get() else {
return;
};
let msg = match msg {
IntoClientResponse::Player(msg) => {
log::error!("got client message?? {msg:?}");
return;
}
IntoClientResponse::Host(msg) => msg,
};
match msg {
ServerToHostMessage::GameCancelled => {
log::error!("game cancelled");
if gloo::utils::window().close().is_err() {
gloo::utils::window()
.location()
.replace("/")
.console_log_warn();
}
}
ServerToHostMessage::Disconnect => disconnect.set(true),
ServerToHostMessage::Daytime {
characters,
marked,
day,
settings,
} => todo!(),
ServerToHostMessage::PlayerStates(character_states) => todo!(),
ServerToHostMessage::ActionPrompt(action_prompt, _) => todo!(),
ServerToHostMessage::ActionResult(character_identity, action_result) => todo!(),
ServerToHostMessage::Lobby {
players: p,
settings: s,
qr_mode,
} => {
if qr_mode {
page.set(BigScreenPage::QrCode);
} else {
players.set(p);
settings.set(s);
page.set(BigScreenPage::Setup);
}
}
ServerToHostMessage::Error(err) => log::error!("server error: {err}"),
ServerToHostMessage::GameOver(game_over) => todo!(),
ServerToHostMessage::WaitingForRoleRevealAcks { ackd, waiting } => todo!(),
ServerToHostMessage::Story { story, page } => todo!(),
ServerToHostMessage::DeadChat(dead_chat_messages) => todo!(),
ServerToHostMessage::DeadChatMessage(dead_chat_message) => todo!(),
}
});
let content = move || match page.get() {
BigScreenPage::None => ().into_any(),
BigScreenPage::Setup => {
view! { <SetupView settings=settings.read_only() /> }.into_any()
}
BigScreenPage::RoleReveal => todo!(),
BigScreenPage::QrCode => view! {
<QrView game_id=game_id/>
}
.into_any(),
};
view! { <div class="big-screen-wrapper">{content}</div> }.into_any()
}
}

View File

@ -0,0 +1,170 @@
use core::ops::Not;
use std::collections::HashMap;
use convert_case::{Case, Casing};
use leptos::prelude::*;
use werewolves_proto::game::{Category, GameId, GameSettings, SetupRole, SetupRoleTitle};
use crate::app::{
class::{AsClasses, Class},
components::{AssociatedIcon, Icon, IconSource, IconType},
};
#[component]
pub fn QrView(game_id: GameId) -> impl IntoView {
let qrcode_url = format!("/api/games/{game_id}/qr");
view! {
<div class="qrcode">
<img src=qrcode_url alt="qr code to join" />
<div class="details">
<h3>{"scan the qrcode to join"}</h3>
</div>
</div>
}
}
#[component]
pub fn SetupView(settings: ReadSignal<GameSettings>) -> impl IntoView {
move || {
let mut by_category: HashMap<Category, Vec<SetupRole>> =
Category::ALL.into_iter().map(|c| (c, Vec::new())).collect();
for slot in settings.read().slots() {
by_category
.get_mut(&slot.role.category())
.unwrap()
.push(slot.role.clone());
}
let mut categories = by_category.into_iter().collect::<Box<[_]>>();
categories.sort_by_key(|(c, _)| match c {
Category::Wolves => 1u8,
Category::Intel => 2,
Category::Villager => 4,
Category::Defensive => 3,
Category::Offensive => 5,
Category::StartsAsVillager => 6,
});
let categories = categories
.into_iter()
.map(|(cat, members)| {
let hide = match cat {
Category::Wolves => CategoryMode::ShowExactRoleCount,
Category::Villager => CategoryMode::ShowExactRoleCount,
Category::Intel
| Category::Defensive
| Category::Offensive
| Category::StartsAsVillager => CategoryMode::HideAllInfo,
};
view! { <SetupCategory category=cat roles=members.into_boxed_slice() mode=hide /> }
})
.collect_view();
let power_roles_count = settings
.read()
.slots()
.iter()
.filter(|r| !matches!(r.role.category(), Category::Villager | Category::Wolves))
.count();
view! {
<div class="setup-screen">
<div class="setup">
//
{categories} <div class="setup-category big">
<span class="slot">
<span class="count">{power_roles_count}</span>
<span class="title village box">{"Power roles from..."}</span>
</span>
</div>
</div>
</div>
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[allow(unused)]
pub enum CategoryMode {
#[default]
HideAllInfo,
ShowTotalCount,
ShowExactRoleCount,
}
#[component]
pub fn SetupCategory(
category: Category,
#[allow(clippy::boxed_local)] roles: Box<[SetupRole]>,
#[prop(optional)] mode: CategoryMode,
) -> impl IntoView {
let mut roles_in_category = SetupRoleTitle::ALL
.into_iter()
.filter(|r| r.category() == category)
.collect::<Box<[_]>>();
roles_in_category.sort_by_key(|l| l.into_role().wakes_night_zero());
let all_roles = roles_in_category
.into_iter()
.map(|r| (r, roles.iter().filter(|sr| sr.title() == r).count()))
.filter(|(_, count)| !(matches!(category, Category::Wolves) && *count == 0))
.map(|(r, count)| {
let as_role = r.into_role();
let show_count = matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0;
let count = if show_count {
count.to_string()
} else {
"?".to_string()
};
view! {
<div class="slot">
<span class="count" class:invisible=!show_count>
{count}
</span>
<span
class=["role", r.category().class(), "box"].as_classes()
class:wakes=as_role.wakes_night_zero()
>
{r.to_string().to_case(Case::Title)}
</span>
<div class="attributes">
<div class="alignment">
<Icon source=as_role.alignment().icon() r#type=IconType::Small />
</div>
<div class="killer" class:inactive=as_role.killer().killer().not()>
<Icon source=IconSource::Killer r#type=IconType::Small />
</div>
<div class="poweful" class:inactive=as_role.powerful().powerful().not()>
<Icon source=IconSource::Powerful r#type=IconType::Small />
</div>
</div>
</div>
}
})
.collect_view();
let show_role_count = matches!(
mode,
CategoryMode::ShowTotalCount | CategoryMode::ShowExactRoleCount
);
let roles_count = if show_role_count {
roles.len().to_string()
} else {
"?".to_string()
};
view! {
<div class="setup-category big">
<div class="slot">
<span class="count" class:invisible=!show_role_count>
{roles_count}
</span>
<div class=["title", category.class(), "box"]
.as_classes()>{category.to_string().to_case(Case::Title)}</div>
</div>
{all_roles}
</div>
}
}

View File

@ -11,8 +11,8 @@ use werewolves_proto::{
}, },
}; };
use crate::ConsoleLogError;
use crate::app::{Preferences, components::DialogModal}; use crate::app::{Preferences, components::DialogModal};
use crate::{ConsoleLogError, app::error::WolfError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum HostPage { enum HostPage {
@ -24,7 +24,7 @@ enum HostPage {
#[component] #[component]
pub fn HostGamePage( pub fn HostGamePage(
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
message: Signal<Option<Srv2Host>>, message: Signal<Option<Srv2Host>>,
reply: WriteSignal<Option<HostMessage>>, reply: WriteSignal<Option<HostMessage>>,
) -> impl IntoView { ) -> impl IntoView {
@ -68,14 +68,14 @@ pub fn HostGamePage(
} }
} }
Srv2Host::GameCancelled => { Srv2Host::GameCancelled => {
error.set(Some("game was cancelled".into())); error.set(Some(WolfError::GameCancelled));
gloo::utils::window() gloo::utils::window()
.location() .location()
.replace("/") .replace("/")
.console_log_warn(); .console_log_warn();
} }
Srv2Host::Error(err) => { Srv2Host::Error(err) => {
error.set(Some(err.to_string())); error.set(Some(err.into()));
} }
Srv2Host::WaitingForRoleRevealAcks { ackd, waiting } => { Srv2Host::WaitingForRoleRevealAcks { ackd, waiting } => {
let mut reveals = ackd let mut reveals = ackd

View File

@ -12,13 +12,14 @@ use crate::app::{
DialogModal, DialogMode, Equals, IdentificationInline, Sample, TutorialBox, DialogModal, DialogMode, Equals, IdentificationInline, Sample, TutorialBox,
input::ChangePlayerNumber, input::ChangePlayerNumber,
}, },
error::WolfError,
}; };
#[component] #[component]
pub fn HostPlayerList( pub fn HostPlayerList(
players: ReadSignal<Box<[PlayerState]>>, players: ReadSignal<Box<[PlayerState]>>,
reply: WriteSignal<Option<HostMessage>>, reply: WriteSignal<Option<HostMessage>>,
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
) -> impl IntoView { ) -> impl IntoView {
let players = move || { let players = move || {
let mut players = players.get(); let mut players = players.get();
@ -88,7 +89,7 @@ pub fn HostPlayerList(
fn HostPlayerDialogBody( fn HostPlayerDialogBody(
player: PlayerState, player: PlayerState,
reply: WriteSignal<Option<HostMessage>>, reply: WriteSignal<Option<HostMessage>>,
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
) -> impl IntoView { ) -> impl IntoView {
let pid = player.identification.player_id; let pid = player.identification.player_id;
let kick = move |_| reply.set(Some(HostMessage::Lobby(HostLobbyMessage::Kick(pid)))); let kick = move |_| reply.set(Some(HostMessage::Lobby(HostLobbyMessage::Kick(pid))));

View File

@ -3,7 +3,7 @@ use core::ops::Not;
use leptos::{ev::MouseEvent, prelude::*}; use leptos::{ev::MouseEvent, prelude::*};
use werewolves_proto::message::{CharacterIdentity, host::HostMessage}; use werewolves_proto::message::{CharacterIdentity, host::HostMessage};
use crate::app::components::IdentityInline; use crate::app::{components::IdentityInline, error::WolfError};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RoleRevealCharacter { pub struct RoleRevealCharacter {
@ -15,7 +15,7 @@ pub struct RoleRevealCharacter {
pub fn RoleRevealAcks( pub fn RoleRevealAcks(
reply: WriteSignal<Option<HostMessage>>, reply: WriteSignal<Option<HostMessage>>,
acks: ReadSignal<Box<[RoleRevealCharacter]>>, acks: ReadSignal<Box<[RoleRevealCharacter]>>,
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
) -> impl IntoView { ) -> impl IntoView {
let acks = move || { let acks = move || {
acks.get() acks.get()
@ -47,7 +47,11 @@ pub fn RoleRevealAcks(
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
reply.set(Some(HostMessage::ForceAllRoleAcks)); reply.set(Some(HostMessage::ForceAllRoleAcks));
}; };
view! { <button on:click=force class="force-all">"force all"</button> } view! {
<button on:click=force class="force-all">
"force all"
</button>
}
}) })
}; };

View File

@ -16,6 +16,7 @@ use werewolves_proto::{
use crate::app::{ use crate::app::{
class::{AsClasses, Class, PartialClass}, class::{AsClasses, Class, PartialClass},
components::{DialogModal, DialogMode, IdentityInline}, components::{DialogModal, DialogMode, IdentityInline},
error::WolfError,
pages::game::host::HostPlayerList, pages::game::host::HostPlayerList,
}; };
@ -27,7 +28,7 @@ pub fn Settings(
dialog_open: RwSignal<HashMap<SlotId, bool>>, dialog_open: RwSignal<HashMap<SlotId, bool>>,
open_categories: RwSignal<HashMap<Category, bool>>, open_categories: RwSignal<HashMap<Category, bool>>,
reply: WriteSignal<Option<HostMessage>>, reply: WriteSignal<Option<HostMessage>>,
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
) -> impl IntoView { ) -> impl IntoView {
let slots = move || { let slots = move || {
settings settings

View File

@ -7,13 +7,17 @@ use convert_case::{Case, Casing};
use leptos::prelude::*; use leptos::prelude::*;
use sorted_vec::SortedSet; use sorted_vec::SortedSet;
use werewolves_proto::{ use werewolves_proto::{
error::ServerError,
message::{ message::{
ClientDeadChat, ClientMessage, ServerToClientMessage as Srv2Client, dead::DeadChatMessage, ClientDeadChat, ClientMessage, ServerToClientMessage as Srv2Client, dead::DeadChatMessage,
}, },
role::RoleTitle, role::RoleTitle,
}; };
use crate::{ConsoleLogError, app::components::ErrorBox}; use crate::{
ConsoleLogError,
app::{components::ErrorBox, error::WolfError},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Page { enum Page {
@ -31,7 +35,7 @@ enum Page {
#[component] #[component]
pub fn PlayerGamePage( pub fn PlayerGamePage(
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
message: Signal<Option<Srv2Client>>, message: Signal<Option<Srv2Client>>,
reply: WriteSignal<Option<ClientMessage>>, reply: WriteSignal<Option<ClientMessage>>,
disconnect: RwSignal<bool>, disconnect: RwSignal<bool>,
@ -44,7 +48,7 @@ pub fn PlayerGamePage(
}; };
match message { match message {
Srv2Client::GameCancelled => { Srv2Client::GameCancelled => {
error.set(Some("game was cancelled".into())); error.set(Some(WolfError::GameCancelled));
gloo::utils::window() gloo::utils::window()
.location() .location()
.replace("/") .replace("/")
@ -80,7 +84,7 @@ pub fn PlayerGamePage(
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
gloo::utils::window().location().reload().console_log_err(); gloo::utils::window().location().reload().console_log_err();
} }
Srv2Client::Error(err) => error.set(Some(err.to_string())), Srv2Client::Error(err) => error.set(Some(ServerError::GameError(err).into())),
} }
// match message {} // match message {}
}); });

View File

@ -6,12 +6,14 @@ use leptos::{
}; };
use werewolves_proto::message::{ClientMessage, UpdateSelf}; use werewolves_proto::message::{ClientMessage, UpdateSelf};
use crate::app::error::WolfError;
#[component] #[component]
pub fn PlayerLobby( pub fn PlayerLobby(
reply: WriteSignal<Option<ClientMessage>>, reply: WriteSignal<Option<ClientMessage>>,
joined: ReadSignal<bool>, joined: ReadSignal<bool>,
current_number: ReadSignal<Option<NonZeroU8>>, current_number: ReadSignal<Option<NonZeroU8>>,
error: WriteSignal<Option<String>>, error: WriteSignal<Option<WolfError>>,
) -> impl IntoView { ) -> impl IntoView {
let click = move |ev: MouseEvent| { let click = move |ev: MouseEvent| {
ev.prevent_default(); ev.prevent_default();
@ -61,7 +63,7 @@ pub fn PlayerLobby(
let submit = move |ev: SubmitEvent| { let submit = move |ev: SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
let Some(num) = number.get() else { let Some(num) = number.get() else {
error.set(Some("please set a number".into())); error.set(Some(WolfError::NoSeatNumber));
return; return;
}; };
reply.set(Some(ClientMessage::UpdateSelf(UpdateSelf::Number(num)))); reply.set(Some(ClientMessage::UpdateSelf(UpdateSelf::Number(num))));

View File

@ -2,16 +2,17 @@ use leptos::prelude::*;
use reactive_stores::Store; use reactive_stores::Store;
use crate::app::{ use crate::app::{
error::WolfError,
pages::{Signin, Signup}, pages::{Signin, Signup},
storage::user::{AuthContext, AuthContextStoreFields, UserSession}, storage::user::{AuthContext, AuthContextStoreFields, UserSession},
}; };
#[component] #[component]
pub fn Main() -> impl IntoView { pub fn Main(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>(); let auth = expect_context::<Store<AuthContext>>();
move || match auth.session().get() { move || match auth.session().get() {
Some(session) => view! { <SignedInMain session=session /> }.into_any(), Some(session) => view! { <SignedInMain session=session /> }.into_any(),
None => view! { <SignedOutMain /> }.into_any(), None => view! { <SignedOutMain error=error /> }.into_any(),
} }
} }
#[component] #[component]
@ -20,19 +21,19 @@ pub fn SignedInMain(session: UserSession) -> impl IntoView {
} }
#[component] #[component]
pub fn SignedOutMain() -> impl IntoView { pub fn SignedOutMain(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
view! { view! {
<h1>"welcome"</h1> <h1>"welcome"</h1>
<div class="welcome-columns"> <div class="welcome-columns">
<div> <div>
<h2>"create a new account"</h2> <h2>"create a new account"</h2>
<h4>"with just a username"</h4> <h4>"with just a username"</h4>
<Signup redirect=false header=false /> <Signup error=error redirect=false header=false />
</div> </div>
<div> <div>
<h2>"sign in"</h2> <h2>"sign in"</h2>
<h4>"with an existing account"</h4> <h4>"with an existing account"</h4>
<Signin redirect=false header=false /> <Signin error=error redirect=false header=false />
</div> </div>
</div> </div>
} }

View File

@ -1,13 +1,13 @@
use core::ops::Not; use core::ops::Not;
use chrono::Utc; use chrono::Utc;
use gloo::history::History;
use leptos::{ev::MouseEvent, prelude::*}; use leptos::{ev::MouseEvent, prelude::*};
use werewolves_proto::player::{Password, Username}; use werewolves_proto::player::{Password, Username};
use crate::{ use crate::{
app::{ app::{
components::ErrorBox, components::ErrorBox,
error::WolfError,
storage::user::{AuthContext, UserToken}, storage::user::{AuthContext, UserToken},
}, },
auth::Signin, auth::Signin,
@ -16,12 +16,12 @@ use reactive_stores::Store;
#[component] #[component]
pub fn Signin( pub fn Signin(
error: WriteSignal<Option<WolfError>>,
#[prop(default = true)] redirect: bool, #[prop(default = true)] redirect: bool,
#[prop(optional)] header: bool, #[prop(optional)] header: bool,
) -> impl IntoView { ) -> impl IntoView {
let submit = ServerAction::<Signin>::new(); let submit = ServerAction::<Signin>::new();
use crate::app::storage::user::AuthContextStoreFields; use crate::app::storage::user::AuthContextStoreFields;
let error: RwSignal<Option<String>> = RwSignal::new(None);
let auth = expect_context::<Store<AuthContext>>(); let auth = expect_context::<Store<AuthContext>>();
@ -51,22 +51,20 @@ pub fn Signin(
let username = match Username::new(user.get()) { let username = match Username::new(user.get()) {
Ok(u) => u, Ok(u) => u,
Err(exp_range) => { Err(exp_range) => {
error.set(Some(format!( error.set(Some(WolfError::UsernameLimits {
"username must be between {} and {} characters", min: *exp_range.start(),
exp_range.start(), max: exp_range.max().unwrap_or_default(),
exp_range.end() }));
)));
return; return;
} }
}; };
let password = match Password::new(pass.get()) { let password = match Password::new(pass.get()) {
Ok(p) => p, Ok(p) => p,
Err(exp_range) => { Err(exp_range) => {
error.set(Some(format!( error.set(Some(WolfError::PasswordLimits {
"password must be between {} and {} characters", min: *exp_range.start(),
exp_range.start(), max: exp_range.max().unwrap_or_default(),
exp_range.end() }));
)));
return; return;
} }
}; };
@ -85,7 +83,7 @@ pub fn Signin(
submit.clear(); submit.clear();
} }
} }
Some(Err(err)) => error.set(Some(err.to_string())), Some(Err(err)) => error.set(Some(err.into())),
None => {} None => {}
}); });
@ -122,7 +120,6 @@ pub fn Signin(
<label for="password">"password"</label> <label for="password">"password"</label>
{pass_input} {pass_input}
{submit} {submit}
<ErrorBox msg=error />
</form> </form>
} }
} }

View File

@ -2,10 +2,7 @@ use core::ops::Not;
use chrono::Utc; use chrono::Utc;
use gloo::history::History; use gloo::history::History;
use leptos::{ use leptos::{ev::MouseEvent, prelude::*};
ev::MouseEvent,
prelude::*,
};
use rand::distr::SampleString; use rand::distr::SampleString;
use reactive_stores::Store; use reactive_stores::Store;
use werewolves_proto::{ use werewolves_proto::{
@ -17,6 +14,7 @@ use werewolves_proto::{
use crate::{ use crate::{
app::{ app::{
components::{ErrorBox, input::TextInput}, components::{ErrorBox, input::TextInput},
error::WolfError,
storage::user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserToken}, storage::user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserToken},
}, },
auth::Signin, auth::Signin,
@ -42,6 +40,7 @@ pub async fn create_user(
#[component] #[component]
pub fn Signup( pub fn Signup(
error: WriteSignal<Option<WolfError>>,
#[prop(default = true)] redirect: bool, #[prop(default = true)] redirect: bool,
#[prop(default = true)] header: bool, #[prop(default = true)] header: bool,
) -> impl IntoView { ) -> impl IntoView {
@ -62,7 +61,6 @@ pub fn Signup(
} }
let submit = ServerAction::<CreateUser>::new(); let submit = ServerAction::<CreateUser>::new();
let error = RwSignal::new(None);
let generated_password = Password::new( let generated_password = Password::new(
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), Password::MAX_LEN), rand::distr::Alphanumeric.sample_string(&mut rand::rng(), Password::MAX_LEN),
) )
@ -76,11 +74,10 @@ pub fn Signup(
let username = match Username::new(user.get().clone()) { let username = match Username::new(user.get().clone()) {
Ok(u) => u, Ok(u) => u,
Err(exp_range) => { Err(exp_range) => {
error.set(Some(format!( error.set(Some(WolfError::UsernameLimits {
"username must be between {} and {} characters", min: *exp_range.start(),
exp_range.start(), max: *exp_range.end(),
exp_range.end() }));
)));
return; return;
} }
}; };
@ -117,7 +114,7 @@ pub fn Signup(
}) })
} }
Err(err) => { Err(err) => {
error.write().replace(err.to_string()); error.write().replace(err.into());
None None
} }
} }
@ -131,7 +128,7 @@ pub fn Signup(
auth.session().set(Some(sess.into())); auth.session().set(Some(sess.into()));
} }
Some(Err(err)) => { Some(Err(err)) => {
error.set(Some(format!("error signing in after signup: {err}"))); error.set(Some(err.into()));
} }
None => {} None => {}
}); });
@ -147,7 +144,6 @@ pub fn Signup(
view! { <button on:click=click.clone()>"sign up"</button> } view! { <button on:click=click.clone()>"sign up"</button> }
}} }}
{result} {result}
<ErrorBox msg=error />
</form> </form>
} }
} }

View File

@ -4,11 +4,12 @@ use reactive_stores::Store;
use crate::app::{ use crate::app::{
Preferences, Preferences,
error::WolfError,
storage::user::{AuthContext, AuthContextStoreFields}, storage::user::{AuthContext, AuthContextStoreFields},
}; };
#[component] #[component]
pub fn UserSettings() -> impl IntoView { pub fn UserSettings(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>(); let auth = expect_context::<Store<AuthContext>>();
let (prefs_read, prefs_write) = let (prefs_read, prefs_write) =
expect_context::<(Signal<Preferences>, WriteSignal<Preferences>)>(); expect_context::<(Signal<Preferences>, WriteSignal<Preferences>)>();
@ -51,10 +52,10 @@ pub fn UserSettings() -> impl IntoView {
view! { view! {
<ul class="user-settings-list"> <ul class="user-settings-list">
<li> <li>
<UpdateProfileButton /> <UpdateProfileButton error=error />
</li> </li>
<li> <li>
<ChangePasswordButton /> <ChangePasswordButton error=error />
</li> </li>
<li>{tutorial_toggle_button}</li> <li>{tutorial_toggle_button}</li>
<li>{cancel_game_toggle_button}</li> <li>{cancel_game_toggle_button}</li>

View File

@ -5,6 +5,7 @@ use werewolves_proto::{cbor_leptos::CborPost, error::ServerError, player::Passwo
use crate::{ use crate::{
app::{ app::{
components::{DialogModal, ErrorBox}, components::{DialogModal, ErrorBox},
error::WolfError,
storage::user::{AuthContext, AuthContextStoreFields}, storage::user::{AuthContext, AuthContextStoreFields},
}, },
db::AppState, db::AppState,
@ -24,9 +25,8 @@ pub async fn change_password(
} }
#[component] #[component]
pub fn ChangePasswordButton() -> impl IntoView { pub fn ChangePasswordButton(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>(); let auth = expect_context::<Store<AuthContext>>();
let error = RwSignal::new(None);
let action = ServerAction::<ChangePassword>::new(); let action = ServerAction::<ChangePassword>::new();
let current = RwSignal::new(String::new()); let current = RwSignal::new(String::new());
Effect::new(move || { Effect::new(move || {
@ -42,26 +42,24 @@ pub fn ChangePasswordButton() -> impl IntoView {
let current = match Password::new(current.get()) { let current = match Password::new(current.get()) {
Ok(c) => c, Ok(c) => c,
Err(exp_range) => { Err(exp_range) => {
error.set(Some(format!( error.set(Some(WolfError::CurrentPasswordLimits {
"current password must be between {} and {} characters", min: *exp_range.start(),
exp_range.start(), max: *exp_range.end(),
exp_range.end() }));
)));
return; return;
} }
}; };
if new.get() != new_confirm.get() { if new.get() != new_confirm.get() {
error.set(Some("password confirmation does not match".into())); error.set(Some(WolfError::PasswordConfirmNoMatch));
return; return;
} }
let new = match Password::new(new.get()) { let new = match Password::new(new.get()) {
Ok(c) => c, Ok(c) => c,
Err(exp_range) => { Err(exp_range) => {
error.set(Some(format!( error.set(Some(WolfError::PasswordLimits {
"new password must be between {} and {} characters", min: *exp_range.start(),
exp_range.start(), max: *exp_range.end(),
exp_range.end() }));
)));
return; return;
} }
}; };
@ -87,7 +85,7 @@ pub fn ChangePasswordButton() -> impl IntoView {
auth.passwordless().set(None); auth.passwordless().set(None);
} }
Some(Err(err)) => { Some(Err(err)) => {
error.set(Some(err.to_string())); error.set(Some(err.into()));
} }
None => {} None => {}
} }
@ -110,7 +108,6 @@ pub fn ChangePasswordButton() -> impl IntoView {
view! { view! {
<DialogModal button_content="change password" close_backdrop=false> <DialogModal button_content="change password" close_backdrop=false>
<ErrorBox msg=error />
<form> <form>
<div class="form-fields"> <div class="form-fields">
<label for="current-password">"current password"</label> <label for="current-password">"current password"</label>

View File

@ -9,6 +9,7 @@ use werewolves_proto::{
use crate::{ use crate::{
app::{ app::{
components::{DialogModal, ErrorBox}, components::{DialogModal, ErrorBox},
error::WolfError,
storage::user::{AuthContext, AuthContextStoreFields}, storage::user::{AuthContext, AuthContextStoreFields},
}, },
db::AppState, db::AppState,
@ -27,9 +28,8 @@ fn trim_or_none(s: String) -> Option<String> {
} }
#[component] #[component]
pub fn UpdateProfileButton() -> impl IntoView { pub fn UpdateProfileButton(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>(); let auth = expect_context::<Store<AuthContext>>();
let error = RwSignal::new(None);
let display_name = RwSignal::new(String::new()); let display_name = RwSignal::new(String::new());
let pronouns = RwSignal::new(String::new()); let pronouns = RwSignal::new(String::new());
Effect::new(move || { Effect::new(move || {
@ -69,7 +69,7 @@ pub fn UpdateProfileButton() -> impl IntoView {
sess.pronouns = input.update.pronouns; sess.pronouns = input.update.pronouns;
} }
} }
Some(Err(err)) => error.set(Some(err.to_string())), Some(Err(err)) => error.set(Some(err.into())),
None => {} None => {}
}); });
let on_success = move || { let on_success = move || {
@ -82,7 +82,6 @@ pub fn UpdateProfileButton() -> impl IntoView {
}; };
view! { view! {
<DialogModal button_content="update profile" close_backdrop=false> <DialogModal button_content="update profile" close_backdrop=false>
<ErrorBox msg=error />
<form> <form>
<label for="display-name">"display name"</label> <label for="display-name">"display name"</label>
<input <input