diff --git a/Cargo.lock b/Cargo.lock index ab6c230..f013825 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 = "addr2line" @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "ahash" version = "0.8.11" @@ -63,7 +69,8 @@ dependencies = [ [[package]] name = "any_spawner" version = "0.1.1" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9747eb01aed7603aba23f7c869d5d7e5d37aab9c3501aced42d8fdb786f1f6e3" dependencies = [ "futures", "thiserror", @@ -252,7 +259,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] @@ -311,6 +318,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" + [[package]] name = "byteorder" version = "1.5.0" @@ -379,6 +392,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186dce98367766de751c42c4f03970fc60fc012296e706ccbb9d5df9b6c1e271" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colored" version = "2.1.0" @@ -461,7 +480,8 @@ dependencies = [ [[package]] name = "const_str_slice_concat" version = "0.1.0" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" [[package]] name = "convert_case" @@ -499,6 +519,42 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.13" @@ -523,6 +579,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -659,6 +724,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -670,6 +756,15 @@ dependencies = [ "syn 2.0.75", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -682,6 +777,18 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "dwrote" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70182709525a3632b2ba96b6569225467b18ecb4a77f46d255f713a6bebf05fd" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "either" version = "1.13.0" @@ -694,7 +801,8 @@ dependencies = [ [[package]] name = "either_of" version = "0.1.0" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e22feb4d5cacf9f2c64902a1c35ef0f2d766e42db316a98b93992bbce669cb" dependencies = [ "pin-project-lite", ] @@ -768,6 +876,31 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "fdeflate" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.0.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.0", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "flume" version = "0.11.0" @@ -785,13 +918,59 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "font-kit" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64b34f4efd515f905952d91bc185039863705592c0c53ae6d979805dd154520" +dependencies = [ + "bitflags 2.6.0", + "byteorder", + "core-foundation", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.75", ] [[package]] @@ -800,6 +979,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -809,6 +994,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "futures" version = "0.3.30" @@ -932,6 +1128,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.29.0" @@ -1180,8 +1386,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hydration_context" -version = "0.2.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.2.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c1bf20c4d7b63c3b06ba3f39b6db2e99eff55888cbc73375b033c1cf355c9a" dependencies = [ "futures", "js-sys", @@ -1344,6 +1551,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.4.0" @@ -1387,6 +1608,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "js-sys" version = "0.3.70" @@ -1407,8 +1634,9 @@ dependencies = [ [[package]] name = "leptos" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f67de237bdbb6cdb4b97fc32783a7410ec04df8846a24367193f69d14f2ddbe" dependencies = [ "any_spawner", "base64 0.22.1", @@ -1443,9 +1671,9 @@ dependencies = [ [[package]] name = "leptos-use" -version = "0.14.0-beta4" +version = "0.14.0-gamma2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9239da86a154e60a583504baf6c6b55977c4da36a8c0f6a6df06a2f1b99cd59" +checksum = "b8ee56b21556b3f64d9b5f866c7ca4008c20b852b71dc92bc18207b83a789e1c" dependencies = [ "cfg-if", "codee", @@ -1467,15 +1695,14 @@ dependencies = [ [[package]] name = "leptos_axum" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbaa2a95ff37ffa0a504578af2125d193561306c8d4628f4a007a0ea7f25183" dependencies = [ "any_spawner", "axum", "dashmap", "futures", - "http 1.1.0", - "http-body-util", "hydration_context", "leptos", "leptos_integration_utils", @@ -1484,7 +1711,6 @@ dependencies = [ "leptos_router", "once_cell", "parking_lot", - "serde_json", "server_fn", "tokio", "tower", @@ -1493,8 +1719,9 @@ dependencies = [ [[package]] name = "leptos_config" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f07003f01935ef2fda98562534ac729905e5542e006626d98413183d4dcb70" dependencies = [ "config", "regex", @@ -1505,8 +1732,9 @@ dependencies = [ [[package]] name = "leptos_dom" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "716c834abb1ae19594dfc008138d09a03289cd5b99bd9dc68e1faa09cf24d70d" dependencies = [ "js-sys", "or_poisoned", @@ -1519,8 +1747,9 @@ dependencies = [ [[package]] name = "leptos_hot_reload" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35daec2869c12c48c095edb855316a97a68b43c28bf9deaf1af86f37b64904e" dependencies = [ "anyhow", "camino", @@ -1536,8 +1765,9 @@ dependencies = [ [[package]] name = "leptos_integration_utils" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19597927587b97a9bb2090090edee5d4158f88a126fcb90cb3a8b3ee15f148b" dependencies = [ "futures", "hydration_context", @@ -1550,8 +1780,9 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398cf72b3f88e7dfa472eddbc1adf193db966696478f61fa3e98db5f974b859" dependencies = [ "attribute-derive", "cfg-if", @@ -1571,8 +1802,9 @@ dependencies = [ [[package]] name = "leptos_meta" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4aae4080714e5d7e20da1213860acf7829a5150ca2e90e3ef422fb42860d9e6" dependencies = [ "futures", "indexmap", @@ -1586,8 +1818,9 @@ dependencies = [ [[package]] name = "leptos_router" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59123e4fd804110d8c094295f5aa4036372eaf3b9442fd2e9b34f4a69ed69e71" dependencies = [ "any_spawner", "either_of", @@ -1598,11 +1831,9 @@ dependencies = [ "leptos_router_macro", "once_cell", "or_poisoned", - "paste", "percent-encoding", "reactive_graph", "send_wrapper", - "serde", "tachys", "thiserror", "url", @@ -1612,8 +1843,9 @@ dependencies = [ [[package]] name = "leptos_router_macro" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01505eb4ca7978a752f25f8e169bfcf02e6f9d519a70469c42468a1a28043a1a" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -1622,14 +1854,16 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a11dde60b3ab2e1714b865614d7e94796d051661283252fafb814b244d2815" dependencies = [ "any_spawner", "base64 0.22.1", "codee", "futures", "hydration_context", + "or_poisoned", "reactive_graph", "send_wrapper", "serde", @@ -1644,12 +1878,32 @@ version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + [[package]] name = "libm" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.27.0" @@ -1797,6 +2051,8 @@ dependencies = [ "minesweeper-lib", "nanoid", "oauth2", + "plotters", + "plotters-canvas", "regex", "reqwest 0.12.7", "serde", @@ -1832,6 +2088,16 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.0.2" @@ -1889,8 +2155,9 @@ dependencies = [ [[package]] name = "next_tuple" -version = "0.1.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.1.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3269cf6f446d3606787952f4ba564ababaf1b1d9513ccde8baeb160829e63281" [[package]] name = "nom" @@ -2006,7 +2273,8 @@ dependencies = [ [[package]] name = "oco_ref" version = "0.2.0" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b94982fe39a861561cf67ff17a7849f2cedadbbad960a797634032b7abb998" dependencies = [ "serde", "thiserror", @@ -2026,7 +2294,7 @@ checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ "bitflags 2.6.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -2062,10 +2330,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "or_poisoned" version = "0.1.0" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" [[package]] name = "overload" @@ -2114,6 +2389,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf07ef4804cfa9aea3b04a7bbdd5a40031dbb6b4f2cbaf2b011666c80c5b4f2" +dependencies = [ + "rustc_version", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2188,6 +2482,77 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "chrono", + "font-kit", + "image", + "lazy_static", + "num-traits", + "pathfinder_geometry", + "plotters-backend", + "plotters-bitmap", + "plotters-svg", + "ttf-parser", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-bitmap" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" +dependencies = [ + "gif", + "image", + "plotters-backend", +] + +[[package]] +name = "plotters-canvas" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498a82bf654581fa4df6c1bd73e56f8556bdeec5eca5a0989d170ca0728ccace" +dependencies = [ + "js-sys", + "plotters-backend", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.0", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2354,8 +2719,9 @@ dependencies = [ [[package]] name = "reactive_graph" -version = "0.1.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.1.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6be4ea45cd870ff0b31a76ddbddfce767911054c1a92676c667211cacc1290" dependencies = [ "any_spawner", "async-lock", @@ -2390,6 +2756,17 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.6" @@ -2602,6 +2979,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.34" @@ -2755,6 +3141,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "send_wrapper" version = "0.6.0" @@ -2851,8 +3243,9 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c9a71f0e0dfa29ea8a91af0ab915c83302189d90c475c7242b75a4f3a6ab84" dependencies = [ "axum", "bytes", @@ -2886,8 +3279,9 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b06b44c79bc217c1f93ab031f100bb0af50ae25a709e1425fbc38a272db6883" dependencies = [ "const_format", "convert_case", @@ -2899,8 +3293,9 @@ dependencies = [ [[package]] name = "server_fn_macro_default" -version = "0.7.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.7.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "423977ae5d4812892ad112b1212dacaaf3c10f2df8660f4f37c4a489d54ab31c" dependencies = [ "server_fn_macro", "syn 2.0.75", @@ -2962,6 +3357,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simple_logger" version = "5.0.0" @@ -3355,8 +3756,9 @@ dependencies = [ [[package]] name = "tachys" -version = "0.1.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.1.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06dc40c2abdcc554e22a32a22599375d6b897043af2cd43cf0e92770eb00357b" dependencies = [ "any_spawner", "const_str_slice_concat", @@ -3428,8 +3830,9 @@ dependencies = [ [[package]] name = "throw_error" -version = "0.2.0-gamma" -source = "git+https://github.com/leptos-rs/leptos?rev=699c54e16cea34e4e2353a0f447c4cea02b41b99#699c54e16cea34e4e2353a0f447c4cea02b41b99" +version = "0.2.0-gamma3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be5a9f20fa1c74a4f58ee0399d534d39e01ec4337bc04378578804e6472d667" dependencies = [ "pin-project-lite", ] @@ -3824,6 +4227,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + [[package]] name = "tungstenite" version = "0.21.0" @@ -4131,6 +4540,12 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "whoami" version = "1.5.1" @@ -4378,6 +4793,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + [[package]] name = "xxhash-rust" version = "0.8.12" @@ -4390,6 +4814,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index e960a72..d438496 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,10 +30,3 @@ opt-level = 'z' lto = true codegen-units = 1 panic = "abort" - -[patch.crates-io] -leptos = { git = "https://github.com/leptos-rs/leptos", rev = "699c54e16cea34e4e2353a0f447c4cea02b41b99" } -leptos_axum = { git = "https://github.com/leptos-rs/leptos", rev = "699c54e16cea34e4e2353a0f447c4cea02b41b99", optional = true } -leptos_meta = { git = "https://github.com/leptos-rs/leptos", rev = "699c54e16cea34e4e2353a0f447c4cea02b41b99" } -leptos_router = { git = "https://github.com/leptos-rs/leptos", rev = "699c54e16cea34e4e2353a0f447c4cea02b41b99" } -server_fn = { git = "https://github.com/leptos-rs/leptos", rev = "699c54e16cea34e4e2353a0f447c4cea02b41b99" } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/web/Cargo.toml b/web/Cargo.toml index 9f08eea..0f7f0da 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -24,7 +24,7 @@ leptos = { version = "0.7.0-gamma", features = ["nightly"] } leptos_axum = { version = "0.7.0-gamma", optional = true } leptos_meta = { version = "0.7.0-gamma" } leptos_router = { version = "0.7.0-gamma", features = ["nightly"] } -leptos-use = { version = "0.14.0-beta" } +leptos-use = { version = "0.14.0-gamma" } log = "0.4" nanoid = { version = "0.4", optional = true } oauth2 = { version = "4.4", optional = true } @@ -47,6 +47,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = tr wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["WebSocket", "Performance"] } getrandom = { version = "0.2", features = ["js"] } +plotters = "0.3.7" +plotters-canvas = "0.3.0" [features] hydrate = ["leptos/hydrate", "leptos/nightly"] diff --git a/web/build.rs b/web/build.rs index 7609593..d506869 100644 --- a/web/build.rs +++ b/web/build.rs @@ -2,4 +2,4 @@ fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); -} \ No newline at end of file +} diff --git a/web/src/app/login.rs b/web/src/app/login.rs index dbf77f2..8f21cdf 100644 --- a/web/src/app/login.rs +++ b/web/src/app/login.rs @@ -7,12 +7,17 @@ use super::auth::{Login, LoginForm, OAuthTarget}; pub fn LoginView(login: ServerAction) -> impl IntoView { view! { - <> - <div class="flex-1 flex flex-col items-center justify-center py-12 px-4 space-y-4"> - <LoginForm login target=OAuthTarget::Google /> - <LoginForm login target=OAuthTarget::Reddit /> - <LoginForm login target=OAuthTarget::Github /> + <div class="flex-1 flex flex-col items-center justify-center py-12 px-4 space-y-4 mx-auto w-full max-w-sm"> + <h1 class="text-4xl my-4 text-gray-900 dark:text-gray-200 font-bold">"Log In"</h1> + <div class="text-center pb-8 text-gray-900 dark:text-gray-100"> + "Log in if you want to set your display name, keep game history, or see your game stats & trends" </div> - </> + <LoginForm login target=OAuthTarget::Google /> + <LoginForm login target=OAuthTarget::Reddit /> + <LoginForm login target=OAuthTarget::Github /> + <div class="text-center pt-8 text-gray-900 dark:text-gray-100"> + "Note: None of your personal info is checked or stored - only your username is used to identify your account" + </div> + </div> } } diff --git a/web/src/app/minesweeper/client.rs b/web/src/app/minesweeper/client.rs index 4a801b3..755dc6c 100644 --- a/web/src/app/minesweeper/client.rs +++ b/web/src/app/minesweeper/client.rs @@ -94,11 +94,10 @@ impl FrontendGame { if !(self.started).get_untracked() || (self.completed).get_untracked() { bail!("Tried to play when game not active") } - let Some(player) = self.player_id.get_untracked() else { + let Some(player) = self.player_id.get_untracked() else { bail!("Tried to play when not a player") }; - let Some(player_info) = self.players[player] - .get_untracked() else { + let Some(player_info) = self.players[player].get_untracked() else { bail!("Tried to play when player info not available") }; if player_info.dead { diff --git a/web/src/app/minesweeper/players.rs b/web/src/app/minesweeper/players.rs index b1c7ce6..007a69c 100644 --- a/web/src/app/minesweeper/players.rs +++ b/web/src/app/minesweeper/players.rs @@ -244,7 +244,7 @@ fn PlayForm(join_trigger: Trigger) -> impl IntoView { } #[server] -async fn start_game(game_id: String) -> Result<(), ServerFnError> { +pub async fn start_game(game_id: String) -> Result<(), ServerFnError> { let auth_session = use_context::<AuthSession>() .ok_or_else(|| ServerFnError::new("Unable to find auth session".to_string()))?; let game_manager = use_context::<GameManager>() diff --git a/web/src/app/profile.rs b/web/src/app/profile.rs index 8dcb133..b5f00a3 100644 --- a/web/src/app/profile.rs +++ b/web/src/app/profile.rs @@ -1,40 +1,22 @@ +mod display_name; +mod game_history; +mod stats; + use codee::string::JsonSerdeCodec; -use leptos::either::*; use leptos::prelude::*; use leptos_meta::*; use leptos_router::components::*; -use regex::Regex; -use serde::{Deserialize, Serialize}; use super::{ auth::{FrontendUser, LogOutForm, Logout}, minesweeper::GameMode, }; -use crate::{ - button_class, - components::icons::{IconTooltip, Mine, Star, Trophy}, - input_class, player_class, player_icon_holder, -}; +use display_name::SetDisplayName; +use game_history::GameHistory; +use stats::{PlayerStatsTable, TimelineStatsGraphs}; #[cfg(feature = "ssr")] use super::{auth::get_user, minesweeper::GameSettings}; -#[cfg(feature = "ssr")] -use crate::backend::{AuthSession, GameManager}; -#[cfg(feature = "ssr")] -use axum_login::AuthUser; - -fn no_prefix_serverfnerror(s: ServerFnError) -> String { - s.to_string() - .split(": ") - .last() - .expect("ServerFnError String expected to have prefix") - .to_string() -} - -fn validate_display_name(name: &str) -> bool { - let re = Regex::new(r"^[\w]+$").unwrap(); - re.is_match(name) && name.len() >= 3 && name.len() <= 16 -} #[component] pub fn ProfileView( @@ -69,6 +51,8 @@ pub fn ProfileView( <hr class="w-full" /> </span> </div> + <PlayerStatsTable /> + <TimelineStatsGraphs /> <GameHistory /> </div> </> @@ -82,255 +66,3 @@ pub fn ProfileView( <Suspense fallback=move || ()>{move || { user.get().map(user_profile) }}</Suspense> } } - -#[server] -async fn set_display_name(display_name: String) -> Result<String, ServerFnError> { - if !validate_display_name(&display_name) { - return Err(ServerFnError::new("Display name not valid".to_string())); - } - let user = get_user() - .await? - .ok_or_else(|| ServerFnError::new("Unable to find user".to_string()))?; - if let Some(name) = &user.display_name { - if name == &display_name { - return Ok(display_name); - } - } - let auth_session = use_context::<AuthSession>().unwrap(); - auth_session - .backend - .update_user_display_name(user.id(), &display_name) - .await - .map(|_| display_name) - .map_err(|_| ServerFnError::new("Unable to update display name".to_string())) -} - -#[component] -fn SetDisplayName(user: FrontendUser, user_updated: WriteSignal<String>) -> impl IntoView { - let set_display_name = ServerAction::<SetDisplayName>::new(); - let (name_err, set_name_err) = signal::<Option<String>>(None); - - let on_submit = move |ev| { - let data = SetDisplayName::from_event(&ev); - if data.is_err() || !validate_display_name(&data.unwrap().display_name) { - ev.prevent_default(); - set_name_err(Some("Display name not valid".to_string())); - } - }; - - Effect::new(move |_| match set_display_name.value().get() { - Some(Ok(name)) => { - user_updated(name); - set_name_err(None); - } - Some(Err(e)) => set_name_err(Some( - no_prefix_serverfnerror(e) + ". This display name may already be taken", - )), - _ => {} - }); - - let curr_name = FrontendUser::display_name_or_anon(user.display_name.as_ref(), true); - - view! { - <div class="flex flex-col space-y-2 w-full max-w-xs"> - <span class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-neutral-950 dark:text-neutral-50"> - {curr_name.clone()} - </span> - {move || { - name_err - .get() - .map(|s| { - view! { - <span class="text-sm font-medium leading-none text-red-500">{s}</span> - } - }) - }} - - <ActionForm - action=set_display_name - on:submit=move |e| on_submit(e.into()) - attr:class="flex space-x-2" - > - <input - class=input_class!() - type="text" - id="set_display_name_display_name" - name="display_name" - placeholder=curr_name - /> - <button type="submit" class=button_class!() disabled=set_display_name.pending()> - "Set display name" - </button> - </ActionForm> - </div> - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PlayerGame { - game_id: String, - player: u8, - dead: bool, - victory_click: bool, - top_score: bool, - score: i64, - start_time: Option<String>, - game_time: Option<usize>, - game_mode: GameMode, -} - -#[server] -async fn get_player_games() -> Result<Vec<PlayerGame>, ServerFnError> { - let auth_session = use_context::<AuthSession>() - .ok_or_else(|| ServerFnError::new("Unable to find auth session".to_string()))?; - let user = auth_session.user.ok_or(ServerFnError::new( - "Cannot find player games when not logged in".to_string(), - ))?; - let game_manager = use_context::<GameManager>() - .ok_or_else(|| ServerFnError::new("No game manager".to_string()))?; - - let games = game_manager - .get_player_games_for_user(&user) - .await - .map_err(|e| ServerFnError::new(e.to_string()))?; - - Ok(games - .into_iter() - .map(|pu| PlayerGame { - game_id: pu.game_id, - player: pu.player, - dead: pu.dead, - victory_click: pu.victory_click, - top_score: pu.top_score, - score: pu.score, - start_time: pu - .start_time - .map(|dt| dt.date_naive().format("%Y-%m-%d").to_string()), - game_time: match (pu.start_time, pu.end_time) { - (Some(st), Some(et)) => { - Some(999.min(et.signed_duration_since(st).num_seconds() as usize)) - } - _ => None, - }, - game_mode: GameMode::from(GameSettings::new( - pu.rows, - pu.cols, - pu.num_mines, - pu.max_players.into(), - )), - }) - .collect()) -} - -#[component] -fn GameHistory() -> impl IntoView { - let player_games = Resource::new(|| (), move |_| async { get_player_games().await }); - let td_class = "border border-slate-100 dark:border-slate-700 p-1"; - let header_class = "border dark:border-slate-600 font-medium p-4 text-gray-900 dark:text-gray-200 bg-neutral-500/50"; - - let loading_row = move |num: usize| { - let player_class = player_class!(0).to_owned() + " text-black"; - view! { - <tr class=player_class> - <td class=td_class>"Game "{num}</td> - <td class=td_class></td> - <td class=td_class></td> - <td class=td_class>"Loading..."</td> - <td class=td_class></td> - <td class=td_class></td> - </tr> - } - }; - let game_view = move |game: PlayerGame| { - let player_class = player_class!(game.player as usize).to_owned() + " text-black"; - view! { - <tr class=player_class> - <td class=td_class> - <A - attr:class="text-sky-800 hover:text-sky-500 font-medium" - href=format!("/game/{}", game.game_id) - > - {game.game_id} - </A> - </td> - <td class=td_class>{game.start_time}</td> - <td class=td_class>{game.game_mode.long_name()}</td> - <td class=td_class>{game.game_time}</td> - <td class=td_class> - {if game.dead { - Either::Left( - view! { - <span class=player_icon_holder!("bg-red-600", true)> - <Mine /> - <IconTooltip>"Dead"</IconTooltip> - </span> - }, - ) - } else { - Either::Right(()) - }} - {if game.top_score { - Either::Left( - view! { - <span class=player_icon_holder!("bg-green-800", true)> - <Trophy /> - <IconTooltip>"Top Score"</IconTooltip> - </span> - }, - ) - } else { - Either::Right(()) - }} - {if game.victory_click { - Either::Left( - view! { - <span class=player_icon_holder!("bg-black", true)> - <Star /> - <IconTooltip>"Victory Click"</IconTooltip> - </span> - }, - ) - } else { - Either::Right(()) - }} - - </td> - <td class=td_class>{game.score}</td> - </tr> - } - }; - view! { - <h2 class="text-2xl my-4 text-gray-900 dark:text-gray-200">"Game History"</h2> - <div class="max-w-full overflow-x-auto"> - <table class="border border-solid border-slate-400 border-collapse table-auto text-sm text-center bg-neutral-200/80 dark:bg-neutral-800/80"> - <thead> - <tr> - <th class=header_class>"Game"</th> - <th class=header_class>"Date"</th> - <th class=header_class>"Game Mode"</th> - <th class=header_class>"Duration"</th> - <th class=header_class>"Status"</th> - <th class=header_class>"Score"</th> - </tr> - </thead> - <tbody> - <Suspense fallback=move || { - (0..5).map(loading_row).collect_view() - }> - - {move || { - Suspend::new(async move { - player_games - .await - .map(|games| { - games.into_iter().map(game_view).collect_view() - }) - }) - }} - - </Suspense> - </tbody> - </table> - </div> - } -} diff --git a/web/src/app/profile/display_name.rs b/web/src/app/profile/display_name.rs new file mode 100644 index 0000000..b70f447 --- /dev/null +++ b/web/src/app/profile/display_name.rs @@ -0,0 +1,108 @@ +use leptos::prelude::*; +use regex::Regex; + +use super::FrontendUser; +use crate::{button_class, input_class}; + +#[cfg(feature = "ssr")] +use super::get_user; +#[cfg(feature = "ssr")] +use crate::backend::AuthSession; +#[cfg(feature = "ssr")] +use axum_login::AuthUser; + +fn no_prefix_serverfnerror(s: ServerFnError) -> String { + s.to_string() + .split(": ") + .last() + .expect("ServerFnError String expected to have prefix") + .to_string() +} + +fn validate_display_name(name: &str) -> bool { + let re = Regex::new(r"^[\w]+$").unwrap(); + re.is_match(name) && name.len() >= 3 && name.len() <= 16 +} + +#[server] +pub async fn set_display_name(display_name: String) -> Result<String, ServerFnError> { + if !validate_display_name(&display_name) { + return Err(ServerFnError::new("Display name not valid".to_string())); + } + let user = get_user() + .await? + .ok_or_else(|| ServerFnError::new("Unable to find user".to_string()))?; + if let Some(name) = &user.display_name { + if name == &display_name { + return Ok(display_name); + } + } + let auth_session = use_context::<AuthSession>().unwrap(); + auth_session + .backend + .update_user_display_name(user.id(), &display_name) + .await + .map(|_| display_name) + .map_err(|_| ServerFnError::new("Unable to update display name".to_string())) +} + +#[component] +pub fn SetDisplayName(user: FrontendUser, user_updated: WriteSignal<String>) -> impl IntoView { + let set_display_name = ServerAction::<SetDisplayName>::new(); + let (name_err, set_name_err) = signal::<Option<String>>(None); + + let on_submit = move |ev| { + let data = SetDisplayName::from_event(&ev); + if data.is_err() || !validate_display_name(&data.unwrap().display_name) { + ev.prevent_default(); + set_name_err(Some("Display name not valid".to_string())); + } + }; + + Effect::new(move |_| match set_display_name.value().get() { + Some(Ok(name)) => { + user_updated(name); + set_name_err(None); + } + Some(Err(e)) => set_name_err(Some( + no_prefix_serverfnerror(e) + ". This display name may already be taken", + )), + _ => {} + }); + + let curr_name = FrontendUser::display_name_or_anon(user.display_name.as_ref(), true); + + view! { + <div class="flex flex-col space-y-2 w-full max-w-xs"> + <span class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-neutral-950 dark:text-neutral-50"> + {curr_name.clone()} + </span> + {move || { + name_err + .get() + .map(|s| { + view! { + <span class="text-sm font-medium leading-none text-red-500">{s}</span> + } + }) + }} + + <ActionForm + action=set_display_name + on:submit=move |e| on_submit(e.into()) + attr:class="flex space-x-2" + > + <input + class=input_class!() + type="text" + id="set_display_name_display_name" + name="display_name" + placeholder=curr_name + /> + <button type="submit" class=button_class!() disabled=set_display_name.pending()> + "Set display name" + </button> + </ActionForm> + </div> + } +} diff --git a/web/src/app/profile/game_history.rs b/web/src/app/profile/game_history.rs new file mode 100644 index 0000000..140a178 --- /dev/null +++ b/web/src/app/profile/game_history.rs @@ -0,0 +1,184 @@ +use leptos::either::*; +use leptos::prelude::*; +use leptos_router::components::*; +use serde::{Deserialize, Serialize}; + +use super::GameMode; +use crate::{ + components::icons::{IconTooltip, Mine, Star, Trophy}, + player_class, player_icon_holder, +}; + +#[cfg(feature = "ssr")] +use super::GameSettings; +#[cfg(feature = "ssr")] +use crate::backend::{AuthSession, GameManager}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PlayerGame { + game_id: String, + player: u8, + dead: bool, + victory_click: bool, + top_score: bool, + score: i64, + start_time: Option<String>, + game_time: Option<usize>, + game_mode: GameMode, +} + +#[server] +pub async fn get_player_games() -> Result<Vec<PlayerGame>, ServerFnError> { + let auth_session = use_context::<AuthSession>() + .ok_or_else(|| ServerFnError::new("Unable to find auth session".to_string()))?; + let user = auth_session.user.ok_or(ServerFnError::new( + "Cannot find player games when not logged in".to_string(), + ))?; + let game_manager = use_context::<GameManager>() + .ok_or_else(|| ServerFnError::new("No game manager".to_string()))?; + + let games = game_manager + .get_player_games_for_user(&user) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(games + .into_iter() + .map(|pu| PlayerGame { + game_id: pu.game_id, + player: pu.player, + dead: pu.dead, + victory_click: pu.victory_click, + top_score: pu.top_score, + score: pu.score, + start_time: pu + .start_time + .map(|dt| dt.date_naive().format("%Y-%m-%d").to_string()), + game_time: match (pu.start_time, pu.end_time) { + (Some(st), Some(et)) => { + Some(999.min(et.signed_duration_since(st).num_seconds() as usize)) + } + _ => None, + }, + game_mode: GameMode::from(GameSettings::new( + pu.rows, + pu.cols, + pu.num_mines, + pu.max_players.into(), + )), + }) + .collect()) +} + +#[component] +pub fn GameHistory() -> impl IntoView { + let player_games = Resource::new(|| (), move |_| async { get_player_games().await }); + let td_class = "border border-slate-100 dark:border-slate-700 p-1"; + let header_class = "border dark:border-slate-600 font-medium p-4 text-gray-900 dark:text-gray-200 bg-neutral-500/50"; + + let loading_row = move |num: usize| { + let player_class = player_class!(0).to_owned() + " text-black"; + view! { + <tr class=player_class> + <td class=td_class>"Game "{num}</td> + <td class=td_class></td> + <td class=td_class></td> + <td class=td_class>"Loading..."</td> + <td class=td_class></td> + <td class=td_class></td> + </tr> + } + }; + let game_view = move |game: PlayerGame| { + let player_class = player_class!(game.player as usize).to_owned() + " text-black"; + view! { + <tr class=player_class> + <td class=td_class> + <A + attr:class="text-sky-800 hover:text-sky-500 font-medium" + href=format!("/game/{}", game.game_id) + > + {game.game_id} + </A> + </td> + <td class=td_class>{game.start_time}</td> + <td class=td_class>{game.game_mode.long_name()}</td> + <td class=td_class>{game.game_time}</td> + <td class=td_class> + {if game.dead { + Either::Left( + view! { + <span class=player_icon_holder!("bg-red-600", true)> + <Mine /> + <IconTooltip>"Dead"</IconTooltip> + </span> + }, + ) + } else { + Either::Right(()) + }} + {if game.top_score { + Either::Left( + view! { + <span class=player_icon_holder!("bg-green-800", true)> + <Trophy /> + <IconTooltip>"Top Score"</IconTooltip> + </span> + }, + ) + } else { + Either::Right(()) + }} + {if game.victory_click { + Either::Left( + view! { + <span class=player_icon_holder!("bg-black", true)> + <Star /> + <IconTooltip>"Victory Click"</IconTooltip> + </span> + }, + ) + } else { + Either::Right(()) + }} + + </td> + <td class=td_class>{game.score}</td> + </tr> + } + }; + view! { + <h2 class="text-2xl my-4 text-gray-900 dark:text-gray-200">"Game History"</h2> + <div class="max-w-full overflow-x-auto"> + <table class="border border-solid border-slate-400 border-collapse table-auto text-sm text-center bg-neutral-200/80 dark:bg-neutral-800/80"> + <thead> + <tr> + <th class=header_class>"Game"</th> + <th class=header_class>"Date"</th> + <th class=header_class>"Game Mode"</th> + <th class=header_class>"Duration"</th> + <th class=header_class>"Status"</th> + <th class=header_class>"Score"</th> + </tr> + </thead> + <tbody> + <Suspense fallback=move || { + (0..5).map(loading_row).collect_view() + }> + + {move || { + Suspend::new(async move { + player_games + .await + .map(|games| { + games.into_iter().map(game_view).collect_view() + }) + }) + }} + + </Suspense> + </tbody> + </table> + </div> + } +} diff --git a/web/src/app/profile/stats.rs b/web/src/app/profile/stats.rs new file mode 100644 index 0000000..16cf5c4 --- /dev/null +++ b/web/src/app/profile/stats.rs @@ -0,0 +1,491 @@ +use std::collections::VecDeque; + +use anyhow::Result; +use codee::string::JsonSerdeWasmCodec; +use full_palette::{CYAN_200, CYAN_500, INDIGO_200, WHITE}; +use leptos::{html::Canvas, prelude::*}; +use leptos_use::storage::{use_local_storage_with_options, UseStorageOptions}; +use plotters::prelude::*; +use plotters_canvas::CanvasBackend; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::JsValue; +use web_sys::HtmlCanvasElement; + +use super::GameMode; + +#[cfg(feature = "ssr")] +use crate::backend::{AuthSession, GameManager}; +use crate::button_class; +#[cfg(feature = "ssr")] +use crate::models::game::GameStats; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameModeStats { + pub played: usize, + pub best_time: usize, + pub average_time: f64, + pub victories: usize, +} + +#[cfg(feature = "ssr")] +impl From<GameStats> for GameModeStats { + fn from(value: GameStats) -> Self { + Self { + played: value.played as usize, + best_time: value.best_time as usize, + average_time: value.average_time, + victories: value.victories as usize, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PlayerStats { + pub beginner: GameModeStats, + pub intermediate: GameModeStats, + pub expert: GameModeStats, +} + +#[server] +pub async fn get_player_stats() -> Result<PlayerStats, ServerFnError> { + let auth_session = use_context::<AuthSession>() + .ok_or_else(|| ServerFnError::new("Unable to find auth session".to_string()))?; + let user = auth_session.user.ok_or(ServerFnError::new( + "Cannot find player games when not logged in".to_string(), + ))?; + let game_manager = use_context::<GameManager>() + .ok_or_else(|| ServerFnError::new("No game manager".to_string()))?; + + let stats = game_manager + .get_aggregate_stats_for_user(&user) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let beginner = GameModeStats::from(stats.beginner); + let intermediate = GameModeStats::from(stats.intermediate); + let expert = GameModeStats::from(stats.expert); + + Ok(PlayerStats { + beginner, + intermediate, + expert, + }) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TimelinePlayerStats { + pub beginner: Vec<(bool, i64)>, + pub intermediate: Vec<(bool, i64)>, + pub expert: Vec<(bool, i64)>, +} + +#[server] +pub async fn get_timeline_stats() -> Result<TimelinePlayerStats, ServerFnError> { + let auth_session = use_context::<AuthSession>() + .ok_or_else(|| ServerFnError::new("Unable to find auth session".to_string()))?; + let user = auth_session.user.ok_or(ServerFnError::new( + "Cannot find player games when not logged in".to_string(), + ))?; + let game_manager = use_context::<GameManager>() + .ok_or_else(|| ServerFnError::new("No game manager".to_string()))?; + + let stats = game_manager + .get_timeline_stats_for_user(&user) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let beginner = stats.beginner; + let intermediate = stats.intermediate; + let expert = stats.expert; + + Ok(TimelinePlayerStats { + beginner, + intermediate, + expert, + }) +} + +#[component] +pub fn PlayerStatsTable() -> impl IntoView { + let td_class = + "border border-slate-100 dark:border-slate-700 py-1 px-2 text-gray-900 dark:text-gray-200"; + let header_class = "border dark:border-slate-600 font-medium p-4 text-gray-900 dark:text-gray-200 bg-neutral-500/50"; + let player_stats = Resource::new(|| (), move |_| async { get_player_stats().await }); + + let mode_view = move |mode: GameMode, stats: GameModeStats| { + view! { + <tr> + <td class=td_class>{mode.short_name()}</td> + <td class=td_class>{stats.played}</td> + <td class=td_class> + {if stats.played > 0 { + format!("{}%", stats.victories * 100 / stats.played) + } else { + "N/A".to_string() + }} + </td> + <td class=td_class> + {if stats.played > 0 { + format!("{}s", stats.best_time) + } else { + "N/A".to_string() + }} + </td> + <td class=td_class> + {if stats.played > 0 { + format!("{:.1}s", stats.average_time) + } else { + "N/A".to_string() + }} + </td> + </tr> + } + }; + view! { + <h2 class="text-2xl my-4 text-gray-900 dark:text-gray-200">"Stats"</h2> + <div class="max-w-full overflow-x-auto"> + + <table class="border border-solid border-slate-400 border-collapse table-auto text-sm text-center bg-neutral-200/80 dark:bg-neutral-800/80"> + <thead> + <tr> + <th class=header_class>"Mode"</th> + <th class=header_class>"Played"</th> + <th class=header_class>"Winrate"</th> + <th class=header_class>"Best Time"</th> + <th class=header_class>"Average Time"</th> + </tr> + </thead> + <tbody> + <Suspense> + {move || Suspend::new(async move { + let stats = player_stats.await; + stats + .map(|stats| { + view! { + <> + {mode_view(GameMode::ClassicBeginner, stats.beginner)} + {mode_view( + GameMode::ClassicIntermediate, + stats.intermediate, + )} {mode_view(GameMode::ClassicExpert, stats.expert)} + </> + } + }) + })} + </Suspense> + </tbody> + </table> + </div> + } +} + +#[component] +pub fn StatSelectButtons( + selected: Signal<GameMode>, + set_selected: WriteSignal<GameMode>, +) -> impl IntoView { + let classic_modes = [ + GameMode::ClassicBeginner, + GameMode::ClassicIntermediate, + GameMode::ClassicExpert, + ]; + + let class_signal = move |mode: GameMode| { + let selected = selected.get(); + if mode == selected { + button_class!( + "w-full rounded rounded-lg", + "bg-neutral-800 text-neutral-50 border-neutral-500" + ) + } else { + button_class!("w-full rounded rounded-lg") + } + }; + + let mode_button = move |mode: GameMode| { + view! { + <div class="flex-1"> + <button + type="button" + class=move || class_signal(mode) + on:click=move |_| { + set_selected(mode); + } + > + + {mode.short_name()} + </button> + </div> + } + }; + + view! { + <div class="w-full space-y-2"> + <div class="flex w-full space-x-2">{classic_modes.map(mode_button).collect_view()}</div> + </div> + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ParsedMode { + speed: Vec<(usize, f64)>, + winrate: Vec<(usize, f64)>, + best_time: Vec<(usize, f64)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ParsedStats { + beginner: ParsedMode, + intermediate: ParsedMode, + expert: ParsedMode, +} + +fn parse_stats(stats: &[(bool, i64)]) -> ParsedMode { + let len = stats.len(); + let (_, _, speed_series, winrate_series, mut best_time_series) = stats.iter().enumerate().fold( + ( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + Vec::with_capacity(len), + Vec::with_capacity(len), + Vec::with_capacity(len), + ), + |(mut speed_acc, mut wr_acc, mut speed_series, mut wr_series, mut best_time), + (i, (v, s))| { + let x = i + 1; + if wr_acc.len() == 10 { + wr_acc.pop_front(); + } + wr_acc.push_back(*v); + let ave_wr = + wr_acc.iter().copied().filter(|b| *b).count() as f64 / wr_acc.len() as f64 * 100.0; + wr_series.push((x, ave_wr)); + + if *v { + if speed_acc.len() == 10 { + speed_acc.pop_front(); + } + let s = *s as f64; + speed_acc.push_back(s); + let ave_time = speed_acc.iter().sum::<f64>() / speed_acc.len() as f64; + speed_series.push((x, ave_time)); + + if best_time.is_empty() { + best_time.push((x, s)); + } + let prev_best = best_time.last().unwrap().1; + if s < prev_best { + best_time.push((x, prev_best)); + best_time.push((x, s)); + } + } + + (speed_acc, wr_acc, speed_series, wr_series, best_time) + }, + ); + if let Some(best_time_last) = best_time_series.last() { + if best_time_last.0 < winrate_series.len() { + best_time_series.push((winrate_series.len(), best_time_last.1)); + } + } + ParsedMode { + speed: speed_series, + winrate: winrate_series, + best_time: best_time_series, + } +} + +fn draw_chart(canvas: HtmlCanvasElement, mode: GameMode, stats: &ParsedMode) -> Result<()> { + let len = 10.max(stats.winrate.len()); + + let ParsedMode { + speed: speed_series, + winrate: winrate_series, + best_time: best_time_series, + } = stats; + + let max = speed_series + .iter() + .map(|(_, y)| *y) + .max_by(|a, b| a.total_cmp(b)) + .unwrap_or_default(); + let max = 10.0_f64.max(max); + + let backend = CanvasBackend::with_canvas_object(canvas).expect("should be able to init canvas"); + let root = backend.into_drawing_area(); + root.fill(&RGBColor(17, 24, 39))?; + + let large_font = ("sans-serif", 20.0) + .with_color(WHITE) + .into_text_style(&root); + let small_font = ("sans-serif", 14.0) + .with_color(WHITE) + .into_text_style(&root); + let tiny_font = ("sans-serif", 12.0) + .with_color(WHITE) + .into_text_style(&root); + + let root = root.titled(&format!("{} Stats", mode.short_name()), large_font)?; + + let mut chart = ChartBuilder::on(&root) + .margin(2) + .caption( + "Winrate & Time - 10 game moving average", + small_font.clone(), + ) + .x_label_area_size(35) + .y_label_area_size(45) + .right_y_label_area_size(45) + .build_cartesian_2d(1usize..len, 0.0..max + 5.0)? + .set_secondary_coord(1usize..len, 0.0..100.0); + + let drop_decimal_places = |x: &f64| format!("{:.0}", x); + + chart + .configure_mesh() + .max_light_lines(1) + .disable_x_mesh() + .x_labels(20.min(len)) + .x_desc("Games") + .y_labels(10) + .y_desc("Time (Seconds - victories only)") + .y_label_formatter(&drop_decimal_places) + .y_label_offset(-10) + .axis_desc_style(small_font.clone()) + .y_label_style(tiny_font.clone()) + .x_label_style(tiny_font.clone()) + .bold_line_style(TRANSPARENT) + .light_line_style(WHITE) + .axis_style(WHITE) + .draw()?; + + chart + .configure_secondary_axes() + .y_labels(10) + .y_desc("Winrate (%)") + .y_label_formatter(&drop_decimal_places) + .y_label_offset(-10) + .axis_desc_style(small_font) + .label_style(tiny_font.clone()) + .axis_style(WHITE) + .draw()?; + + chart + .draw_series( + LineSeries::new(speed_series.clone(), CYAN_200.stroke_width(2).filled()).point_size(2), + )? + .label("Time") + .legend(|(x, y)| PathElement::new(vec![(x, y - 5), (x + 20, y - 5)], CYAN_200)); + + chart + .draw_series(LineSeries::new( + best_time_series.clone(), + CYAN_500.stroke_width(2).filled(), + ))? + .label("Best Time") + .legend(|(x, y)| PathElement::new(vec![(x, y - 5), (x + 20, y - 5)], CYAN_500)); + + let mut seen_t = 999.0_f64; + chart.draw_series(PointSeries::of_element( + best_time_series + .iter() + .filter(|(_, t)| { + let ret = *t != seen_t; + seen_t = *t; + ret + }) + .cloned(), + 2, + CYAN_500, + &|coord, size, style| { + EmptyElement::at(coord) + + Circle::new((0, 0), size, style) + + Text::new(format!("{:.0}s", coord.1), (0, 5), tiny_font.clone()) + }, + ))?; + + chart + .draw_secondary_series( + LineSeries::new(winrate_series.clone(), INDIGO_200.stroke_width(2).filled()) + .point_size(2), + )? + .label("Winrate") + .legend(|(x, y)| PathElement::new(vec![(x, y - 5), (x + 20, y - 5)], INDIGO_200)); + + chart + .configure_series_labels() + .position(SeriesLabelPosition::LowerLeft) + .background_style(RGBAColor(65, 65, 65, 0.8)) + .label_font(tiny_font) + .margin(2) + .legend_area_size(20) + .draw()?; + + root.present()?; + Ok(()) +} + +#[component] +pub fn TimelineStatsGraphs() -> impl IntoView { + let timeline_stats = Resource::new( + || (), + move |_| async { + let stats = get_timeline_stats().await; + stats.map(|ts| ParsedStats { + beginner: parse_stats(&ts.beginner), + intermediate: parse_stats(&ts.intermediate), + expert: parse_stats(&ts.expert), + }) + }, + ); + let canvas_ref = NodeRef::<Canvas>::new(); + + let storage_options = UseStorageOptions::<GameMode, serde_json::Error, JsValue>::default() + .initial_value(GameMode::ClassicBeginner) + .delay_during_hydration(true); + let (selected_mode, set_selected_mode, _) = use_local_storage_with_options::< + GameMode, + JsonSerdeWasmCodec, + >("game_mode_stats", storage_options); + + Effect::watch( + move || (timeline_stats.get(), selected_mode.get(), canvas_ref.get()), + |(tstats, mode, canvas), _, _| { + let canvas: HtmlCanvasElement = if let Some(el) = canvas { + el.to_owned() + } else { + log::debug!("canvas not ready"); + return; + }; + log::debug!("Stats: {:?}", tstats); + let stats = if let Some(Ok(stats)) = tstats { + stats + } else { + return; + }; + let stats = match mode { + GameMode::ClassicBeginner => &stats.beginner, + GameMode::ClassicIntermediate => &stats.intermediate, + GameMode::ClassicExpert => &stats.expert, + _ => return, + }; + if let Err(e) = draw_chart(canvas, *mode, stats) { + log::debug!("Unable to draw chart: {}", e); + }; + }, + true, + ); + + view! { + <div class="flex flex-col w-full max-w-xs space-y-2"> + <StatSelectButtons selected=selected_mode set_selected=set_selected_mode /> + </div> + <canvas + node_ref=canvas_ref + id="stats_canvas" + class="max-w-full" + width="800px" + height="500px" + /> + } +} diff --git a/web/src/backend/auth.rs b/web/src/backend/auth.rs index de83781..adaa874 100644 --- a/web/src/backend/auth.rs +++ b/web/src/backend/auth.rs @@ -39,11 +39,11 @@ async fn oauth_callback( }): Query<AuthzResp>, ) -> impl IntoResponse { let Ok(Some(old_state)) = session.get(CSRF_STATE_KEY).await else { - return StatusCode::BAD_REQUEST.into_response(); - }; + return StatusCode::BAD_REQUEST.into_response(); + }; let Ok(Some(oauth_target)) = session.get(OAUTH_TARGET).await else { - return StatusCode::BAD_REQUEST.into_response(); - }; + return StatusCode::BAD_REQUEST.into_response(); + }; let creds = OAuthCreds { code, diff --git a/web/src/backend/game_manager.rs b/web/src/backend/game_manager.rs index 889bb1a..e407359 100644 --- a/web/src/backend/game_manager.rs +++ b/web/src/backend/game_manager.rs @@ -21,7 +21,8 @@ use crate::{ messages::{ClientMessage, GameMessage}, models::{ game::{ - Game, GameLog, GameParameters, Player, PlayerGame, PlayerUser, SimpleGameWithPlayers, + AggregateStats, Game, GameLog, GameParameters, Player, PlayerGame, PlayerUser, + SimpleGameWithPlayers, TimelineStats, }, user::User, }, @@ -155,26 +156,56 @@ impl GameManager { pub async fn get_game(&self, game_id: &str) -> Result<Game> { Game::get_game(&self.db, game_id) - .await? + .await + .map_err(|e| { + log::debug!("Error fetching game: {}", e); + e + })? .ok_or(anyhow!("Game does not exist")) } pub async fn get_game_log(&self, game_id: &str) -> Result<GameLog> { GameLog::get_log(&self.db, game_id) - .await? + .await + .map_err(|e| { + log::debug!("Error fetching game log: {}", e); + e + })? .ok_or(anyhow!("Game does not exist")) } pub async fn get_players(&self, game_id: &str) -> Result<Vec<PlayerUser>> { - Player::get_players(&self.db, game_id) - .await - .map_err(|e| e.into()) + Player::get_players(&self.db, game_id).await.map_err(|e| { + log::debug!("Error fetching players: {}", e); + e.into() + }) } pub async fn get_player_games_for_user(&self, user: &User) -> Result<Vec<PlayerGame>> { Player::get_player_games_for_user(&self.db, user, 100) .await - .map_err(|e| e.into()) + .map_err(|e| { + log::debug!("Error fetching player games: {}", e); + e.into() + }) + } + + pub async fn get_aggregate_stats_for_user(&self, user: &User) -> Result<AggregateStats> { + Player::get_aggregate_stats_for_user(&self.db, user) + .await + .map_err(|e| { + log::debug!("Error fetching aggregate stats: {}", e); + e.into() + }) + } + + pub async fn get_timeline_stats_for_user(&self, user: &User) -> Result<TimelineStats> { + Player::get_timeline_stats_for_user(&self.db, user) + .await + .map_err(|e| { + log::debug!("Error fetching timeline stats: {}", e); + e.into() + }) } pub async fn game_is_active(&self, game_id: &str) -> bool { diff --git a/web/src/models/game.rs b/web/src/models/game.rs index 7d8c3a4..e93aaed 100644 --- a/web/src/models/game.rs +++ b/web/src/models/game.rs @@ -255,6 +255,28 @@ pub struct PlayerGame { pub max_players: u8, } +#[derive(Clone, Debug, Serialize, Deserialize, FromRow)] +pub struct GameStats { + pub played: i64, + pub best_time: i64, + pub average_time: f64, + pub victories: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AggregateStats { + pub beginner: GameStats, + pub intermediate: GameStats, + pub expert: GameStats, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TimelineStats { + pub beginner: Vec<(bool, i64)>, + pub intermediate: Vec<(bool, i64)>, + pub expert: Vec<(bool, i64)>, +} + impl Player { pub async fn get_players( db: &SqlitePool, @@ -341,6 +363,91 @@ impl Player { transaction.commit().await?; Ok(()) } + + pub async fn get_aggregate_stats_for_user( + db: &SqlitePool, + user: &User, + ) -> Result<AggregateStats, sqlx::Error> { + let modes = [(9, 9, 10), (16, 16, 40), (16, 30, 99)]; + let mut queries = [String::new(), String::new(), String::new()]; + modes.into_iter().enumerate().for_each(|(i, mode)| { + queries[i] = format!( + r#" + SELECT + count(*) as played, + sum(players.victory_click) as victories, + min(games.seconds) FILTER (WHERE players.victory_click = 1) as best_time, + avg(games.seconds) FILTER (WHERE players.victory_click = 1) as average_time + FROM players + LEFT JOIN games ON players.game_id = games.game_id + WHERE + players.user = ? + AND games.rows = {} AND games.cols = {} AND games.num_mines = {} AND games.max_players = 1 + AND games.seconds IS NOT NULL + "#, + mode.0, + mode.1, + mode.2 + ); + }); + + Ok(AggregateStats { + beginner: sqlx::query_as(&queries[0]) + .bind(user.id) + .fetch_one(db) + .await?, + intermediate: sqlx::query_as(&queries[1]) + .bind(user.id) + .fetch_one(db) + .await?, + expert: sqlx::query_as(&queries[2]) + .bind(user.id) + .fetch_one(db) + .await?, + }) + } + + pub async fn get_timeline_stats_for_user( + db: &SqlitePool, + user: &User, + ) -> Result<TimelineStats, sqlx::Error> { + let modes = [(9, 9, 10), (16, 16, 40), (16, 30, 99)]; + let mut queries = [String::new(), String::new(), String::new()]; + modes.into_iter().enumerate().for_each(|(i, mode)| { + queries[i] = format!( + r#" + SELECT + players.victory_click, + games.seconds + FROM players + LEFT JOIN games ON players.game_id = games.game_id + WHERE + players.user = ? + AND games.rows = {} AND games.cols = {} AND games.num_mines = {} AND games.max_players = 1 + AND games.seconds IS NOT NULL + LIMIT 1000 + "#, + mode.0, + mode.1, + mode.2 + ); + }); + + Ok(TimelineStats { + beginner: sqlx::query_as(&queries[0]) + .bind(user.id) + .fetch_all(db) + .await?, + intermediate: sqlx::query_as(&queries[1]) + .bind(user.id) + .fetch_all(db) + .await?, + expert: sqlx::query_as(&queries[2]) + .bind(user.id) + .fetch_all(db) + .await?, + }) + } } #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]