Fix up the tabletop layout

This commit is contained in:
2026-03-10 23:50:52 -04:00
parent 8e660abe77
commit 33f51b14e0
4 changed files with 312 additions and 250 deletions

View File

@@ -14,7 +14,6 @@ pub struct ImageProps {
#[function_component]
pub fn Image(ImageProps { class, url }: &ImageProps) -> Html {
let styles = css!(
object-fit: cover;
transition: opacity 0.5s ease-in-out;
);

View File

@@ -8,6 +8,9 @@ pub struct PanelProps {
#[prop_or(Classes::new())]
pub class: Classes,
#[prop_or_default]
pub ref_: NodeRef,
#[prop_or(None)]
pub title: Option<AttrValue>,
@@ -21,6 +24,7 @@ pub struct PanelProps {
pub fn Panel(
PanelProps {
class,
ref_,
title,
on_click,
children,
@@ -64,7 +68,7 @@ pub fn Panel(
};
html! {
<div class={classes!((*class).clone(), styles, hover_style)} onclick={click_handler}>
<div ref={ref_} class={classes!((*class).clone(), styles, hover_style)} onclick={click_handler}>
{title_element}
{children.clone()}
</div>

View File

@@ -1,5 +1,7 @@
use glimmer_yew::prelude::*;
use gloo_console::log;
use stylist::css;
use utils::clone;
use visions_types::Card;
use web_sys::HtmlDivElement;
use yew::prelude::*;
@@ -42,65 +44,20 @@ pub fn TabletopElement(
cards,
}: &TabletopElementProperties,
) -> Html {
let stylesheet = use_stylesheet();
let tabletop_ref = use_node_ref();
let tabletop_height = tabletop_ref
.cast::<HtmlDivElement>()
.map(|element| element.offset_height())
.unwrap_or(0);
let (image_style, sidebar_height) = if let Some(stylesheet) = stylesheet {
let image_style = classes!(css!(
position: absolute;
z-index: -1;
));
let sidebar_height = format!(
"100vh - {} - {} - {}px",
stylesheet.space_md, stylesheet.space_md, tabletop_height
);
(image_style, sidebar_height)
} else {
(classes!(), "".to_owned())
};
let image = if let Some(image_url) = tabletop_image {
html! { <Image class={image_style} url={image_url.to_owned()} /> }
} else {
html! { <></> }
};
/*
html! {
<div class={classes!(class.clone())}>
<h1>{title}</h1>
{image}
<CardsPanel cards={cards.clone()} />
</div>
}
*/
let background = if let Some(image) = background_image {
html! {
<BackgroundLayer url={image} />
}
} else {
html! {}
};
let title_height: UseStateHandle<usize> = use_state(|| 0);
html! {
<div>
{background}
<BackgroundLayer url={background_image} />
<TabletopLayer
ref_={tabletop_ref.clone()}
title={title} />
title={title}
image_url={tabletop_image}
title_height={title_height.clone()} />
<TopLayer
cards={cards.clone()}
left_sidebar={left_sidebar.clone()}
right_sidebar={right_sidebar.clone()}
sidebar_height={sidebar_height} />
title_height={*title_height} />
</div>
}
}
@@ -112,74 +69,159 @@ struct BackgroundLayerProps {
#[function_component]
fn BackgroundLayer(BackgroundLayerProps { url }: &BackgroundLayerProps) -> Html {
let styles = css!(
position: absolute;
let layer_style = classes!(css!(
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
filter: brightness(50%);
z-index: -1;
);
z-index: 0;
));
if let Some(ref url) = *url {
let image_style = classes!(css!(
width: 100vw;
height: 100vh;
filter: brightness(50%);
object-fit: cover;
));
let image = if let Some(ref url) = *url {
html! {
<Image class={styles} url={url.to_owned()} />
<Image class={image_style} url={url.to_owned()} />
}
} else {
html! {}
};
html! {
<div class={layer_style}>
{image}
</div>
}
}
#[derive(PartialEq, Properties)]
struct TabletopLayerProps {
ref_: NodeRef,
title: Option<AttrValue>,
image_url: Option<AttrValue>,
title_height: UseStateHandle<usize>,
}
#[function_component]
fn TabletopLayer(TabletopLayerProps { ref_, title }: &TabletopLayerProps) -> Html {
let stylesheet = use_stylesheet();
fn TabletopLayer(
TabletopLayerProps {
title,
image_url,
title_height,
}: &TabletopLayerProps,
) -> Html {
let layer_style = classes!(css!(
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1;
display: flex;
flex-direction: column;
pointer-events: none;
));
let image_container_style = classes!(css!(
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
));
let image_style = classes!(css!(
max-height: 100%;
max-width: 100%;
object-fit: contain;
));
let title_ref = use_node_ref();
use_effect_with(
title_ref.clone(),
clone!(title_height, move |title_ref| {
let height = title_ref
.cast::<HtmlDivElement>()
.map(|element| element.offset_height())
.unwrap_or(0);
title_height.set(height as usize);
}),
);
let tabletop_title = if let Some(stylesheet) = use_stylesheet() {
stylesheet.typography_heading_lg()
} else {
classes!()
};
let image = if let Some(image_url) = image_url {
html! { <Image class={image_style} url={image_url.to_owned()} /> }
} else {
html! { <></> }
};
html! {
<div ref={ref_.clone()}>
<Panel class={tabletop_title}>
<div class={layer_style}>
<Panel ref_={title_ref.clone()} class={tabletop_title}>
{title.clone().unwrap_or(AttrValue::from("unnamed"))}
</Panel>
<div class={image_container_style}>
{image}
</div>
</div>
}
}
#[derive(PartialEq, Properties)]
struct TopLayerProps {
#[prop_or_default]
class: Classes,
cards: Prop<Vec<Card>>,
left_sidebar: Option<Html>,
right_sidebar: Option<Html>,
sidebar_height: String,
title_height: usize,
}
#[function_component]
fn TopLayer(
TopLayerProps {
class,
cards,
left_sidebar,
right_sidebar,
sidebar_height,
title_height,
}: &TopLayerProps,
) -> Html {
let sidebar_style = classes!(css!(height: calc(${sidebar_height});));
log!(format!("TopLayer: {}px", title_height));
let layer_height = if let Some(stylesheet) = use_stylesheet() {
format!(
"100vh - {} - {} - {}px",
stylesheet.space_md, stylesheet.space_md, title_height
)
} else {
"".to_string()
};
let layer_style = if let Some(stylesheet) = use_stylesheet() {
classes!(css!(
position: fixed;
top: ${title_height}px;
left: 0;
width: 100vw;
height: calc(${layer_height});
z-index: 2;
display: flex;
justify-content: space-between;
visibility: ${if *title_height > 0 { "visible" } else { "hidden" }};
))
} else {
classes!()
};
let left_sidebar = if let Some(sidebar) = left_sidebar {
html! {
<CollapsibleSidebar class={sidebar_style.clone()} visible={true} side={SidebarSide::Left}>
<CollapsibleSidebar visible={true} side={SidebarSide::Left}>
{sidebar.clone()}
</CollapsibleSidebar>
}
@@ -189,7 +231,7 @@ fn TopLayer(
let right_sidebar = if let Some(sidebar) = right_sidebar {
html! {
<CollapsibleSidebar class={sidebar_style.clone()} visible={true} side={SidebarSide::Right}>
<CollapsibleSidebar visible={true} side={SidebarSide::Right}>
{sidebar.clone()}
</CollapsibleSidebar>
}
@@ -198,7 +240,7 @@ fn TopLayer(
};
html! {
<div>
<div class={classes!(layer_style)}>
{left_sidebar}
{right_sidebar}
</div>

View File

@@ -2,7 +2,7 @@ use glimmer_yew::prelude::*;
use gloo_console::error;
use stylist::css;
use visions_types::{
Card, CardId, GameId, GameMessage, GameOverview, GameRequest, ImageId, Location, SceneId,
Card, CardId, GameId, GameMessage, GameOverview, GameRequest, ImageId, Location, Scene, SceneId,
};
use wasm_sockets::ConnectionStatus;
use web_sys::*;
@@ -190,171 +190,29 @@ fn View(
on_drop_on_gm_inventory,
}: &ViewProps,
) -> Html {
let (scene_title_style, card_title_style) = if let Some(stylesheet) = use_stylesheet() {
let scene_title_style = classes!(css!(
padding: ${stylesheet.space_md};
background-color: ${stylesheet.color_surface_default()};
));
let card_title_style = classes!(css!(
padding: ${stylesheet.space_md};
background-color: ${stylesheet.color_surface_default()};
));
(scene_title_style, card_title_style)
} else {
(classes!(), classes!())
};
/*
let stylesheet = use_stylesheet();
let (scene_title_style, card_title_style, image_thumbnail_style) =
if let Some(stylesheet) = stylesheet {
let scene_title_style = classes!(css!(
padding: ${stylesheet.space_md};
background-color: ${stylesheet.color_surface_default()};
));
let card_title_style = classes!(css!(
padding: ${stylesheet.space_md};
background-color: ${stylesheet.color_surface_default()};
));
let image_style = classes!(stylesheet.thumbnail());
(scene_title_style, card_title_style, image_style)
} else {
(classes!(), classes!(), classes!())
};
let _background_layer = css!(
position: absolute;
z-index: -1;
);
let scene = state
.scene
.as_ref()
.and_then(|id| state.available_scenes.iter().find(|(sid, _)| *sid == *id))
.cloned()
.unwrap_or((SceneId::from(""), "no-title".to_owned()));
let available_scenes: Vec<(AttrValue, String)> = state
.available_scenes
.iter()
.map(|(id, title)| (AttrValue::from(id.to_string()), title.clone()))
.collect();
let render_scene_title: Callback<String, Html> = Callback::from(clone!(
scene_title_style,
move |title| html! { <div class={scene_title_style.clone()}>{title}</div> }
));
let scene_images: Vec<(AttrValue, String)> = state
.scene_images
.iter()
.map(|image| (AttrValue::from(image.id.to_string()), image.url.clone()))
.collect();
let render_image_thumbnail: Callback<String, Html> = Callback::from(clone!(
image_thumbnail_style,
move |url| html! { <Image class={image_thumbnail_style.clone()} {url} /> }
));
let scene_page = TabPage {
label: AttrValue::from("Scenes"),
content: html! {
<>
<List<String>
elements={Prop::from(available_scenes)}
enabled_item={AttrValue::from(scene.0.to_string())}
render_element={render_scene_title}
on_select={Callback::from({
let on_change_scene = on_change_scene.clone();
move |id: AttrValue| on_change_scene.emit(SceneId::from(id.as_str()))
})}
/>
<List<String>
elements={Prop::from(scene_images)}
render_element={render_image_thumbnail}
on_select={Callback::from(
clone!(on_change_tabletop, move |image_id: AttrValue| {
let image_id = ImageId::from(image_id.to_string());
on_change_tabletop.emit(image_id);
}))}
/>
</>
},
};
let on_select_card: Callback<AttrValue> =
Callback::from(clone!(state, move |card_id: AttrValue| {
let card_id = CardId::from(card_id.to_string());
if let Some(card) = state.cards.iter().find(|card| card.id == card_id) {
state.dispatch(ViewStateAction::OpenCard(card.clone()))
}
}));
let on_new_card = Callback::from(clone!(state, move |_| {
state.dispatch(ViewStateAction::CardNew)
}));
let cards: Vec<(AttrValue, Card)> = state
.cards
.iter()
.filter(|card| card.location == Location::GM)
.map(|item| (AttrValue::from(item.id.to_string()), item.clone()))
.collect();
let render_card_title: Callback<Card, Html> =
Callback::from(clone!(card_title_style, move |card: Card| html! {
<DragSource item={DraggableItem::Card(card.id.clone())}>
<div class={card_title_style.clone()}>{card.label()}</div>
</DragSource>}));
let cards_page = TabPage {
label: AttrValue::from("Cards"),
content: html! {
<DropTarget on_drop={on_drop_on_gm_inventory}>
<Button on_click={on_new_card}>{"+ New Card"}</Button>
<List<Card>
elements={Prop::from(cards)}
on_select={on_select_card}
render_element={render_card_title}
/>
</DropTarget>
},
};
let sidebar_card = match state.sidebar_card {
Some(ref card) => html! {
<DragSource item={DraggableItem::Card(card.id.clone())}>
<CardElement
card={card.clone()}
on_close={clone!(state, move |_| state.dispatch(ViewStateAction::CloseCard))}
on_save={on_save_card} />
</DragSource>
},
None => html! {},
};
let control_bar = html! {
<div>
<TabView orientation={Orientation::Vertical} pages={vec![scene_page, cards_page]} />
{sidebar_card}
</div>
};
let mut characters = state.characters.clone();
characters.sort_by(|l, r| l.name().cmp(r.name()));
let pc_elements = characters
.iter()
.map(|pc| pc.gm_sidebar())
.collect::<Html>();
let tabletop_title = css!(
font-size: 32px;
flex-grow: 0;
margin: ${*design::SPACING_M};
);
let tabletop_ref: NodeRef = use_node_ref();
let tabletop = html! {
<div ref={tabletop_ref.clone()}>
<Panel class={tabletop_title}>
{state.scene_title.clone().unwrap_or("unnamed".to_owned())}
</Panel>
</div>
};
let tabletop_height = tabletop_ref
.cast::<HtmlDivElement>()
.map(|element| element.offset_height())
.unwrap_or(0);
let foreground_style = css!(
display: flex;
height: 100%;
@@ -365,16 +223,6 @@ fn View(
flex-wrap: wrap;
);
let sidebar_height = format!(
"100vh - {} - {} - {}px",
*design::SPACING_M,
*design::SPACING_M,
tabletop_height
);
let sidebar_style = css!(
height: calc(${sidebar_height});
);
let foreground = html! {
<div class={classes!(foreground_style)}>
<CollapsibleSidebar visible={true} class={sidebar_style.clone()} side={SidebarSide::Left}>
@@ -389,17 +237,51 @@ fn View(
</CollapsibleSidebar>
</div>
};
html! {
<div>
<BackgroundImage url={state.background_url.clone()} />
{tabletop}
{foreground}
</div>
}
*/
let on_select_card: Callback<AttrValue> =
Callback::from(clone!(state, move |card_id: AttrValue| {
let card_id = CardId::from(card_id.to_string());
if let Some(card) = state.cards.iter().find(|card| card.id == card_id) {
state.dispatch(ViewStateAction::OpenCard(card.clone()))
}
}));
let render_scene_title: Callback<String, Html> = Callback::from(clone!(
scene_title_style,
move |title| html! { <div class={scene_title_style.clone()}>{title}</div> }
));
let render_card_title: Callback<Card, Html> =
Callback::from(clone!(card_title_style, move |card: Card| html! {
<DragSource item={DraggableItem::Card(card.id.clone())}>
<div class={card_title_style.clone()}>{card.label()}</div>
</DragSource>
}));
let left_sidebar = html! {
<MenuSidebar
state={state.clone()}
on_change_scene={on_change_scene.clone()}
on_change_tabletop={on_change_tabletop}
on_select_card={on_select_card.clone()}
on_save_card={on_save_card.clone()}
on_drop_on_gm_inventory={on_drop_on_gm_inventory.clone()}
render_scene_title={render_scene_title.clone()}
render_card_title={render_card_title.clone()} />
};
let right_sidebar = state
.characters
.iter()
.map(|pc| pc.gm_sidebar())
.collect::<Html>();
html! {
<div>{"Placeholder"}</div>
<TabletopElement
title={state.scene_title.clone()}
background_image={state.background_url.clone()}
tabletop_image={state.tabletop_image.clone().map(|i| AttrValue::from(i.url))}
left_sidebar={Some(left_sidebar)}
right_sidebar={Some(right_sidebar)} />
}
}
@@ -591,6 +473,128 @@ pub fn GmView<C: Client + Clone + 'static>(GmViewProps { client, game }: &GmView
}
}
#[derive(PartialEq, Properties)]
struct MenuSidebarProps {
state: UseReducerHandle<ViewState>,
on_change_scene: Callback<SceneId>,
on_change_tabletop: Callback<ImageId>,
on_select_card: Callback<AttrValue>,
on_save_card: Callback<(bool, Card)>,
on_drop_on_gm_inventory: Callback<DraggableItem>,
render_scene_title: Callback<String, Html>,
render_card_title: Callback<Card, Html>,
}
#[function_component]
fn MenuSidebar(
MenuSidebarProps {
state,
on_change_scene,
on_change_tabletop,
on_select_card,
on_save_card,
on_drop_on_gm_inventory,
render_scene_title,
render_card_title,
}: &MenuSidebarProps,
) -> Html {
let thumbnail_style = if let Some(stylesheet) = use_stylesheet() {
classes!(stylesheet.thumbnail())
} else {
classes!()
};
let on_new_card = Callback::from(clone!(state, move |_| {
state.dispatch(ViewStateAction::CardNew)
}));
let scene = state
.scene
.as_ref()
.and_then(|id| state.available_scenes.iter().find(|(sid, _)| *sid == *id))
.cloned()
.unwrap_or((SceneId::from(""), "no-title".to_owned()));
let available_scenes: Vec<(AttrValue, String)> = state
.available_scenes
.iter()
.map(|(id, title)| (AttrValue::from(id.to_string()), title.clone()))
.collect();
let scene_images: Vec<(AttrValue, String)> = state
.scene_images
.iter()
.map(|image| (AttrValue::from(image.id.to_string()), image.url.clone()))
.collect();
let scene_page = TabPage {
label: AttrValue::from("Scenes"),
content: html! {
<>
<List<String>
elements={Prop::from(available_scenes)}
enabled_item={AttrValue::from(scene.0.to_string())}
render_element={render_scene_title}
on_select={Callback::from({
let on_change_scene = on_change_scene.clone();
move |id: AttrValue| on_change_scene.emit(SceneId::from(id.as_str()))
})}
/>
<List<String>
elements={Prop::from(scene_images)}
render_element={render_thumbnail(thumbnail_style.clone())}
on_select={Callback::from(
clone!(on_change_tabletop, move |image_id: AttrValue| {
let image_id = ImageId::from(image_id.to_string());
on_change_tabletop.emit(image_id);
}))}
/>
</>
},
};
let cards: Vec<(AttrValue, Card)> = state
.cards
.iter()
.filter(|card| card.location == Location::GM)
.map(|item| (AttrValue::from(item.id.to_string()), item.clone()))
.collect();
let cards_page = TabPage {
label: AttrValue::from("Cards"),
content: html! {
<DropTarget on_drop={on_drop_on_gm_inventory}>
<Button on_click={on_new_card}>{"+ New Card"}</Button>
<List<Card>
elements={Prop::from(cards)}
on_select={on_select_card}
render_element={render_card_title}
/>
</DropTarget>
},
};
let sidebar_card = match state.sidebar_card {
Some(ref card) => html! {
<DragSource item={DraggableItem::Card(card.id.clone())}>
<CardElement
card={card.clone()}
on_close={clone!(state, move |_| state.dispatch(ViewStateAction::CloseCard))}
on_save={on_save_card} />
</DragSource>
},
None => html! {},
};
html! {
<div>
<TabView orientation={Orientation::Vertical} pages={vec![scene_page, cards_page]} />
{sidebar_card}
</div>
}
}
fn on_drop_handler(
socket: WebsocketClient,
view_state: UseReducerHandle<ViewState>,
@@ -615,3 +619,16 @@ fn move_card_to(
socket.send(GameRequest::CardUpdate(card));
}
}
fn render_thumbnail(styles: Classes) -> impl Fn(String) -> Html {
clone!(styles, move |url| html! {
<Image class={styles.clone()} {url} />
})
}
/*
let render_image_thumbnail: Callback<String, Html> = Callback::from(clone!(
image_thumbnail_style,
move |url| html! { <Image class={image_thumbnail_style.clone()} {url} /> }
));
*/