├── .gitignore ├── FullstackWasmFSharpApp.sln ├── LICENSE.md ├── README.md ├── TodoBackend ├── FullstackWasmFSharpAppBackend.fsproj ├── Program.fs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json └── appsettings.json ├── TodoFrontend ├── Main.fs ├── Startup.fs ├── TodoFrontend.Client.fsproj ├── template.html └── wwwroot │ ├── base.css │ ├── favicon.ico │ ├── index.css │ └── index.html └── assets └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | ### VisualStudioCode template 2 | .vscode/* 3 | !.vscode/settings.json 4 | !.vscode/tasks.json 5 | !.vscode/launch.json 6 | !.vscode/extensions.json 7 | *.code-workspace 8 | 9 | # Local History for Visual Studio Code 10 | .history/ 11 | 12 | ### JetBrains template 13 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 14 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 15 | 16 | # User-specific stuff 17 | .idea/**/workspace.xml 18 | .idea/**/tasks.xml 19 | .idea/**/usage.statistics.xml 20 | .idea/**/dictionaries 21 | .idea/**/shelf 22 | 23 | # Generated files 24 | .idea/**/contentModel.xml 25 | 26 | # Sensitive or high-churn files 27 | .idea/**/dataSources/ 28 | .idea/**/dataSources.ids 29 | .idea/**/dataSources.local.xml 30 | .idea/**/sqlDataSources.xml 31 | .idea/**/dynamic.xml 32 | .idea/**/uiDesigner.xml 33 | .idea/**/dbnavigator.xml 34 | 35 | # Gradle 36 | .idea/**/gradle.xml 37 | .idea/**/libraries 38 | 39 | # Gradle and Maven with auto-import 40 | # When using Gradle or Maven with auto-import, you should exclude module files, 41 | # since they will be recreated, and may cause churn. Uncomment if using 42 | # auto-import. 43 | # .idea/artifacts 44 | # .idea/compiler.xml 45 | # .idea/jarRepositories.xml 46 | # .idea/modules.xml 47 | # .idea/*.iml 48 | # .idea/modules 49 | # *.iml 50 | # *.ipr 51 | 52 | # CMake 53 | cmake-build-*/ 54 | 55 | # Mongo Explorer plugin 56 | .idea/**/mongoSettings.xml 57 | 58 | # File-based project format 59 | *.iws 60 | 61 | # IntelliJ 62 | out/ 63 | 64 | # mpeltonen/sbt-idea plugin 65 | .idea_modules/ 66 | 67 | # JIRA plugin 68 | atlassian-ide-plugin.xml 69 | 70 | # Cursive Clojure plugin 71 | .idea/replstate.xml 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | fabric.properties 78 | 79 | # Editor-based Rest Client 80 | .idea/httpRequests 81 | 82 | # Android studio 3.1+ serialized cache file 83 | .idea/caches/build_file_checksums.ser 84 | 85 | ### VisualStudio template 86 | ## Ignore Visual Studio temporary files, build results, and 87 | ## files generated by popular Visual Studio add-ons. 88 | ## 89 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 90 | 91 | # User-specific files 92 | *.rsuser 93 | *.suo 94 | *.user 95 | *.userosscache 96 | *.sln.docstates 97 | 98 | # User-specific files (MonoDevelop/Xamarin Studio) 99 | *.userprefs 100 | 101 | # Mono auto generated files 102 | mono_crash.* 103 | 104 | # Build results 105 | [Dd]ebug/ 106 | [Dd]ebugPublic/ 107 | [Rr]elease/ 108 | [Rr]eleases/ 109 | x64/ 110 | x86/ 111 | [Ww][Ii][Nn]32/ 112 | [Aa][Rr][Mm]/ 113 | [Aa][Rr][Mm]64/ 114 | bld/ 115 | [Bb]in/ 116 | [Oo]bj/ 117 | [Ll]og/ 118 | [Ll]ogs/ 119 | 120 | # Visual Studio 2015/2017 cache/options directory 121 | .vs/ 122 | # Uncomment if you have tasks that create the project's static files in wwwroot 123 | #wwwroot/ 124 | 125 | # Visual Studio 2017 auto generated files 126 | Generated\ Files/ 127 | 128 | # MSTest test Results 129 | [Tt]est[Rr]esult*/ 130 | [Bb]uild[Ll]og.* 131 | 132 | # NUnit 133 | *.VisualState.xml 134 | TestResult.xml 135 | nunit-*.xml 136 | 137 | # Build Results of an ATL Project 138 | [Dd]ebugPS/ 139 | [Rr]eleasePS/ 140 | dlldata.c 141 | 142 | # Benchmark Results 143 | BenchmarkDotNet.Artifacts/ 144 | 145 | # .NET Core 146 | project.lock.json 147 | project.fragment.lock.json 148 | artifacts/ 149 | 150 | # ASP.NET Scaffolding 151 | ScaffoldingReadMe.txt 152 | 153 | # StyleCop 154 | StyleCopReport.xml 155 | 156 | # Files built by Visual Studio 157 | *_i.c 158 | *_p.c 159 | *_h.h 160 | *.ilk 161 | *.meta 162 | *.obj 163 | *.iobj 164 | *.pch 165 | *.pdb 166 | *.ipdb 167 | *.pgc 168 | *.pgd 169 | *.rsp 170 | *.sbr 171 | *.tlb 172 | *.tli 173 | *.tlh 174 | *.tmp 175 | *.tmp_proj 176 | *_wpftmp.csproj 177 | *.log 178 | *.vspscc 179 | *.vssscc 180 | .builds 181 | *.pidb 182 | *.svclog 183 | *.scc 184 | 185 | # Chutzpah Test files 186 | _Chutzpah* 187 | 188 | # Visual C++ cache files 189 | ipch/ 190 | *.aps 191 | *.ncb 192 | *.opendb 193 | *.opensdf 194 | *.sdf 195 | *.cachefile 196 | *.VC.db 197 | *.VC.VC.opendb 198 | 199 | # Visual Studio profiler 200 | *.psess 201 | *.vsp 202 | *.vspx 203 | *.sap 204 | 205 | # Visual Studio Trace Files 206 | *.e2e 207 | 208 | # TFS 2012 Local Workspace 209 | $tf/ 210 | 211 | # Guidance Automation Toolkit 212 | *.gpState 213 | 214 | # ReSharper is a .NET coding add-in 215 | _ReSharper*/ 216 | *.[Rr]e[Ss]harper 217 | *.DotSettings.user 218 | 219 | # TeamCity is a build add-in 220 | _TeamCity* 221 | 222 | # DotCover is a Code Coverage Tool 223 | *.dotCover 224 | 225 | # AxoCover is a Code Coverage Tool 226 | .axoCover/* 227 | !.axoCover/settings.json 228 | 229 | # Coverlet is a free, cross platform Code Coverage Tool 230 | coverage*.json 231 | coverage*.xml 232 | coverage*.info 233 | 234 | # Visual Studio code coverage results 235 | *.coverage 236 | *.coveragexml 237 | 238 | # NCrunch 239 | _NCrunch_* 240 | .*crunch*.local.xml 241 | nCrunchTemp_* 242 | 243 | # MightyMoose 244 | *.mm.* 245 | AutoTest.Net/ 246 | 247 | # Web workbench (sass) 248 | .sass-cache/ 249 | 250 | # Installshield output folder 251 | [Ee]xpress/ 252 | 253 | # DocProject is a documentation generator add-in 254 | DocProject/buildhelp/ 255 | DocProject/Help/*.HxT 256 | DocProject/Help/*.HxC 257 | DocProject/Help/*.hhc 258 | DocProject/Help/*.hhk 259 | DocProject/Help/*.hhp 260 | DocProject/Help/Html2 261 | DocProject/Help/html 262 | 263 | # Click-Once directory 264 | publish/ 265 | 266 | # Publish Web Output 267 | *.[Pp]ublish.xml 268 | *.azurePubxml 269 | # Note: Comment the next line if you want to checkin your web deploy settings, 270 | # but database connection strings (with potential passwords) will be unencrypted 271 | *.pubxml 272 | *.publishproj 273 | 274 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 275 | # checkin your Azure Web App publish settings, but sensitive information contained 276 | # in these scripts will be unencrypted 277 | PublishScripts/ 278 | 279 | # NuGet Packages 280 | *.nupkg 281 | # NuGet Symbol Packages 282 | *.snupkg 283 | # The packages folder can be ignored because of Package Restore 284 | **/[Pp]ackages/* 285 | # except build/, which is used as an MSBuild target. 286 | !**/[Pp]ackages/build/ 287 | # Uncomment if necessary however generally it will be regenerated when needed 288 | #!**/[Pp]ackages/repositories.config 289 | # NuGet v3's project.json files produces more ignorable files 290 | *.nuget.props 291 | *.nuget.targets 292 | 293 | # Microsoft Azure Build Output 294 | csx/ 295 | *.build.csdef 296 | 297 | # Microsoft Azure Emulator 298 | ecf/ 299 | rcf/ 300 | 301 | # Windows Store app package directories and files 302 | AppPackages/ 303 | BundleArtifacts/ 304 | Package.StoreAssociation.xml 305 | _pkginfo.txt 306 | *.appx 307 | *.appxbundle 308 | *.appxupload 309 | 310 | # Visual Studio cache files 311 | # files ending in .cache can be ignored 312 | *.[Cc]ache 313 | # but keep track of directories ending in .cache 314 | !?*.[Cc]ache/ 315 | 316 | # Others 317 | ClientBin/ 318 | ~$* 319 | *~ 320 | *.dbmdl 321 | *.dbproj.schemaview 322 | *.jfm 323 | *.pfx 324 | *.publishsettings 325 | orleans.codegen.cs 326 | 327 | # Including strong name files can present a security risk 328 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 329 | #*.snk 330 | 331 | # Since there are multiple workflows, uncomment next line to ignore bower_components 332 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 333 | #bower_components/ 334 | 335 | # RIA/Silverlight projects 336 | Generated_Code/ 337 | 338 | # Backup & report files from converting an old project file 339 | # to a newer Visual Studio version. Backup files are not needed, 340 | # because we have git ;-) 341 | _UpgradeReport_Files/ 342 | Backup*/ 343 | UpgradeLog*.XML 344 | UpgradeLog*.htm 345 | ServiceFabricBackup/ 346 | *.rptproj.bak 347 | 348 | # SQL Server files 349 | *.mdf 350 | *.ldf 351 | *.ndf 352 | 353 | # Business Intelligence projects 354 | *.rdl.data 355 | *.bim.layout 356 | *.bim_*.settings 357 | *.rptproj.rsuser 358 | *- [Bb]ackup.rdl 359 | *- [Bb]ackup ([0-9]).rdl 360 | *- [Bb]ackup ([0-9][0-9]).rdl 361 | 362 | # Microsoft Fakes 363 | FakesAssemblies/ 364 | 365 | # GhostDoc plugin setting file 366 | *.GhostDoc.xml 367 | 368 | # Node.js Tools for Visual Studio 369 | .ntvs_analysis.dat 370 | node_modules/ 371 | 372 | # Visual Studio 6 build log 373 | *.plg 374 | 375 | # Visual Studio 6 workspace options file 376 | *.opt 377 | 378 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 379 | *.vbw 380 | 381 | # Visual Studio LightSwitch build output 382 | **/*.HTMLClient/GeneratedArtifacts 383 | **/*.DesktopClient/GeneratedArtifacts 384 | **/*.DesktopClient/ModelManifest.xml 385 | **/*.Server/GeneratedArtifacts 386 | **/*.Server/ModelManifest.xml 387 | _Pvt_Extensions 388 | 389 | # Paket dependency manager 390 | .paket/paket.exe 391 | paket-files/ 392 | 393 | # FAKE - F# Make 394 | .fake/ 395 | 396 | # CodeRush personal settings 397 | .cr/personal 398 | 399 | # Python Tools for Visual Studio (PTVS) 400 | __pycache__/ 401 | *.pyc 402 | 403 | # Cake - Uncomment if you are using it 404 | # tools/** 405 | # !tools/packages.config 406 | 407 | # Tabs Studio 408 | *.tss 409 | 410 | # Telerik's JustMock configuration file 411 | *.jmconfig 412 | 413 | # BizTalk build output 414 | *.btp.cs 415 | *.btm.cs 416 | *.odx.cs 417 | *.xsd.cs 418 | 419 | # OpenCover UI analysis results 420 | OpenCover/ 421 | 422 | # Azure Stream Analytics local run output 423 | ASALocalRun/ 424 | 425 | # MSBuild Binary and Structured Log 426 | *.binlog 427 | 428 | # NVidia Nsight GPU debugger configuration file 429 | *.nvuser 430 | 431 | # MFractors (Xamarin productivity tool) working folder 432 | .mfractor/ 433 | 434 | # Local History for Visual Studio 435 | .localhistory/ 436 | 437 | # BeatPulse healthcheck temp database 438 | healthchecksdb 439 | 440 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 441 | MigrationBackup/ 442 | 443 | # Ionide (cross platform F# VS Code tools) working folder 444 | .ionide/ 445 | 446 | # Fody - auto-generated XML schema 447 | FodyWeavers.xsd 448 | 449 | ### Windows template 450 | # Windows thumbnail cache files 451 | Thumbs.db 452 | Thumbs.db:encryptable 453 | ehthumbs.db 454 | ehthumbs_vista.db 455 | 456 | # Dump file 457 | *.stackdump 458 | 459 | # Folder config file 460 | [Dd]esktop.ini 461 | 462 | # Recycle Bin used on file shares 463 | $RECYCLE.BIN/ 464 | 465 | # Windows Installer files 466 | *.cab 467 | *.msi 468 | *.msix 469 | *.msm 470 | *.msp 471 | 472 | # Windows shortcuts 473 | *.lnk 474 | 475 | ### macOS template 476 | # General 477 | .DS_Store 478 | .AppleDouble 479 | .LSOverride 480 | 481 | # Icon must end with two \r 482 | Icon 483 | 484 | # Thumbnails 485 | ._* 486 | 487 | # Files that might appear in the root of a volume 488 | .DocumentRevisions-V100 489 | .fseventsd 490 | .Spotlight-V100 491 | .TemporaryItems 492 | .Trashes 493 | .VolumeIcon.icns 494 | .com.apple.timemachine.donotpresent 495 | 496 | # Directories potentially created on remote AFP share 497 | .AppleDB 498 | .AppleDesktop 499 | Network Trash Folder 500 | Temporary Items 501 | .apdisk 502 | 503 | 504 | 505 | .idea/ -------------------------------------------------------------------------------- /FullstackWasmFSharpApp.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FullstackWasmFSharpAppBackend", "TodoBackend/FullstackWasmFSharpAppBackend.fsproj", "{73E11C73-F3BA-4679-A69F-F9844DFA1EA2}" 4 | EndProject 5 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TodoFrontend.Client", "TodoFrontend\TodoFrontend.Client.fsproj", "{9DDF1610-DD45-494D-99C6-C97CFCC2EC69}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {73E11C73-F3BA-4679-A69F-F9844DFA1EA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {73E11C73-F3BA-4679-A69F-F9844DFA1EA2}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {73E11C73-F3BA-4679-A69F-F9844DFA1EA2}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {73E11C73-F3BA-4679-A69F-F9844DFA1EA2}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {9DDF1610-DD45-494D-99C6-C97CFCC2EC69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {9DDF1610-DD45-494D-99C6-C97CFCC2EC69}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {9DDF1610-DD45-494D-99C6-C97CFCC2EC69}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {9DDF1610-DD45-494D-99C6-C97CFCC2EC69}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 delneg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example F# WASI App 2 | 3 | 4 | This is an example app to demonstrate the use of F# in WASI using the new https://github.com/SteveSandersonMS/dotnet-wasi-sdk 5 | 6 | Here's the introductory video from CNCF Europe WASM Conference [Bringing WebAssembly to the .NET Mainstream - Steve Sanderson, Microsoft](https://www.youtube.com/watch?v=PIeYw7kJUIg) 7 | 8 | Shoutout to https://www.strathweb.com/2022/03/running-net-7-apps-on-wasi-on-arm64-mac/ 9 | written by http://twitter.com/filip_woj for tips on running it on macOS 10 | 11 | Upd: The issue is fixed in Wasi.Sdk > 0.1.1, https://github.com/dotnet/aspnetcore/pull/41123#issuecomment-1135884829 12 | 13 | ## Preview 14 | 15 |  16 | 17 | 18 | ## Running Backend 19 | 20 | Ensure you have at least .NET 7.0 preview 3 - you can download it here https://dotnet.microsoft.com/en-us/download/dotnet/7.0 21 | 22 | Also, please install `wasmtime` - https://wasmtime.dev/ 23 | 24 | Command to install it from their website is `curl https://wasmtime.dev/install.sh -sSf | bash` - be sure to check the script first (always check what you `sh` from the internet) 25 | 26 | 27 | Then: 28 | 29 | ```bash 30 | cd TodoBackend 31 | dotnet build 32 | wasmtime bin/Debug/net7.0/FullstackWasmFSharpAppBackend.wasm --tcplisten localhost:8080 --env ASPNETCORE_URLS=http://localhost:8080 33 | > info: Microsoft.Hosting.Lifetime 34 | > Now listening on: http://localhost:8080 35 | ``` 36 | 37 | ## Running frontend 38 | 39 | Please note, that currently the backend has hard-coded address of "localhost:5000" for CORS to work. 40 | 41 | Also, the frontend has hard-coded address of the backend to "http://localhost:8080" for simplicity. 42 | 43 | That said, you should be able to run interactive (with .NET Interpreter in WASM) frontend using: 44 | 45 | ```bash 46 | cd TodoFrontend 47 | dotnet run 48 | ``` 49 | 50 | You can build the portable (i.e. AOT and optimized) version of the frontend which can be deployed as static website with: 51 | 52 | ```bash 53 | cd TodoFrontend 54 | dotnet publish -o $(pwd)/publish/ -r Portable 55 | ``` 56 | 57 | After that, you can serve it like any other SPA - for example, locally using [Serve](https://github.com/vercel/serve) 58 | 59 | 60 | ```bash 61 | serve publish/wwwroot/ -p 5000 62 | ``` 63 | 64 | 65 | #### License 66 | 67 | MIT -------------------------------------------------------------------------------- /TodoBackend/FullstackWasmFSharpAppBackend.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | net7.0 6 | --tcplisten localhost:8080 --env ASPNETCORE_URLS=http://localhost:8080 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /TodoBackend/Program.fs: -------------------------------------------------------------------------------- 1 | open System.Collections 2 | open Microsoft.AspNetCore.Builder 3 | open Microsoft.AspNetCore.Http 4 | open Microsoft.Extensions.DependencyInjection 5 | open Microsoft.Extensions.Hosting 6 | open Giraffe 7 | open Giraffe.EndpointRouting 8 | 9 | type Key = int 10 | type Todo = 11 | { Id: Key 12 | Task: string 13 | IsCompleted: bool 14 | } 15 | 16 | type TodoSave = Todo -> Todo 17 | 18 | type TodoCriteria = 19 | | All 20 | | Single of Key 21 | 22 | type TodoFind = TodoCriteria -> Todo[] 23 | type TodoDelete = Key -> Todo option 24 | type TodoUpdate = Todo -> Todo option 25 | 26 | module TodoInMemory = 27 | let find (inMemory : Hashtable) (criteria : TodoCriteria) : Todo[] = 28 | match criteria with 29 | | All -> inMemory.Values |> Seq.cast |> Array.ofSeq 30 | | Single id -> if inMemory.ContainsKey id then (inMemory.Item id :?> Todo |> Array.singleton) else [||] 31 | let save (inMemory : Hashtable) (todo : Todo) : Todo = 32 | inMemory.Add(todo.Id, todo) 33 | todo 34 | 35 | let delete (inMemory: Hashtable) (key: Key) : Todo option = 36 | if inMemory.ContainsKey key then 37 | let todo = inMemory.Item key :?> Todo 38 | inMemory.Remove key 39 | Some todo 40 | else None 41 | 42 | let update (inMemory: Hashtable) (todo: Todo) : Todo option = 43 | if inMemory.ContainsKey todo.Id then 44 | inMemory[todo.Id] <- todo 45 | Some todo 46 | else 47 | None 48 | 49 | 50 | type IServiceCollection with 51 | member this.AddTodoInMemory (inMemory : Hashtable) = 52 | this.AddSingleton(TodoInMemory.find inMemory) |> ignore 53 | this.AddSingleton(TodoInMemory.save inMemory) |> ignore 54 | this.AddSingleton(TodoInMemory.delete inMemory) |> ignore 55 | this.AddSingleton(TodoInMemory.update inMemory) |> ignore 56 | 57 | module TodoHttp = 58 | let getAllTodos: HttpHandler = 59 | fun (next: HttpFunc) (ctx: HttpContext) -> 60 | let find = ctx.GetService() 61 | let todos = find TodoCriteria.All 62 | json todos next ctx 63 | 64 | let createNewTodo: HttpHandler = 65 | fun (next: HttpFunc) (ctx: HttpContext) -> 66 | task { 67 | let save = ctx.GetService() 68 | let! todo = ctx.BindJsonAsync() 69 | return! json (save todo) next ctx 70 | } 71 | 72 | let updateTodo = 73 | fun (next: HttpFunc) (ctx: HttpContext) -> 74 | task { 75 | let upd = ctx.GetService() 76 | let! todo = ctx.BindJsonAsync() 77 | match upd todo with 78 | | Some todo -> return! json todo next ctx 79 | | None -> 80 | return! RequestErrors.NOT_FOUND "Not found" next ctx 81 | } 82 | 83 | let deleteTodo = 84 | fun (id: int) (next: HttpFunc) (ctx: HttpContext) -> 85 | task { 86 | let del = ctx.GetService() 87 | match del id with 88 | | Some todo -> return! json todo next ctx 89 | | None -> 90 | return! RequestErrors.NOT_FOUND "Not found" next ctx 91 | } 92 | 93 | let handlers = 94 | [ 95 | //OPTIONS [route "/" (text "ok" )] 96 | GET [ route "/" getAllTodos ] 97 | POST [ route "/" createNewTodo ] 98 | PUT [ route "/" updateTodo ] 99 | DELETE [ routef "/%i" deleteTodo ] 100 | ] 101 | 102 | 103 | 104 | let endpoints = 105 | [ subRoute "/todos" TodoHttp.handlers 106 | subRoute "/foo" [ GET [ route "/bar" (text "Aloha!") ] ] 107 | GET [ route "/" (text "Hello World") ] 108 | 109 | ] 110 | 111 | 112 | let notFoundHandler = 113 | "Not Found" 114 | |> text 115 | |> RequestErrors.notFound 116 | 117 | let configureApp (appBuilder : IApplicationBuilder) = 118 | //appBuilder.UseCors(Action<_>(fun (b: Infrastructure.CorsPolicyBuilder) -> b.AllowAnyHeader() |> ignore; b.AllowAnyMethod() |> ignore)) |> ignore 119 | appBuilder.UseCors(fun builder -> 120 | builder.WithOrigins("http://localhost:5000").AllowAnyMethod().AllowAnyHeader() |> ignore 121 | ) |> ignore 122 | appBuilder 123 | .UseRouting() 124 | .UseGiraffe(endpoints) 125 | .UseGiraffe(notFoundHandler) 126 | 127 | let configureServices (services : IServiceCollection) = 128 | let inMemory = Hashtable() 129 | services 130 | .AddRouting() 131 | .AddGiraffe() 132 | .AddTodoInMemory(Hashtable()) 133 | services.AddSingleton(TodoInMemory.find inMemory) |> ignore 134 | services.AddSingleton(TodoInMemory.save inMemory) |> ignore 135 | services.AddSingleton(TodoInMemory.update inMemory) |> ignore 136 | services.AddSingleton(TodoInMemory.delete inMemory) |> ignore 137 | services.AddCors() |> ignore 138 | 139 | 140 | [] 141 | let main args = 142 | let builder = WebApplication.CreateBuilder(args).UseWasiConnectionListener() 143 | configureServices builder.Services 144 | 145 | let app = builder.Build() 146 | 147 | if app.Environment.IsDevelopment() then 148 | app.UseDeveloperExceptionPage() |> ignore 149 | 150 | configureApp app 151 | app.Run() 152 | 153 | 0 154 | -------------------------------------------------------------------------------- /TodoBackend/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "FullstackWasmFSharpAppBackend": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "launchBrowser": false, 7 | "applicationUrl": "http://localhost:8080", 8 | "environmentVariables": { 9 | "ASPNETCORE_ENVIRONMENT": "Development" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /TodoBackend/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /TodoBackend/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /TodoFrontend/Main.fs: -------------------------------------------------------------------------------- 1 | module TodoFrontend.Client.Main 2 | 3 | open System 4 | open System.Net.Http 5 | open System.Net.Http.Json 6 | open Microsoft.AspNetCore.Components 7 | open Elmish 8 | open Bolero 9 | open Bolero.Html 10 | 11 | 12 | /// Parses the template.html file and provides types to fill it with dynamic content. 13 | type MasterTemplate = Template<"template.html"> 14 | 15 | /// Our application has three URL endpoints. 16 | type EndPoint = 17 | | [] All 18 | | [] Active 19 | | [] Completed 20 | 21 | /// This module defines the model, the update and the view for a single entry. 22 | module Entry = 23 | 24 | /// The unique identifier of a Todo entry. 25 | type Key = int 26 | 27 | /// The model for a Todo entry. 28 | type Model = 29 | { 30 | Id : Key 31 | Task : string 32 | IsCompleted : bool 33 | Editing : option 34 | } 35 | 36 | let New (key: Key) (task: string) = 37 | { 38 | Id = key 39 | Task = task 40 | IsCompleted = false 41 | Editing = None 42 | } 43 | 44 | type Message = 45 | | Remove 46 | | StartEdit 47 | | Edit of text: string 48 | | CommitEdit 49 | | CancelEdit 50 | | SetCompleted of completed: bool 51 | 52 | /// Defines how a given Todo entry is updated based on a message. 53 | /// Returns Some to update the entry, or None to delete it. 54 | let Update (msg: Message) (e: Model) : option = 55 | match msg with 56 | | Remove -> 57 | None 58 | | StartEdit -> 59 | Some { e with Editing = Some e.Task } 60 | | Edit value -> 61 | Some { e with Editing = e.Editing |> Option.map (fun _ -> value) } 62 | | CommitEdit -> 63 | Some { e with 64 | Task = e.Editing |> Option.defaultValue e.Task 65 | Editing = None } 66 | | CancelEdit -> 67 | Some { e with Editing = None } 68 | | SetCompleted value -> 69 | Some { e with IsCompleted = value } 70 | 71 | /// Render a given Todo entry. 72 | let Render (endpoint, entry) dispatch = 73 | MasterTemplate.Entry() 74 | .Label(text entry.Task) 75 | .CssAttrs( 76 | attr.``class`` (String.concat " " [ 77 | if entry.IsCompleted then "completed" 78 | if entry.Editing.IsSome then "editing" 79 | match endpoint, entry.IsCompleted with 80 | | EndPoint.Completed, false 81 | | EndPoint.Active, true -> "hidden" 82 | | _ -> () 83 | ]) 84 | ) 85 | .EditingTask( 86 | entry.Editing |> Option.defaultValue "", 87 | fun text -> dispatch (Message.Edit text) 88 | ) 89 | .EditBlur(fun _ -> dispatch Message.CommitEdit) 90 | .EditKeyup(fun e -> 91 | match e.Key with 92 | | "Enter" -> dispatch Message.CommitEdit 93 | | "Escape" -> dispatch Message.CancelEdit 94 | | _ -> () 95 | ) 96 | .IsCompleted( 97 | entry.IsCompleted, 98 | fun x -> dispatch (Message.SetCompleted x) 99 | ) 100 | .Remove(fun _ -> dispatch Message.Remove) 101 | .StartEdit(fun _ -> dispatch Message.StartEdit) 102 | .Elt() 103 | 104 | type Component() = 105 | inherit ElmishComponent() 106 | 107 | override this.ShouldRender(oldModel, newModel) = oldModel <> newModel 108 | 109 | override this.View model dispatch = Render model dispatch 110 | 111 | /// This module defines the model, the update and the view for a full todo list. 112 | module TodoList = 113 | 114 | /// The model for the full TodoList application. 115 | type Model = 116 | { 117 | EndPoint : EndPoint 118 | NewTask : string 119 | Entries : Entry.Model array 120 | NextKey : Entry.Key 121 | error: string option 122 | } 123 | 124 | static member Empty = 125 | { 126 | EndPoint = All 127 | NewTask = "" 128 | Entries = [||] 129 | NextKey = 0 130 | error = None 131 | } 132 | 133 | type Message = 134 | | GetEntries 135 | | GotEntries of Entry.Model array 136 | | EditNewTask of text: string 137 | | AddEntry 138 | | EntryAdded of Entry.Model 139 | | EntryDeleted of Entry.Key 140 | | EntryUpdated of Entry.Model 141 | | ClearCompleted 142 | | SetAllCompleted of completed: bool 143 | | EntryMessage of key: Entry.Key * message: Entry.Message 144 | | SetEndPoint of EndPoint 145 | | Error of exn 146 | 147 | let Router = Router.infer SetEndPoint (fun m -> m.EndPoint) 148 | 149 | /// Defines how the Todo list is updated based on a message. 150 | let Update (http: HttpClient) (msg: Message) (model: Model) = 151 | match msg with 152 | | GetEntries -> 153 | let getEntries() = http.GetFromJsonAsync("/todos") 154 | let cmd = Cmd.OfTask.either getEntries () GotEntries Error 155 | { model with Entries = [||] }, cmd 156 | | GotEntries entries -> 157 | {model with Entries = entries |> Array.sortBy (fun x -> x.Id)}, Cmd.none 158 | | EditNewTask value -> 159 | { model with NewTask = value }, Cmd.none 160 | | AddEntry -> 161 | let newEntry = Entry.New model.NextKey model.NewTask 162 | let saveEntry() = 163 | task { 164 | let! res = http.PostAsJsonAsync("/todos",newEntry) 165 | return! res.Content.ReadFromJsonAsync() 166 | } 167 | let cmd = Cmd.OfTask.either saveEntry () EntryAdded Error 168 | { model with 169 | NewTask = "" 170 | Entries = model.Entries 171 | NextKey = model.NextKey + 1 }, cmd 172 | | EntryAdded entry -> 173 | {model with Entries = Array.append model.Entries [|entry|]}, Cmd.none 174 | | ClearCompleted -> 175 | { model with Entries = Array.filter (fun e -> not e.IsCompleted) model.Entries }, Cmd.none 176 | | SetAllCompleted c -> 177 | { model with Entries = Array.map (fun e -> { e with IsCompleted = c }) model.Entries }, Cmd.none 178 | | EntryMessage (key, msg) -> 179 | let updated = model.Entries 180 | |> Array.tryFind (fun (x: Entry.Model) -> x.Id = key) 181 | |> Option.bind (Entry.Update msg) 182 | match updated with 183 | | None -> 184 | let deleteEntry () = 185 | task { 186 | let! res = http.DeleteAsync($"/todos/{key}") 187 | let! deser = res.Content.ReadFromJsonAsync() 188 | return deser.Id 189 | } 190 | let cmd = Cmd.OfTask.either deleteEntry () EntryDeleted Error 191 | model, cmd 192 | | Some e -> 193 | let updateEntry () = 194 | task { 195 | let! res = http.PutAsJsonAsync("/todos", e) 196 | // let! cont = res.Content.ReadAsStringAsync() 197 | // Console.WriteLine(cont) 198 | return! res.Content.ReadFromJsonAsync() 199 | } 200 | let cmd = Cmd.OfTask.either updateEntry () EntryUpdated Error 201 | model, cmd 202 | | EntryDeleted key -> 203 | {model with Entries = Array.filter (fun x -> x.Id <> key) model.Entries}, Cmd.none 204 | | EntryUpdated entry -> 205 | let newEntries = Array.map (fun (x: Entry.Model) -> if x.Id = entry.Id then entry else x) model.Entries 206 | { model with Entries = newEntries}, Cmd.none 207 | | SetEndPoint ep -> 208 | { model with EndPoint = ep }, Cmd.none 209 | | Error exn -> 210 | Console.WriteLine(exn.Message) 211 | { model with error = Some exn.Message }, Cmd.none 212 | 213 | /// Render the whole application. 214 | let Render (state: Model) (dispatch: Dispatch) = 215 | let countNotCompleted = 216 | state.Entries 217 | |> Array.filter (fun e -> not e.IsCompleted) 218 | |> Array.length 219 | MasterTemplate() 220 | .HiddenIfNoEntries(if Array.isEmpty state.Entries then "hidden" else "") 221 | .Entries(concat { 222 | for entry in state.Entries do 223 | let entryDispatch msg = dispatch (EntryMessage (entry.Id, msg)) 224 | ecomp (state.EndPoint, entry) entryDispatch 225 | }) 226 | // .ClearCompleted(fun _ -> dispatch Message.ClearCompleted) 227 | // .IsCompleted( 228 | // (countNotCompleted = 0), 229 | // fun c -> dispatch (Message.SetAllCompleted c) 230 | // ) 231 | .Task( 232 | state.NewTask, 233 | fun text -> dispatch (Message.EditNewTask text) 234 | ) 235 | .Edit(fun e -> 236 | if e.Key = "Enter" && state.NewTask <> "" then 237 | dispatch Message.AddEntry 238 | ) 239 | .ItemsLeft( 240 | match countNotCompleted with 241 | | 1 -> "1 item left" 242 | | n -> string n + " items left" 243 | ) 244 | .CssFilterAll(attr.``class`` (if state.EndPoint = EndPoint.All then "selected" else null)) 245 | .CssFilterActive(attr.``class`` (if state.EndPoint = EndPoint.Active then "selected" else null)) 246 | .CssFilterCompleted(attr.``class`` (if state.EndPoint = EndPoint.Completed then "selected" else null)) 247 | .Elt() 248 | 249 | /// The entry point of our application, called on page load (see Startup.fs). 250 | type Component() = 251 | inherit ProgramComponent() 252 | [] 253 | member val HttpClient = Unchecked.defaultof with get, set 254 | 255 | override this.Program = 256 | let update = Update this.HttpClient 257 | Program.mkProgram (fun _ -> Model.Empty, Cmd.ofMsg GetEntries) update Render 258 | |> Program.withRouter Router -------------------------------------------------------------------------------- /TodoFrontend/Startup.fs: -------------------------------------------------------------------------------- 1 | namespace TodoFrontend.Client 2 | 3 | open Microsoft.AspNetCore.Components.WebAssembly.Hosting 4 | open Microsoft.Extensions.DependencyInjection 5 | open System 6 | open System.Net.Http 7 | 8 | module Program = 9 | 10 | [] 11 | let Main args = 12 | let builder = WebAssemblyHostBuilder.CreateDefault(args) 13 | builder.RootComponents.Add(".todoapp") 14 | // let backendUri = Uri builder.HostEnvironment.BaseAddress 15 | let backendUri = Uri "http://localhost:8080" 16 | printfn $"Building with backendAddr {backendUri}" 17 | builder.Services.AddScoped(fun _ -> 18 | new HttpClient(BaseAddress = backendUri)) |> ignore 19 | builder.Build().RunAsync() |> ignore 20 | 0 21 | -------------------------------------------------------------------------------- /TodoFrontend/TodoFrontend.Client.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /TodoFrontend/template.html: -------------------------------------------------------------------------------- 1 | 2 | todos 3 | 4 | 5 | 6 | 7 | 8 | 9 | ${Entries} 10 | 11 | 12 | 13 | 14 | ${Label} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /TodoFrontend/wwwroot/base.css: -------------------------------------------------------------------------------- 1 | hr { 2 | margin: 20px 0; 3 | border: 0; 4 | border-top: 1px dashed #c5c5c5; 5 | border-bottom: 1px dashed #f7f7f7; 6 | } 7 | 8 | .learn a { 9 | font-weight: normal; 10 | text-decoration: none; 11 | color: #b83f45; 12 | } 13 | 14 | .learn a:hover { 15 | text-decoration: underline; 16 | color: #787e7e; 17 | } 18 | 19 | .learn h3, 20 | .learn h4, 21 | .learn h5 { 22 | margin: 10px 0; 23 | font-weight: 500; 24 | line-height: 1.2; 25 | color: #000; 26 | } 27 | 28 | .learn h3 { 29 | font-size: 24px; 30 | } 31 | 32 | .learn h4 { 33 | font-size: 18px; 34 | } 35 | 36 | .learn h5 { 37 | margin-bottom: 0; 38 | font-size: 14px; 39 | } 40 | 41 | .learn ul { 42 | padding: 0; 43 | margin: 0 0 30px 25px; 44 | } 45 | 46 | .learn li { 47 | line-height: 20px; 48 | } 49 | 50 | .learn p { 51 | font-size: 15px; 52 | font-weight: 300; 53 | line-height: 1.3; 54 | margin-top: 0; 55 | margin-bottom: 0; 56 | } 57 | 58 | #issue-count { 59 | display: none; 60 | } 61 | 62 | .quote { 63 | border: none; 64 | margin: 20px 0 60px 0; 65 | } 66 | 67 | .quote p { 68 | font-style: italic; 69 | } 70 | 71 | .quote p:before { 72 | content: '“'; 73 | font-size: 50px; 74 | opacity: .15; 75 | position: absolute; 76 | top: -20px; 77 | left: 3px; 78 | } 79 | 80 | .quote p:after { 81 | content: '”'; 82 | font-size: 50px; 83 | opacity: .15; 84 | position: absolute; 85 | bottom: -42px; 86 | right: 3px; 87 | } 88 | 89 | .quote footer { 90 | position: absolute; 91 | bottom: -40px; 92 | right: 0; 93 | } 94 | 95 | .quote footer img { 96 | border-radius: 3px; 97 | } 98 | 99 | .quote footer a { 100 | margin-left: 5px; 101 | vertical-align: middle; 102 | } 103 | 104 | .speech-bubble { 105 | position: relative; 106 | padding: 10px; 107 | background: rgba(0, 0, 0, .04); 108 | border-radius: 5px; 109 | } 110 | 111 | .speech-bubble:after { 112 | content: ''; 113 | position: absolute; 114 | top: 100%; 115 | right: 30px; 116 | border: 13px solid transparent; 117 | border-top-color: rgba(0, 0, 0, .04); 118 | } 119 | 120 | .learn-bar > .learn { 121 | position: absolute; 122 | width: 272px; 123 | top: 8px; 124 | left: -300px; 125 | padding: 10px; 126 | border-radius: 5px; 127 | background-color: rgba(255, 255, 255, .6); 128 | transition-property: left; 129 | transition-duration: 500ms; 130 | } 131 | 132 | @media (min-width: 899px) { 133 | .learn-bar { 134 | width: auto; 135 | padding-left: 300px; 136 | } 137 | 138 | .learn-bar > .learn { 139 | left: 8px; 140 | } 141 | } -------------------------------------------------------------------------------- /TodoFrontend/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delneg/FullstackWasmFSharpApp/9f2324d57aa26168c7a5fc5cd21fa268f2d38841/TodoFrontend/wwwroot/favicon.ico -------------------------------------------------------------------------------- /TodoFrontend/wwwroot/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body { 24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 25 | line-height: 1.4em; 26 | background: #f5f5f5; 27 | color: #4d4d4d; 28 | min-width: 230px; 29 | max-width: 550px; 30 | margin: 0 auto; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-weight: 300; 34 | } 35 | 36 | :focus { 37 | outline: 0; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | .todoapp { 45 | background: #fff; 46 | margin: 130px 0 40px 0; 47 | position: relative; 48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 49 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 50 | } 51 | 52 | .todoapp input::-webkit-input-placeholder { 53 | font-style: italic; 54 | font-weight: 300; 55 | color: #e6e6e6; 56 | } 57 | 58 | .todoapp input::-moz-placeholder { 59 | font-style: italic; 60 | font-weight: 300; 61 | color: #e6e6e6; 62 | } 63 | 64 | .todoapp input::input-placeholder { 65 | font-style: italic; 66 | font-weight: 300; 67 | color: #e6e6e6; 68 | } 69 | 70 | .todoapp h1 { 71 | position: absolute; 72 | top: -155px; 73 | width: 100%; 74 | font-size: 100px; 75 | font-weight: 100; 76 | text-align: center; 77 | color: rgba(175, 47, 47, 0.15); 78 | -webkit-text-rendering: optimizeLegibility; 79 | -moz-text-rendering: optimizeLegibility; 80 | text-rendering: optimizeLegibility; 81 | } 82 | 83 | .new-todo, 84 | .edit { 85 | position: relative; 86 | margin: 0; 87 | width: 100%; 88 | font-size: 24px; 89 | font-family: inherit; 90 | font-weight: inherit; 91 | line-height: 1.4em; 92 | border: 0; 93 | color: inherit; 94 | padding: 6px; 95 | border: 1px solid #999; 96 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 97 | box-sizing: border-box; 98 | -webkit-font-smoothing: antialiased; 99 | -moz-osx-font-smoothing: grayscale; 100 | } 101 | 102 | .new-todo { 103 | padding: 16px 16px 16px 60px; 104 | border: none; 105 | background: rgba(0, 0, 0, 0.003); 106 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 107 | } 108 | 109 | .main { 110 | position: relative; 111 | z-index: 2; 112 | border-top: 1px solid #e6e6e6; 113 | } 114 | 115 | .toggle-all { 116 | width: 1px; 117 | height: 1px; 118 | border: none; /* Mobile Safari */ 119 | opacity: 0; 120 | position: absolute; 121 | right: 100%; 122 | bottom: 100%; 123 | } 124 | 125 | .toggle-all + label { 126 | width: 60px; 127 | height: 34px; 128 | font-size: 0; 129 | position: absolute; 130 | top: -52px; 131 | left: -13px; 132 | -webkit-transform: rotate(90deg); 133 | transform: rotate(90deg); 134 | } 135 | 136 | .toggle-all + label:before { 137 | content: '❯'; 138 | font-size: 22px; 139 | color: #e6e6e6; 140 | padding: 10px 27px 10px 27px; 141 | } 142 | 143 | .toggle-all:checked + label:before { 144 | color: #737373; 145 | } 146 | 147 | .todo-list { 148 | margin: 0; 149 | padding: 0; 150 | list-style: none; 151 | } 152 | 153 | .todo-list li { 154 | position: relative; 155 | font-size: 24px; 156 | border-bottom: 1px solid #ededed; 157 | } 158 | 159 | .todo-list li:last-child { 160 | border-bottom: none; 161 | } 162 | 163 | .todo-list li.editing { 164 | border-bottom: none; 165 | padding: 0; 166 | } 167 | 168 | .todo-list li.editing .edit { 169 | display: block; 170 | width: 506px; 171 | padding: 12px 16px; 172 | margin: 0 0 0 43px; 173 | } 174 | 175 | .todo-list li.editing .view { 176 | display: none; 177 | } 178 | 179 | .todo-list li .toggle { 180 | text-align: center; 181 | width: 40px; 182 | /* auto, since non-WebKit browsers doesn't support input styling */ 183 | height: auto; 184 | position: absolute; 185 | top: 0; 186 | bottom: 0; 187 | margin: auto 0; 188 | border: none; /* Mobile Safari */ 189 | -webkit-appearance: none; 190 | appearance: none; 191 | } 192 | 193 | .todo-list li .toggle { 194 | opacity: 0; 195 | } 196 | 197 | .todo-list li .toggle + label { 198 | /* 199 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 200 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 201 | */ 202 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 203 | background-repeat: no-repeat; 204 | background-position: center left; 205 | } 206 | 207 | .todo-list li .toggle:checked + label { 208 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); 209 | } 210 | 211 | .todo-list li label { 212 | word-break: break-all; 213 | padding: 15px 15px 15px 60px; 214 | display: block; 215 | line-height: 1.2; 216 | transition: color 0.4s; 217 | } 218 | 219 | .todo-list li.completed label { 220 | color: #d9d9d9; 221 | text-decoration: line-through; 222 | } 223 | 224 | .todo-list li .destroy { 225 | display: none; 226 | position: absolute; 227 | top: 0; 228 | right: 10px; 229 | bottom: 0; 230 | width: 40px; 231 | height: 40px; 232 | margin: auto 0; 233 | font-size: 30px; 234 | color: #cc9a9a; 235 | margin-bottom: 11px; 236 | transition: color 0.2s ease-out; 237 | } 238 | 239 | .todo-list li .destroy:hover { 240 | color: #af5b5e; 241 | } 242 | 243 | .todo-list li .destroy:after { 244 | content: '×'; 245 | } 246 | 247 | .todo-list li:hover .destroy { 248 | display: block; 249 | } 250 | 251 | .todo-list li .edit { 252 | display: none; 253 | } 254 | 255 | .todo-list li.editing:last-child { 256 | margin-bottom: -1px; 257 | } 258 | 259 | .footer { 260 | color: #777; 261 | padding: 10px 15px; 262 | height: 20px; 263 | text-align: center; 264 | border-top: 1px solid #e6e6e6; 265 | } 266 | 267 | .footer:before { 268 | content: ''; 269 | position: absolute; 270 | right: 0; 271 | bottom: 0; 272 | left: 0; 273 | height: 50px; 274 | overflow: hidden; 275 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 276 | 0 8px 0 -3px #f6f6f6, 277 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 278 | 0 16px 0 -6px #f6f6f6, 279 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 280 | } 281 | 282 | .todo-count { 283 | float: left; 284 | text-align: left; 285 | } 286 | 287 | .todo-count strong { 288 | font-weight: 300; 289 | } 290 | 291 | .filters { 292 | margin: 0; 293 | padding: 0; 294 | list-style: none; 295 | position: absolute; 296 | right: 0; 297 | left: 0; 298 | } 299 | 300 | .filters li { 301 | display: inline; 302 | } 303 | 304 | .filters li a { 305 | color: inherit; 306 | margin: 3px; 307 | padding: 3px 7px; 308 | text-decoration: none; 309 | border: 1px solid transparent; 310 | border-radius: 3px; 311 | } 312 | 313 | .filters li a:hover { 314 | border-color: rgba(175, 47, 47, 0.1); 315 | } 316 | 317 | .filters li a.selected { 318 | border-color: rgba(175, 47, 47, 0.2); 319 | } 320 | 321 | .clear-completed, 322 | html .clear-completed:active { 323 | float: right; 324 | position: relative; 325 | line-height: 20px; 326 | text-decoration: none; 327 | cursor: pointer; 328 | } 329 | 330 | .clear-completed:hover { 331 | text-decoration: underline; 332 | } 333 | 334 | .info { 335 | margin: 65px auto 0; 336 | color: #bfbfbf; 337 | font-size: 10px; 338 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 339 | text-align: center; 340 | } 341 | 342 | .info p { 343 | line-height: 1; 344 | } 345 | 346 | .info a { 347 | color: inherit; 348 | text-decoration: none; 349 | font-weight: 400; 350 | } 351 | 352 | .info a:hover { 353 | text-decoration: underline; 354 | } 355 | 356 | /* 357 | Hack to remove background from Mobile Safari. 358 | Can't use it globally since it destroys checkboxes in Firefox 359 | */ 360 | @media screen and (-webkit-min-device-pixel-ratio:0) { 361 | .toggle-all, 362 | .todo-list li .toggle { 363 | background: none; 364 | } 365 | 366 | .todo-list li .toggle { 367 | height: 40px; 368 | } 369 | } 370 | 371 | @media (max-width: 430px) { 372 | .footer { 373 | height: 50px; 374 | } 375 | 376 | .filters { 377 | bottom: 10px; 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /TodoFrontend/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Fullstack WASM F# 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delneg/FullstackWasmFSharpApp/9f2324d57aa26168c7a5fc5cd21fa268f2d38841/assets/screenshot.png --------------------------------------------------------------------------------