diff --git a/.gitignore b/.gitignore index 98cbc44..2a507e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target /dist + +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index c66e7ae..a921a8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -15,42 +15,44 @@ dependencies = [ [[package]] name = "bincode" -version = "2.0.0-rc.3" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11ea1a0346b94ef188834a65c068a03aec181c94896d481d7a0a40d85b0ce95" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" dependencies = [ "bincode_derive", "serde", + "unty", ] [[package]] name = "bincode_derive" -version = "2.0.0-rc.3" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30759b3b99a1b802a7a3aa21c85c3ded5c28e1c83170d82d70f08bbf7f3e4c" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" dependencies = [ "virtue", ] [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cirru_parser" -version = "0.1.31" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320a8dd7792dfd18c39e2b6d06e894028cf2ce0dfb2fcd4964fb730dc37ea150" +checksum = "c6522ea4b36bfa9d4e3acd2b4ae3c537b20b0b668ad589f65c24233e71f89b1b" dependencies = [ "bincode", + "serde", ] [[package]] @@ -65,9 +67,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", @@ -85,36 +87,31 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ + "once_cell", "wasm-bindgen", ] [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" - -[[package]] -name = "log" -version = "0.4.21" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "lru" @@ -127,15 +124,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoize" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df4051db13d0816cf23196d3baa216385ae099339f5d0645a8d9ff2305e82b8" +checksum = "f8d1d5792299bab3f8b5d88d1b7a7cb50ad7ef039a8c4d45a6b84880a6526276" dependencies = [ "lazy_static", "lru", @@ -144,9 +141,9 @@ dependencies = [ [[package]] name = "memoize-inner" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bdece7e91f0d1e33df7b46ec187a93ea0d4e642113a1039ac8bfdd4a3273ac" +checksum = "6dd8f89255d8ff313afabed9a3c83ef0993cc056679dfd001f5111a026f876f7" dependencies = [ "lazy_static", "proc-macro2", @@ -156,33 +153,33 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "proc-macro2" -version = "1.0.85" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] [[package]] name = "respo" -version = "0.1.9" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe25b3a821ec98e0bc1ac8f1913382e7ac25bafc40317fc9f6ed4a8ed22a288" +checksum = "e17b16a7842d91fb272a544106509b4d6683b695994288c92ee99b6d1983dda5" dependencies = [ "cirru_parser", "js-sys", @@ -202,7 +199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "604687f92aa7e859453ffc3615280146aa01b2b882e14c7c8d4dee530151bfb1" dependencies = [ "quote", - "syn 2.0.66", + "syn 2.0.111", ] [[package]] @@ -213,53 +210,93 @@ dependencies = [ "memoize", "respo", "respo_state_derive", + "ruled-router", + "ruled-router-derive", "serde", "serde_json", + "wasm-bindgen", "web-sys", ] +[[package]] +name = "ruled-router" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9707a79f20a3d470df4563df379c359397004d8d6f6eee6522c150059503a230" +dependencies = [ + "ruled-router-derive", +] + +[[package]] +name = "ruled-router-derive" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8defadee934403943198ee6e5a468ac68a0b49a7c14e159da41381dbe0832101" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "rust-hsluv" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efe2374f2385cdd8755a446f80b2a646de603c9d8539ca38734879b5c71e378b" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" -version = "1.0.204" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.111", ] [[package]] name = "serde_json" -version = "1.0.121" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab380d7d9f22ef3f21ad3e6c1ebe8e4fc7a2000ccba2e4d71fc96f15b2cb609" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -275,9 +312,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.66" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -286,58 +323,52 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "virtue" -version = "0.0.13" +version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcc60c0624df774c82a0ef104151231d37da4962957d691c011c852b2473314" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" -dependencies = [ - "bumpalo", - "log", "once_cell", - "proc-macro2", - "quote", - "syn 2.0.66", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -345,28 +376,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.66", - "wasm-bindgen-backend", + "syn 2.0.111", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 7fff323..b3900d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -respo = "0.1.9" +respo = "0.1.16" # respo = { path = "../rs.respo/respo" } serde = { version = "1.0.204", features = ["derive", "rc"] } serde_json = "1.0.121" @@ -12,17 +12,16 @@ respo_state_derive = "0.0.1" # wasm-bindgen = "0.2.92" console_error_panic_hook = "0.1.7" # js-sys = "0.3.69" -memoize = "0.4.2" - - -[dependencies.web-sys] -version = "0.3.69" -features = [ +memoize = "0.5.1" +ruled-router = "0.0.7" +ruled-router-derive = "0.0.7" +wasm-bindgen = "0.2.92" +web-sys = { version = "0.3.69", features = [ "console", - 'Document', + "Document", "HtmlHeadElement", - 'Element', - 'Window', + "Element", + "Window", "HtmlElement", "HtmlInputElement", "MouseEvent", @@ -30,11 +29,13 @@ features = [ "InputEvent", "Node", "NodeList", - "Element", "HtmlCollection", "CssStyleDeclaration", "FocusEvent", "HtmlLabelElement", "BeforeUnloadEvent", - "Storage" -] + "Storage", + "History", + "Location", + "PopStateEvent", +] } diff --git a/Trunk.toml b/Trunk.toml index bd188b2..5bf2885 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -1,3 +1,14 @@ [build] -public_url = "./" +# Use absolute path for SPA routing to work correctly +# Relative path "./" breaks when accessing deep routes like /counter/42 +public_url = "/" + +[serve] +# Fallback to index.html for SPA routing +# All unknown routes will load index.html +open = false + +[watch] +# Watch settings +ignore = [] diff --git a/src/counter.rs b/src/counter.rs index 32d3d1f..60ce83b 100644 --- a/src/counter.rs +++ b/src/counter.rs @@ -75,13 +75,13 @@ pub fn comp_counter(states: &RespoStatesTree, _counted: i32) -> Result &Rc> { &self.store } + fn get_mount_target(&self) -> &web_sys::Node { &self.mount_target } + fn pick_storage_key() -> &'static str { APP_STORE_KEY } - fn dispatch(store_to_action: Rc>, op: ::Action) -> Result<(), String> { - let mut store = store_to_action.borrow_mut(); - store.update(op) + fn dispatch(store: Rc>, op: ::Action) -> Result<(), String> { + store.borrow_mut().update(op) } fn view(store: Ref) -> Result::Action>, String> { - let states = &store.states; - // util::log!("global store: {:?}", store); - Ok( div() .class(ui_global()) .style(RespoStyle::default().padding(12.0)) - .children([comp_counter(&states.pick("counter"), store.counted)?.to_node()]) + .children([ + comp_nav_links()?.to_node(), + comp_route_content(&store.states, store.counted, &store.router)?.to_node(), + ]) .to_node(), ) } @@ -59,15 +63,31 @@ impl RespoApp for App { fn main() { panic::set_hook(Box::new(console_error_panic_hook::hook)); + // Get initial route from URL - this takes priority over saved state + let initial_route = get_current_route(); + let app = App { mount_target: query_select_node(".app").expect("mount target"), store: Rc::new(RefCell::new(Store::default())), }; - app.try_load_storage().expect("load storage"); + // Load storage (may contain outdated router state) + let _ = app.try_load_storage(); + + // Override stored router with URL-based route + // URL should always be the source of truth for routing + app.store.borrow_mut().router = initial_route; + app.backup_model_beforeunload().expect("backup model"); - util::log!("store: {:?}", app.store.as_ref()); + // Setup popstate listener for browser back/forward navigation + { + let store = app.store.clone(); + setup_router_listener(move |route| { + let _ = store.borrow_mut().update(ActionOp::RouteRestore(route)); + respo::request_rerender(); + }); + } app.render_loop().expect("app render"); } diff --git a/src/pages.rs b/src/pages.rs new file mode 100644 index 0000000..63405f9 --- /dev/null +++ b/src/pages.rs @@ -0,0 +1,140 @@ +//! Page components for route-based rendering + +use respo::css::{CssColor, RespoStyle}; +use respo::states_tree::RespoStatesTree; +use respo::ui::ui_button; +use respo::{button, div, span, DispatchFn, RespoElement, RespoEvent}; +use ruled_router::error::RouteState; + +use crate::counter::comp_counter; +use crate::router::{self, AppRouterMatch, CounterModuleRoute, CounterSubRouterMatch}; +use crate::store::ActionOp; + +/// Navigation links component +pub fn comp_nav_links() -> Result, String> { + let on_home = move |e, dispatch: DispatchFn| -> Result<(), String> { + if let RespoEvent::Click { original_event, .. } = e { + original_event.prevent_default(); + } + router::navigate_home(&dispatch) + }; + + let on_counter = move |e, dispatch: DispatchFn| -> Result<(), String> { + if let RespoEvent::Click { original_event, .. } = e { + original_event.prevent_default(); + } + router::navigate_counter(&dispatch) + }; + + let on_counter_detail = move |e, dispatch: DispatchFn| -> Result<(), String> { + if let RespoEvent::Click { original_event, .. } = e { + original_event.prevent_default(); + } + router::navigate_counter_detail(&dispatch, 42) + }; + + Ok( + div().style(RespoStyle::default().margin(8.0)).elements([ + button() + .class(ui_button()) + .inner_text("Home") + .style(RespoStyle::default().margin(4.0)) + .on_click(on_home), + button() + .class(ui_button()) + .inner_text("Counter") + .style(RespoStyle::default().margin(4.0)) + .on_click(on_counter), + button() + .class(ui_button()) + .inner_text("Counter Detail (42)") + .style(RespoStyle::default().margin(4.0)) + .on_click(on_counter_detail), + ]), + ) +} + +/// Route-based content component - renders different pages based on current route +pub fn comp_route_content(states: &RespoStatesTree, counted: i32, router: &AppRouterMatch) -> Result, String> { + match router { + AppRouterMatch::Home(home_route) => comp_home_page(home_route), + AppRouterMatch::Counter(counter_module) => comp_counter_page(states, counted, counter_module), + } +} + +/// Home page component +fn comp_home_page(home_route: &router::HomeRoute) -> Result, String> { + let welcome_msg = home_route.query.welcome.clone().unwrap_or_else(|| "Welcome Home!".to_string()); + Ok( + div().style(RespoStyle::default().padding(16.0)).elements([span() + .inner_text(format!("🏠 {welcome_msg}")) + .style(RespoStyle::default().font_size(24.0).color(CssColor::Hsluv(200, 80, 50)))]), + ) +} + +/// Counter page component - handles counter module routes +fn comp_counter_page( + states: &RespoStatesTree, + counted: i32, + counter_module: &CounterModuleRoute, +) -> Result, String> { + match &counter_module.sub_router { + RouteState::SubRoute(CounterSubRouterMatch::Detail(detail)) => { + // Counter detail view + Ok( + div().style(RespoStyle::default().padding(16.0)).elements([ + div().elements([span() + .inner_text(format!("📊 Counter Detail - ID: {}", detail.id)) + .style(RespoStyle::default().font_size(20.0).color(CssColor::Hsluv(120, 80, 50)))]), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Tab: {:?}", detail.query.tab))]), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Page: {}", detail.query.page))]), + comp_counter(&states.pick("counter"), counted)?, + ]), + ) + } + RouteState::NoSubRoute => { + // Counter module main view + Ok( + div().style(RespoStyle::default().padding(16.0)).elements([ + div().elements([span() + .inner_text("📊 Counter Module") + .style(RespoStyle::default().font_size(20.0).color(CssColor::Hsluv(60, 80, 50)))]), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Tab: {:?}", counter_module.query.tab))]), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Page: {}", counter_module.query.page))]), + comp_counter(&states.pick("counter"), counted)?, + ]), + ) + } + RouteState::ParseFailed { + remaining_path, + attempted_patterns, + closest_match, + } => { + // Route parse failed - show error page + Ok( + div().style(RespoStyle::default().padding(16.0)).elements([ + span() + .inner_text("⚠️ Route Parse Failed") + .style(RespoStyle::default().font_size(20.0).color(CssColor::Hsluv(0, 80, 50))), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Remaining path: {remaining_path}"))]), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Attempted patterns: {attempted_patterns:?}"))]), + div() + .style(RespoStyle::default().margin(8.0)) + .elements([span().inner_text(format!("Closest match: {closest_match:?}"))]), + ]), + ) + } + } +} diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..087fbf1 --- /dev/null +++ b/src/router.rs @@ -0,0 +1,205 @@ +//! Router definitions and utilities +//! +//! This module contains: +//! - Route definitions using ruled_router derive macros +//! - Navigation helper functions +//! - Browser history management +//! - Popstate event handling + +use respo::DispatchFn; +use ruled_router::error::RouteState; +use ruled_router::prelude::*; +use ruled_router_derive::{QueryDerive, RouterData, RouterMatch}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +use crate::store::ActionOp; + +// ============================================================================ +// Route Definitions +// ============================================================================ + +/// Top-level route matcher (enum for matching different routes) +/// Note: Order matters! More specific routes should come first. +#[derive(RouterMatch, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AppRouterMatch { + Counter(CounterModuleRoute), + Home(HomeRoute), +} + +impl Default for AppRouterMatch { + fn default() -> Self { + AppRouterMatch::Home(HomeRoute::default()) + } +} + +/// Home route (root path) +#[derive(RouterData, Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[router(pattern = "/")] +pub struct HomeRoute { + #[query] + pub query: HomeQuery, +} + +/// Home query parameters +#[derive(QueryDerive, Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +pub struct HomeQuery { + #[query(name = "welcome")] + pub welcome: Option, +} + +/// Counter module route with fixed prefix pattern +#[derive(RouterData, Debug, Clone, PartialEq)] +#[router(pattern = "/counter")] +pub struct CounterModuleRoute { + #[query] + pub query: CounterQuery, + #[sub_router] + pub sub_router: RouteState, +} + +impl Serialize for CounterModuleRoute { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Serialize as URL string for simpler storage format + serializer.serialize_str(&self.format()) + } +} + +impl<'de> Deserialize<'de> for CounterModuleRoute { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + ::parse_route(&s).map_err(serde::de::Error::custom) + } +} + +impl Default for CounterModuleRoute { + fn default() -> Self { + Self { + query: CounterQuery::default(), + sub_router: RouteState::NoSubRoute, + } + } +} + +/// Sub-router matcher for counter routes +#[derive(RouterMatch, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum CounterSubRouterMatch { + Detail(CounterDetailRoute), +} + +/// Detail route with dynamic parameter +#[derive(RouterData, Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[router(pattern = "/:id")] +pub struct CounterDetailRoute { + #[serde(default)] + pub id: u32, + #[query] + pub query: CounterQuery, +} + +/// Query parameters for counter routes +#[derive(QueryDerive, Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +pub struct CounterQuery { + #[query(name = "tab")] + pub tab: Option, + #[query(name = "page", default = "1")] + pub page: u32, +} + +// ============================================================================ +// Navigation Helpers +// ============================================================================ + +/// Navigate to home page +pub fn navigate_home(dispatch: &DispatchFn) -> Result<(), String> { + navigate_to(dispatch, AppRouterMatch::Home(HomeRoute::default())) +} + +/// Navigate to counter page +pub fn navigate_counter(dispatch: &DispatchFn) -> Result<(), String> { + navigate_to(dispatch, AppRouterMatch::Counter(CounterModuleRoute::default())) +} + +/// Navigate to counter detail page with id +pub fn navigate_counter_detail(dispatch: &DispatchFn, id: u32) -> Result<(), String> { + navigate_to( + dispatch, + AppRouterMatch::Counter(CounterModuleRoute { + query: CounterQuery::default(), + sub_router: RouteState::SubRoute(CounterSubRouterMatch::Detail(CounterDetailRoute { + id, + query: CounterQuery::default(), + })), + }), + ) +} + +/// Navigate to a new route by dispatching an action +/// This follows the unidirectional data flow pattern: +/// 1. Format route to URL path +/// 2. Push to browser history +/// 3. Dispatch action to update store +pub fn navigate_to(dispatch: &DispatchFn, route: AppRouterMatch) -> Result<(), String> { + let path = route.format(); + push_history_state(&path); + dispatch.run(ActionOp::RouteChange(route)) +} + +// ============================================================================ +// Browser History Management +// ============================================================================ + +/// Push a new state to browser history +fn push_history_state(path: &str) { + if let Some(window) = web_sys::window() { + if let Ok(history) = window.history() { + let _ = history.push_state_with_url(&JsValue::NULL, "", Some(path)); + } + } +} + +/// Get current route from browser URL +pub fn get_current_route() -> AppRouterMatch { + web_sys::window() + .and_then(|w| { + let pathname = w.location().pathname().ok()?; + let search = w.location().search().unwrap_or_default(); + let full_path = format!("{pathname}{search}"); + Some(parse_route(&full_path)) + }) + .unwrap_or_default() +} + +/// Parse URL path and return matched route +fn parse_route(path: &str) -> AppRouterMatch { + AppRouterMatch::try_parse(path).unwrap_or_default() +} + +// ============================================================================ +// Popstate Event Handling +// ============================================================================ + +/// Setup popstate event listener to handle browser back/forward navigation +pub fn setup_router_listener(on_route_change: F) +where + F: Fn(AppRouterMatch) + 'static, +{ + let callback = Closure::wrap(Box::new(move |_event: web_sys::PopStateEvent| { + let route = get_current_route(); + on_route_change(route); + }) as Box); + + if let Some(window) = web_sys::window() { + let _ = window.add_event_listener_with_callback("popstate", callback.as_ref().unchecked_ref()); + } + + // Leak the closure to keep it alive for the lifetime of the app + callback.forget(); +} diff --git a/src/store.rs b/src/store.rs index 6baee73..53ab96d 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,20 +1,30 @@ -use respo::{states_tree::RespoUpdateState, RespoAction, RespoStore}; +//! Application state management + +use respo::states_tree::{RespoStatesTree, RespoUpdateState}; +use respo::{RespoAction, RespoStore}; use serde::{Deserialize, Serialize}; -use respo::states_tree::RespoStatesTree; +use crate::router::AppRouterMatch; +/// Application store - single source of truth #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Store { pub counted: i32, pub states: RespoStatesTree, + /// Current matched route + pub router: AppRouterMatch, } +/// Application actions #[derive(Clone, Debug, Default)] pub enum ActionOp { Increment, Decrement, - /// contains State and Value StatesChange(RespoUpdateState), + /// Route change - navigates and pushes history state + RouteChange(AppRouterMatch), + /// Route restore - from browser back/forward, skips pushState + RouteRestore(AppRouterMatch), #[default] Noop, } @@ -22,7 +32,7 @@ pub enum ActionOp { impl RespoAction for ActionOp { type Intent = (); - fn states_action(a: respo::states_tree::RespoUpdateState) -> Self { + fn states_action(a: RespoUpdateState) -> Self { Self::StatesChange(a) } } @@ -36,16 +46,13 @@ impl RespoStore for Store { fn update(&mut self, op: Self::Action) -> Result<(), String> { match op { - ActionOp::Noop => { - // nothing to to - } - ActionOp::Increment => { - self.counted += 1; - } - ActionOp::Decrement => { - self.counted -= 1; - } + ActionOp::Noop => {} + ActionOp::Increment => self.counted += 1, + ActionOp::Decrement => self.counted -= 1, ActionOp::StatesChange(a) => self.update_states(a), + ActionOp::RouteChange(route) | ActionOp::RouteRestore(route) => { + self.router = route; + } } Ok(()) } @@ -58,6 +65,6 @@ impl RespoStore for Store { where Self: Sized, { - serde_json::from_str(s).map_err(|e| format!("{:?}", e)) + serde_json::from_str(s).map_err(|e| format!("{e:?}")) } }