├── .firebaserc ├── wakatime-stats.png ├── src └── Mandadin.Client │ ├── wwwroot │ ├── icon.png │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── apple-icon-precomposed.png │ ├── js │ │ ├── overlay-controls.js │ │ ├── clipboard.js │ │ ├── types.d.ts │ │ ├── share.js │ │ ├── interop.js │ │ ├── theme.js │ │ └── database.js │ ├── icons │ │ ├── text.html │ │ ├── check.html │ │ ├── back.html │ │ ├── close.html │ │ ├── copy.html │ │ ├── save.html │ │ ├── import.html │ │ ├── trash.html │ │ ├── clipboard.html │ │ └── share.html │ ├── service-worker.js │ ├── browserconfig.xml │ ├── manifest.webmanifest │ ├── index.html │ ├── css │ │ └── index.css │ ├── service-worker.published.js │ └── lib │ │ └── paper.min.css │ ├── Parser.fsi │ ├── Router.fs │ ├── Startup.fs │ ├── Mandadin.Client.fsproj │ ├── Components │ ├── Navbar.fs │ └── TrackListItems.fs │ ├── Parser.fs │ ├── Types.fs │ ├── Views │ ├── Import.fs │ ├── Notes.fs │ ├── TrackLists.fs │ └── TrackListItems.fs │ ├── Modals.fs │ ├── Services.fs │ └── Main.fs ├── .config └── dotnet-tools.json ├── .vscode ├── tasks.json └── launch.json ├── deploy.ps1 ├── jsconfig.json ├── .github └── workflows │ └── firebase-hosting-merge.yml ├── .editorconfig ├── LICENSE ├── firebase.json ├── Mandadin.sln ├── README.md └── .gitignore /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "mandadin-4" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /wakatime-stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/wakatime-stats.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/icon.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/favicon-16x16.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/favicon-32x32.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/favicon-96x96.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/ms-icon-70x70.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-57x57.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-60x60.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-72x72.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-76x76.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/ms-icon-144x144.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/ms-icon-150x150.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/ms-icon-310x310.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/android-icon-36x36.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/android-icon-48x48.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/android-icon-72x72.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/android-icon-96x96.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-114x114.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-120x120.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-144x144.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-152x152.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-180x180.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/android-icon-144x144.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/android-icon-192x192.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AngelMunoz/Mandadin/HEAD/src/Mandadin.Client/wwwroot/apple-icon-precomposed.png -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/overlay-controls.js: -------------------------------------------------------------------------------- 1 | export function HasOverlayControls() { 2 | if ('windowControlsOverlay' in navigator) { return true; } 3 | return false; 4 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/check.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "fantomas": { 6 | "version": "6.2.3", 7 | "commands": [ 8 | "fantomas" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/Parser.fsi: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open TheBlunt 4 | 5 | [] 6 | module Parse = 7 | val entries: lines: string array -> Result 8 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/back.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/close.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/copy.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/save.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "msbuild", 6 | "problemMatcher": [ 7 | "$msCompile" 8 | ], 9 | "group": "build", 10 | "label": "build", 11 | "detail": "Build the Mandadin.Client.fsproj project using dotnet build" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/service-worker.js: -------------------------------------------------------------------------------- 1 | // In development, always fetch from the network and do not enable offline support. 2 | // This is because caching would make development more difficult (changes would not 3 | // be reflected on the first load after each change). 4 | self.addEventListener('fetch', () => {}); 5 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/import.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /deploy.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | Write-Host "Deleting 'dist' Directory"; 3 | Remove-Item -Recurse "./dist" -ErrorAction SilentlyContinue; 4 | Write-Host "Building Release"; 5 | dotnet publish -c Release -o dist; 6 | if ($LASTEXITCODE -gt 0) { 7 | Stop-Process -Name "Failed To build" -ErrorAction Stop 8 | } 9 | Write-Host "Publishing"; 10 | firebase deploy; -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/trash.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/clipboard.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "es2015", 5 | "checkJs": true, 6 | "moduleResolution": "node", 7 | "baseUrl": "./src/Mandadin.Client", 8 | "paths": { 9 | "/*": ["wwwroot/*"] 10 | } 11 | }, 12 | "include": [ 13 | "src/Mandadin.Client/wwwroot/js" 14 | ], 15 | "typeAcquisition": { 16 | "enable": true, 17 | "include": [ 18 | "@types/pouchdb" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/icons/share.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/clipboard.js: -------------------------------------------------------------------------------- 1 | 2 | const clipboard = navigator.clipboard; 3 | 4 | 5 | /** 6 | * 7 | * @param {string} text 8 | * @returns {Promise} 9 | */ 10 | export function CopyTextToClipboard(text) { 11 | if (!clipboard) return Promise.reject("Clipboard API not available"); 12 | return clipboard.writeText(text); 13 | } 14 | 15 | /** 16 | * * @returns {Promise} 17 | */ 18 | export function ReadTextFromClipboard() { 19 | if (!clipboard) return Promise.reject("Clipboard API not available"); 20 | return clipboard.readText(); 21 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/types.d.ts: -------------------------------------------------------------------------------- 1 | type Theme = "Light" | "Dark"; 2 | // Enable Custom Theme Later 3 | // | ['Custom', string[]] 4 | 5 | type Note = { 6 | id: string; 7 | content: string; 8 | rev: string; 9 | }; 10 | 11 | type List = { 12 | id: string; 13 | rev: string; 14 | }; 15 | 16 | type ListItem = { 17 | id: string; 18 | isDone: boolean; 19 | listId: string; 20 | name: string; 21 | rev: string; 22 | }; 23 | 24 | type Elements = { 25 | GetValue: (id: any) => string; 26 | }; 27 | 28 | declare interface Window { 29 | Mandadin: { Theme; Share; Clipboard; Database; Elements }; 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "blazorwasm", 9 | "name": "Bolero App", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/src/Mandadin.Client/bin/Debug/net8.0/Mandadin.Client.dll", 12 | "url": "http://localhost:5000", 13 | "cwd": "${workspaceFolder}", 14 | "env": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | "on": 6 | push: 7 | branches: 8 | - master 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup dotnet 15 | uses: actions/setup-dotnet@v3 16 | with: 17 | dotnet-version: "8.x.x" 18 | - run: dotnet workload install wasm-tools 19 | - run: dotnet publish -c Release src/Mandadin.Client -o dist 20 | - uses: FirebaseExtended/action-hosting-deploy@v0 21 | with: 22 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 23 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_MANDADIN_4 }}" 24 | channelId: live 25 | projectId: mandadin-4 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.fs] 2 | indent_size=2 3 | max_line_length=80 4 | fsharp_semicolon_at_end_of_line=false 5 | fsharp_space_before_parameter=true 6 | fsharp_space_before_lowercase_invocation=true 7 | fsharp_space_before_uppercase_invocation=false 8 | fsharp_space_before_class_constructor=false 9 | fsharp_space_before_member=false 10 | fsharp_space_before_colon=false 11 | fsharp_space_after_comma=true 12 | fsharp_space_before_semicolon=false 13 | fsharp_space_after_semicolon=true 14 | fsharp_indent_on_try_with=false 15 | fsharp_space_around_delimiter=true 16 | fsharp_max_if_then_else_short_width=40 17 | fsharp_max_infix_operator_expression=50 18 | fsharp_max_record_width=40 19 | fsharp_max_array_or_list_width=40 20 | fsharp_max_value_binding_width=40 21 | fsharp_max_function_binding_width=40 22 | fsharp_multiline_block_brackets_on_same_column=false 23 | fsharp_newline_between_type_definition_and_members=false 24 | fsharp_keep_if_then_in_same_line=false 25 | fsharp_max_elmish_width=20 26 | fsharp_single_argument_web_mode=false 27 | fsharp_align_function_signature_to_indentation=false 28 | fsharp_alternative_long_member_definitions=false 29 | fsharp_strict_mode=false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Angel Daniel Munoz Gonzalez 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. 22 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Router.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open Microsoft.AspNetCore.Components.Routing 4 | 5 | open Bolero 6 | open Bolero.Html 7 | 8 | module Router = 9 | 10 | type IRouterContainer<'Container, 'Model, 'Msg 11 | when 'Container: (static member Router: Router)> = 12 | 'Container 13 | 14 | let inline private navLink<'Container, 'Model, 'Msg 15 | when IRouterContainer<'Container, 'Msg, 'Model>> 16 | view 17 | label 18 | classes 19 | hash 20 | linkMatch 21 | = 22 | let href = 23 | 'Container.Router.HRef(view, defaultArg hash null) 24 | 25 | li { 26 | navLink (defaultArg linkMatch NavLinkMatch.All) { 27 | attr.``class`` (defaultArg classes "") 28 | href 29 | text label 30 | } 31 | } 32 | 33 | type Router = 34 | 35 | static member inline navLink<'Container, 'Model, 'Msg 36 | when IRouterContainer<'Container, 'Model, 'Msg>> 37 | ( 38 | view: Page, 39 | label: string, 40 | ?classes: string, 41 | ?hash: string, 42 | ?linkMatch: NavLinkMatch 43 | ) = 44 | navLink<'Container, 'Msg, 'Model> view label classes hash linkMatch 45 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist/wwwroot", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ], 15 | "headers": [ 16 | { 17 | "source": "**/*.@(blat|dll|webcil|dat)", 18 | "headers": [ 19 | { 20 | "key": "content-type", 21 | "value": "application/octet-stream" 22 | } 23 | ] 24 | }, 25 | { 26 | "source": "**/*.wasm", 27 | "headers": [ 28 | { 29 | "key": "content-type", 30 | "value": "application/wasm" 31 | } 32 | ] 33 | }, 34 | { 35 | "source": "**/*.json", 36 | "headers": [ 37 | { 38 | "key": "content-type", 39 | "value": "application/json" 40 | } 41 | ] 42 | }, 43 | { 44 | "source": "**/*.@(woff|woff2)", 45 | "headers": [ 46 | { 47 | "key": "content-type", 48 | "value": "application/font-woff" 49 | } 50 | ] 51 | } 52 | ] 53 | } 54 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/Startup.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open Microsoft.Extensions.DependencyInjection 4 | open Microsoft.Extensions.Logging 5 | open Microsoft.AspNetCore.Components.WebAssembly.Hosting 6 | 7 | 8 | module Program = 9 | 10 | [] 11 | let Main args = 12 | task { 13 | let builder = 14 | WebAssemblyHostBuilder.CreateDefault(args) 15 | 16 | builder.RootComponents.Add("#main") 17 | 18 | let level = 19 | if builder.HostEnvironment.Environment = "Production" then 20 | LogLevel.Information 21 | else 22 | LogLevel.Debug 23 | 24 | builder.Logging.SetMinimumLevel(level) |> ignore 25 | 26 | builder.Logging.AddFilter( 27 | "Microsoft.AspNetCore.Components.RenderTree.*", 28 | LogLevel.Warning 29 | ) 30 | |> ignore 31 | 32 | 33 | builder.Services 34 | .AddSingleton(Services.ListItems.factory) 35 | .AddSingleton(Services.Notes.factory) 36 | .AddSingleton(Services.Share.factory) 37 | |> ignore 38 | 39 | let app = builder.Build() 40 | 41 | do! app.RunAsync() 42 | } 43 | |> Async.AwaitTask 44 | |> Async.Start 45 | 46 | 0 47 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/share.js: -------------------------------------------------------------------------------- 1 | const channel = new BroadcastChannel("share-target"); 2 | 3 | /** 4 | * @returns {Promise} 5 | */ 6 | export function CanShare() { 7 | //@ts-ignore 8 | if (navigator.canShare) { 9 | //@ts-ignore 10 | return window.navigator.canShare({ 11 | title: "", 12 | text: "", 13 | url: "", 14 | }); 15 | } 16 | return Promise.resolve(false); 17 | } 18 | 19 | /** 20 | * 21 | * @param {string} title 22 | * @param {string} text 23 | * @param {string?} url 24 | * @returns {Promise} 25 | */ 26 | export function ShareContent(title, text, url = undefined) { 27 | if (navigator.share) { 28 | return navigator.share({ title, text, url }); 29 | } 30 | return Promise.reject("Share API not available"); 31 | } 32 | 33 | channel.onmessage = function (event) { 34 | const isAllowed = ["SEND_IMPORT_DATA"].includes(event.data.type); 35 | 36 | if ((event.data && !isAllowed) || (!event.data && !event.data.data)) { 37 | return; 38 | } 39 | const data = event.data.data; 40 | if (data && data.text) { 41 | sessionStorage.setItem( 42 | "import", 43 | JSON.stringify({ 44 | title: data.title, 45 | text: data.text, 46 | url: "", 47 | }) 48 | ); 49 | } 50 | }; 51 | 52 | const delay = (time) => 53 | new Promise((resolve) => setTimeout(() => resolve(), time)); 54 | 55 | export async function ImportShareData() { 56 | channel.postMessage({ type: "GET_IMPORT_DATA" }); 57 | await delay(500); 58 | try { 59 | return JSON.parse(sessionStorage.getItem("import")); 60 | } catch (e) { 61 | console.error(e); 62 | return { text: "", title: "", url: "" }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/interop.js: -------------------------------------------------------------------------------- 1 | import { SwitchTheme, GetTheme } from "./theme.js"; 2 | import { CopyTextToClipboard, ReadTextFromClipboard } from "./clipboard.js"; 3 | import { CanShare, ShareContent, ImportShareData } from "./share.js"; 4 | import { 5 | FindNotes, 6 | CreateNote, 7 | UpdateNote, 8 | FindNote, 9 | DeleteNote, 10 | FindLists, 11 | CreateList, 12 | ImportList, 13 | ListNameExists, 14 | DeleteList, 15 | GetListItems, 16 | ListItemExists, 17 | CreateListItem, 18 | UpdateListItem, 19 | DeleteListItem, 20 | GetHideDone, 21 | SaveHideDone, 22 | } from "./database.js"; 23 | 24 | import { HasOverlayControls } from "./overlay-controls.js"; 25 | 26 | // Ensure we have the right theme 27 | // when we load the script 28 | GetTheme(); 29 | 30 | (function (window) { 31 | window.Mandadin = window.Mandadin || { 32 | Theme: { 33 | SwitchTheme, 34 | GetTheme, 35 | HasOverlayControls, 36 | }, 37 | Share: { 38 | CanShare, 39 | ShareContent, 40 | ImportShareData, 41 | }, 42 | Clipboard: { 43 | CopyTextToClipboard, 44 | ReadTextFromClipboard, 45 | }, 46 | Database: { 47 | FindNotes, 48 | CreateNote, 49 | UpdateNote, 50 | FindNote, 51 | DeleteNote, 52 | FindLists, 53 | CreateList, 54 | ImportList, 55 | ListNameExists, 56 | DeleteList, 57 | GetListItems, 58 | ListItemExists, 59 | CreateListItem, 60 | UpdateListItem, 61 | DeleteListItem, 62 | GetHideDone, 63 | SaveHideDone, 64 | }, 65 | Elements: { 66 | GetValue(elt) { 67 | if (elt) { 68 | return elt.value; 69 | } 70 | return ""; 71 | }, 72 | }, 73 | }; 74 | })(window); 75 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Mandadin.Client.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | service-worker-assets.js 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Components/Navbar.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Components 2 | 3 | open Bolero 4 | open Bolero.Html 5 | open Mandadin.Client 6 | open Microsoft.AspNetCore.Components.Routing 7 | 8 | module Navbar = 9 | let collapsibleMenu = 10 | concat { 11 | input { 12 | attr.``class`` "toggle" 13 | attr.id "collapsible1" 14 | attr.name "collapsible1" 15 | attr.``type`` "checkbox" 16 | } 17 | 18 | label { 19 | attr.``for`` "collapsible1" 20 | 21 | for i in 1..3 do 22 | div { attr.``class`` $"bar%i{i}" } 23 | } 24 | } 25 | 26 | type Navbar = 27 | static member View 28 | ( 29 | theme: Theme, 30 | ?onThemeChangeRequest: _ -> unit, 31 | ?menuItems: Node 32 | ) = 33 | let onThemeChangeRequest = 34 | defaultArg onThemeChangeRequest ignore 35 | 36 | let menuItems = 37 | defaultArg menuItems (empty ()) 38 | 39 | nav { 40 | attr.``class`` "split-nav mandadin-navbar" 41 | 42 | section { 43 | attr.``class`` "collapsible" 44 | 45 | collapsibleMenu 46 | 47 | div { 48 | attr.``class`` "collapsible-body" 49 | 50 | ul { 51 | attr.``class`` "inline" 52 | menuItems 53 | 54 | li { 55 | attr.``class`` "cursor pointer" 56 | on.click onThemeChangeRequest 57 | textf "Tema %s" theme.AsDisplay 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | module TitleBar = 65 | let View (title: string option) = 66 | let title = defaultArg title "Hola!" 67 | 68 | header { 69 | attr.``class`` "border mandadin-title-bar" 70 | 71 | navLink NavLinkMatch.All { 72 | attr.href "/" 73 | attr.``class`` "no-drag" 74 | text $"{title} | Mandadin" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mandadin", 3 | "short_name": "Mandadin", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "display_override": [ 7 | "window-controls-overlay" 8 | ], 9 | "icons": [ 10 | { 11 | "src": "/android-icon-36x36.png", 12 | "sizes": "36x36", 13 | "type": "image/png", 14 | "density": "0.75" 15 | }, 16 | { 17 | "src": "/android-icon-48x48.png", 18 | "sizes": "48x48", 19 | "type": "image/png", 20 | "density": "1.0" 21 | }, 22 | { 23 | "src": "/android-icon-72x72.png", 24 | "sizes": "72x72", 25 | "type": "image/png", 26 | "density": "1.5" 27 | }, 28 | { 29 | "src": "/android-icon-96x96.png", 30 | "sizes": "96x96", 31 | "type": "image/png", 32 | "density": "2.0" 33 | }, 34 | { 35 | "src": "/android-icon-144x144.png", 36 | "sizes": "144x144", 37 | "type": "image/png", 38 | "density": "3.0" 39 | }, 40 | { 41 | "src": "/android-icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "density": "4.0" 45 | } 46 | ], 47 | "background_color": "#41403e", 48 | "theme_color": "#41403e", 49 | "orientation": "portrait", 50 | "lang": "es-MX", 51 | "iarc_rating_id": "a4eb77ee-72dd-403e-ad13-857531f15774", 52 | "shortcuts": [ 53 | { 54 | "name": "Mis Listas", 55 | "url": "/lists", 56 | "description": "Ver mis listas actuales", 57 | "short_name": "Listas" 58 | } 59 | ], 60 | "protocol_handlers": [ 61 | { 62 | "protocol": "web+mandadin", 63 | "url": "/lists/%s" 64 | } 65 | ], 66 | "share_target": { 67 | "action": "/import", 68 | "method": "POST", 69 | "enctype": "multipart/form-data", 70 | "params": { 71 | "title": "title", 72 | "text": "text", 73 | "url": "url" 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/Parser.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open TheBlunt 4 | open FsToolkit.ErrorHandling 5 | 6 | module Parse = 7 | let openCheckBox = 8 | pchar (fun c -> c = '[') (fun c -> 9 | $"'{c}' is not a valid character at this position") 10 | 11 | let closeCheckBox = 12 | pchar (fun c -> c = ']') (fun c -> 13 | $"'{c}' is not a valid character at this position") 14 | 15 | let completedCheckBox = 16 | pchar (fun c -> c = 'x') (fun c -> 17 | $"'{c}' is not a valid character at this position") 18 | 19 | let checkBox = 20 | parse { 21 | let! ``open`` = openCheckBox 22 | let! em1 = ptry blanks 23 | 24 | let! isChecked = 25 | ptry completedCheckBox 26 | |> map (fun check -> 27 | match check with 28 | | Some _ -> true 29 | | None -> false) 30 | 31 | let! em2 = ptry blanks 32 | let! ``close`` = closeCheckBox 33 | 34 | return 35 | { range = 36 | Range.merge 37 | [ ``open``.range 38 | em1.range 39 | isChecked.range 40 | em2.range 41 | ``close``.range ] 42 | result = isChecked.result } 43 | } 44 | 45 | let eol = 46 | pchoice [ pstr "\r\n"; pstr "\n"; pstr "\r" ] 47 | 48 | let content = 49 | parse { 50 | let! init = blanks 51 | let! content = many anyChar |> pconcat 52 | let! endl = eoi 53 | 54 | return 55 | { range = 56 | Range.merge 57 | [ init.range 58 | content.range 59 | endl.range ] 60 | result = content.result } 61 | } 62 | 63 | let mandadinEntry = 64 | content |> andThen checkBox 65 | 66 | let inline parseLine line = 67 | match run line mandadinEntry with 68 | | POk { result = isChecked, content } -> 69 | Ok([| box isChecked; box content |]) 70 | | PError e -> Error(line, e) 71 | 72 | let entries lines = 73 | lines 74 | |> List.ofArray 75 | |> List.traverseResultA parseLine 76 | |> Result.map List.toArray 77 | -------------------------------------------------------------------------------- /Mandadin.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{87F6C079-CBD7-4866-BDEB-CBDEA9E51D2A}" 7 | EndProject 8 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Mandadin.Client", "src\Mandadin.Client\Mandadin.Client.fsproj", "{1481BBD6-A6DA-4912-A03A-134880D14701}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Debug|x64 = Debug|x64 14 | Debug|x86 = Debug|x86 15 | Release|Any CPU = Release|Any CPU 16 | Release|x64 = Release|x64 17 | Release|x86 = Release|x86 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Debug|x64.ActiveCfg = Debug|Any CPU 23 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Debug|x64.Build.0 = Debug|Any CPU 24 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Debug|x86.ActiveCfg = Debug|Any CPU 25 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Debug|x86.Build.0 = Debug|Any CPU 26 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Release|x64.ActiveCfg = Release|Any CPU 29 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Release|x64.Build.0 = Release|Any CPU 30 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Release|x86.ActiveCfg = Release|Any CPU 31 | {1481BBD6-A6DA-4912-A03A-134880D14701}.Release|x86.Build.0 = Release|Any CPU 32 | EndGlobalSection 33 | GlobalSection(SolutionProperties) = preSolution 34 | HideSolutionNode = FALSE 35 | EndGlobalSection 36 | GlobalSection(NestedProjects) = preSolution 37 | {1481BBD6-A6DA-4912-A03A-134880D14701} = {87F6C079-CBD7-4866-BDEB-CBDEA9E51D2A} 38 | EndGlobalSection 39 | GlobalSection(ExtensibilityGlobals) = postSolution 40 | SolutionGuid = {8CFD6033-FB8D-48BE-9628-9D83DC023CAB} 41 | EndGlobalSection 42 | EndGlobal 43 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Mandadin 4 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
Loading...
36 | 37 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mandadin 4 2 | 3 | > ***DISCLAIMER***: This was written a last year, my actual thoughts on wasm have changed slightly but I will keep this README as is just to capture my feelings at the moment, I'm likely to re-write this at some point 4 | 5 | The 4th version because how else I'm I ever going to learn languages and stacks... 6 | 7 | 8 | Mandadi 4 is a simple note-taking/grocery shopping app with a lot of UX and UI issues most of the time I write a new version of this project is to test a tech stack or to practice a particular skill. some of the particular features this Mandadin has are 9 | 10 | - Webassembly (Written in F#) 11 | - Web Share API 12 | - Clipboard API 13 | - Javascript Interop 14 | - PWA 15 | - Offline 16 | - MVU architecture 17 | - Component as pages 18 | - "Reusable" components (Modals) 19 | - Hosted on Firebase 20 | 21 | So... yup! it is a simple concept with some complex things under the hood 22 | 23 | if you are feeling curious, go to check it out! https://mandadin-4.web.app/ it's a PWA so if you visit on your Phone it will likely will prompt you to add it to your home screen 24 | 25 | 26 | # Review 27 | So that's cool but why would I use Bolero in the first place? 28 | 29 | There are some reasons you may want to do that, the first one is Correctness the joy of using F# will give you correct programs most of the time, from the 4 versions I've done of this this is the first one I haven't spent more time fixing bugs than working on the implementation. 30 | 31 | Dev time, according to my wakatime stats I spent about 22hrs to get the MVP out during 7 days given the fact that this is the 4th time I write this and I already know what I need to know about the app, it is still fastest one I've ever done 32 | ![wakatime-stats](wakatime-stats.png) 33 | 34 | it feels really good working with F# and Bolero I don't have any complains at all when it comes to Bolero 35 | 36 | ## why wouldn't I want to work with Bolero then? 37 | I don't think you don't want to work with Bolero, it's more about webassembly itself even if its Rust, C++, C, C# you name the flavor. 38 | As you can see within [wwwroot\js](https://github.com/AngelMunoz/Mandadin/tree/master/src/Mandadin.Client/wwwroot/js) there is javascript, so one of the advantages of F# is that you don't have to deal with the extreme flexibility of javascript which can be a good and a bad thing, the interop bits will be the main source of bugs you will have and if you are into a more serious project that must interop with JS libraries chances are that your js folder will grow bigger and bigger which will require at some point that you actually either bundle/minimize/uglify your code and if you are already defining a toolchain for that do you even want to start with webassembly at all? you should be better using a JS/TS project from the beginning and benefit from its wonderful tooling. 39 | 40 | As I said, I don't have complains about Bolero, it's more about webassembly in general, but if you can afford to go 100% vanilla F# (no js dependencies) be my guest and enjoy the benefits of using Bolero and F# 41 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {PouchDB.Database<{ theme: string }>} 3 | */ 4 | const themedb = new PouchDB("theme"); 5 | 6 | /** 7 | * 8 | * @param {string} theme 9 | * @returns {Promise} 10 | */ 11 | export function SaveTheme(theme) { 12 | return themedb 13 | .get("theme") 14 | .then((doc) => themedb.put({ ...doc, theme })) 15 | .then(({ ok }) => ok) 16 | .catch(({ status, ...error }) => { 17 | if (status === 404) { 18 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) { 19 | return themedb 20 | .put({ _id: "theme", theme: "Dark" }) 21 | .then(({ ok }) => ok); 22 | } 23 | if (window.matchMedia("(prefers-color-scheme: light)").matches) { 24 | return themedb 25 | .put({ _id: "theme", theme: "Light" }) 26 | .then(({ ok }) => ok); 27 | } 28 | return themedb.put({ _id: "theme", theme }).then(({ ok }) => ok); 29 | } 30 | return false; 31 | }); 32 | } 33 | 34 | function getCurrentTheme() { 35 | const html = document.querySelector("html"); 36 | if (html.classList.contains("dark")) { 37 | return "Dark"; 38 | } 39 | return "Light"; 40 | } 41 | 42 | /** 43 | * @returns {Promise} 44 | */ 45 | export function GetTheme() { 46 | const currentTheme = getCurrentTheme(); 47 | return themedb 48 | .get("theme") 49 | .then(({ theme }) => { 50 | if (theme !== currentTheme) { 51 | return SwitchTheme(theme).then((didSwitch) => 52 | didSwitch ? theme : currentTheme 53 | ); 54 | } 55 | return theme; 56 | }) 57 | .catch((err) => { 58 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) { 59 | return themedb 60 | .put({ _id: "theme", theme: "Dark" }) 61 | .then(({ ok }) => "Dark"); 62 | } 63 | if (window.matchMedia("(prefers-color-scheme: light)").matches) { 64 | return themedb 65 | .put({ _id: "theme", theme: "Light" }) 66 | .then(({ ok }) => "Light"); 67 | } 68 | return "Dark"; 69 | }); 70 | } 71 | 72 | /** 73 | * interacts with the HTML Element to switch classes 74 | * @param {Theme} theme 75 | * @return {Promise} 76 | */ 77 | export function SwitchTheme(theme) { 78 | const html = document.querySelector("html"); 79 | switch (theme) { 80 | case "Dark": 81 | if (html.classList.contains("dark")) { 82 | return Promise.resolve(false); 83 | } 84 | html.classList.add("dark"); 85 | return SaveTheme(theme); 86 | 87 | case "Light": 88 | if (!html.classList.contains("dark")) { 89 | return Promise.resolve(false); 90 | } 91 | html.classList.remove("dark"); 92 | return SaveTheme(theme); 93 | } 94 | } 95 | 96 | window 97 | .matchMedia("(prefers-color-scheme: dark)") 98 | .addEventListener("change", (event) => { 99 | if (event.matches) { 100 | SwitchTheme("Dark"); 101 | } else { 102 | SwitchTheme("Light"); 103 | } 104 | }); 105 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/css/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fallback-title-bar-height: 40px; 3 | } 4 | 5 | html, 6 | body { 7 | padding: 0; 8 | margin: 0; 9 | background-color: var(--main-background, #41403e); 10 | } 11 | 12 | nav .bar1, 13 | nav .bar2, 14 | nav .bar3 { 15 | height: 2px; 16 | } 17 | 18 | .cursor.pointer { 19 | cursor: pointer; 20 | } 21 | 22 | article.mandadin { 23 | display: flex; 24 | flex-direction: column; 25 | height: 100vh; 26 | overflow-y: auto; 27 | } 28 | 29 | header.mandadin-title-bar { 30 | app-region: drag; 31 | /* Pre-fix app-region during standardization process */ 32 | -webkit-app-region: drag; 33 | height: calc( 34 | env(titlebar-area-height, var(--fallback-title-bar-height)) + 2px 35 | ); 36 | display: flex; 37 | justify-content: center; 38 | position: sticky; 39 | top: 0; 40 | } 41 | 42 | .mandadin-title-bar .no-drag { 43 | app-region: no-drag; 44 | /* Pre-fix app-region during standardization process */ 45 | -webkit-app-region: no-drag; 46 | margin: auto; 47 | } 48 | 49 | nav.mandadin-navbar { 50 | flex: 0 1; 51 | } 52 | 53 | .mandadin-main { 54 | margin: 1em auto; 55 | flex: 1 0; 56 | } 57 | 58 | .mandadin-footer { 59 | margin: auto 0 0 0; 60 | flex: 0 1; 61 | } 62 | 63 | .notes-form { 64 | position: -webkit-sticky; 65 | position: sticky; 66 | top: 0; 67 | } 68 | 69 | .notes-list { 70 | display: flex; 71 | flex-wrap: wrap; 72 | align-content: center; 73 | align-items: center; 74 | overflow-y: auto; 75 | } 76 | .note-list-item { 77 | display: flex; 78 | flex-direction: column; 79 | align-content: space-evenly; 80 | align-items: stretch; 81 | } 82 | 83 | .tracklist-list { 84 | display: flex; 85 | flex-wrap: wrap; 86 | padding: 0; 87 | margin: 0.5em 0; 88 | overflow-y: auto; 89 | } 90 | 91 | .tracklist-item { 92 | display: flex; 93 | flex-direction: row; 94 | align-content: space-evenly; 95 | align-items: stretch; 96 | padding: 1em; 97 | } 98 | 99 | .listitem-item { 100 | display: flex; 101 | align-items: center; 102 | justify-content: space-around; 103 | margin: 1em; 104 | } 105 | 106 | @media all and (max-width: 525px) { 107 | .tracklist-item, 108 | .listitem-item { 109 | width: 100%; 110 | margin: 0.5em 0; 111 | padding: 0; 112 | } 113 | } 114 | 115 | ul.notes-list li::before, 116 | .tracklist-list li::before { 117 | content: none; 118 | } 119 | 120 | .listitem-item-checkbox { 121 | padding: 0; 122 | margin-right: 0.5em; 123 | } 124 | 125 | .m-0 { 126 | margin: 0; 127 | } 128 | 129 | .m-1 { 130 | margin: 1em; 131 | } 132 | .m-05 { 133 | margin: 0.5em; 134 | } 135 | 136 | .my-1 { 137 | margin-top: 1em; 138 | margin-bottom: 1em; 139 | } 140 | 141 | .mx-1 { 142 | margin-left: 1em; 143 | margin-right: 1em; 144 | } 145 | 146 | .modal { 147 | overflow-y: auto; 148 | } 149 | 150 | /* Small devices (landscape phones, 576px and up) */ 151 | @media (max-width: 576px) { 152 | .modal-state:checked + .modal .modal-body { 153 | margin-top: 12vh; 154 | } 155 | .hidden-on-mobile { 156 | display: none; 157 | } 158 | } 159 | 160 | /* Medium devices (tablets, 768px and up) */ 161 | @media (max-width: 768px) { 162 | .hidden-on-mobile { 163 | display: initial; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/service-worker.published.js: -------------------------------------------------------------------------------- 1 | // Caution! Be sure you understand the caveats before publishing an application with 2 | // offline support. See https://aka.ms/blazor-offline-considerations 3 | 4 | self.importScripts('./service-worker-assets.js'); 5 | self.addEventListener('install', event => event.waitUntil(onInstall(event))); 6 | self.addEventListener('activate', event => event.waitUntil(onActivate(event))); 7 | self.addEventListener('fetch', event => event.respondWith(onFetch(event))); 8 | 9 | var importData; 10 | const shareChannel = new BroadcastChannel("share-target"); 11 | 12 | const cacheNamePrefix = 'offline-cache-'; 13 | const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; 14 | const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/]; 15 | const offlineAssetsExclude = [/^service-worker\.js$/]; 16 | 17 | shareChannel.addEventListener("message", function (event) { 18 | if (event.data && event.data.type === 'GET_IMPORT_DATA' && importData) { 19 | shareChannel.postMessage({ 20 | type: "SEND_IMPORT_DATA", 21 | data: { ...importData } 22 | }); 23 | } 24 | if (event.data && event.data.type === 'REMOVE_IMPORT_DATA' && importData) { 25 | importData = null; 26 | } 27 | }); 28 | 29 | async function onInstall(event) { 30 | console.info('Service worker: Install'); 31 | 32 | // Fetch and cache all matching items from the assets manifest 33 | const assetsRequests = self.assetsManifest.assets 34 | .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) 35 | .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) 36 | .map(asset => new Request(asset.url, { integrity: asset.hash })); 37 | await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); 38 | } 39 | 40 | async function onActivate(event) { 41 | console.info('Service worker: Activate'); 42 | 43 | // Delete unused caches 44 | const cacheKeys = await caches.keys(); 45 | await Promise.all(cacheKeys 46 | .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) 47 | .map(key => caches.delete(key))); 48 | } 49 | 50 | async function onFetch(event) { 51 | let cachedResponse = null; 52 | const url = new URL(event.request.url); 53 | 54 | if (event.request.method === 'GET') { 55 | // For all navigation requests, try to serve index.html from cache 56 | // If you need some URLs to be server-rendered, edit the following check to exclude those URLs 57 | const shouldServeIndexHtml = event.request.mode === 'navigate'; 58 | 59 | const request = shouldServeIndexHtml ? 'index.html' : event.request; 60 | const cache = await caches.open(cacheName); 61 | cachedResponse = await cache.match(request); 62 | } 63 | 64 | if (event.request.method === 'POST' && 65 | url.pathname === '/import') { 66 | try { 67 | const formData = await event.request.formData(); 68 | const title = formData.get('title') || ''; 69 | const text = formData.get('text') || ''; 70 | const url = formData.get('url') || ''; 71 | importData = { title, text, url }; 72 | return Response.redirect("/import", 303); 73 | } catch (e) { 74 | console.error(e); 75 | } 76 | } 77 | 78 | return cachedResponse || fetch(event.request); 79 | } 80 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Types.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open Bolero 4 | open System.Threading.Tasks 5 | 6 | open FsToolkit.ErrorHandling 7 | 8 | [] 9 | type Page = 10 | | [] Lists 11 | | [] Notes 12 | | [] ListDetail of listId: string 13 | | [] Import 14 | 15 | [] 16 | type Theme = 17 | | Light 18 | | Dark 19 | 20 | static member ofString(theme: string) = 21 | match theme with 22 | | "Light" -> Light 23 | | _ -> Dark 24 | 25 | member this.AsString = 26 | match this with 27 | | Light -> "Light" 28 | | Dark -> "Dark" 29 | 30 | member this.AsDisplay = 31 | match this with 32 | | Light -> "Claro" 33 | | Dark -> "Oscuro" 34 | 35 | 36 | [] 37 | type SaveResult = { Id: string; Ok: bool; Rev: string } 38 | 39 | [] 40 | type Note = 41 | { Id: string 42 | Content: string 43 | Rev: string } 44 | 45 | [] 46 | type TrackList = { Id: string; Rev: string } 47 | 48 | [] 49 | type TrackListItem = 50 | { Id: string 51 | IsDone: bool 52 | ListId: string 53 | Name: string 54 | Rev: string } 55 | 56 | [] 57 | type Icon = 58 | | Copy 59 | | Share 60 | | Trash 61 | | Clipboard 62 | | Save 63 | | Text 64 | | Import 65 | | Close 66 | | Check 67 | | Back 68 | 69 | [] 70 | type TaskListItemError = 71 | | EmtptyString 72 | | ExistingItem of name: string 73 | | CreationFailed of error: exn 74 | 75 | type ITrackListItemService = 76 | 77 | abstract GetHideDone: listId: string -> ValueTask 78 | abstract SetHideDone: listId: string * hideDone: bool -> ValueTask 79 | 80 | abstract GetItems: 81 | listId: string * ?hideDone: bool -> ValueTask 82 | 83 | abstract ItemExists: listId: string * name: string -> ValueTask 84 | 85 | abstract CreateItem: 86 | listId: string * name: string -> 87 | TaskResult 88 | 89 | abstract UpdateItem: item: TrackListItem -> ValueTask 90 | abstract DeleteItem: item: TrackListItem -> ValueTask 91 | 92 | type INoteService = 93 | abstract GetNotes: unit -> ValueTask> 94 | abstract CreateNote: content: string -> ValueTask> 95 | 96 | abstract UpdateNote: 97 | content: string * note: Note -> ValueTask> 98 | 99 | abstract DeleteNote: note: Note -> ValueTask 100 | 101 | type IShareService = 102 | abstract ShareTracklistItem: listId: string * content: string -> ValueTask 103 | abstract ShareNote: content: string -> ValueTask 104 | abstract ToClipboard: content: string -> ValueTask 105 | abstract FromClipboard: unit -> ValueTask 106 | 107 | 108 | [] 109 | module Icon = 110 | type Copy = Template<"wwwroot/icons/copy.html"> 111 | type Share = Template<"wwwroot/icons/share.html"> 112 | type Trash = Template<"wwwroot/icons/trash.html"> 113 | type Clipboard = Template<"wwwroot/icons/clipboard.html"> 114 | type Save = Template<"wwwroot/icons/save.html"> 115 | type Text = Template<"wwwroot/icons/text.html"> 116 | type Import = Template<"wwwroot/icons/import.html"> 117 | type Close = Template<"wwwroot/icons/close.html"> 118 | type Check = Template<"wwwroot/icons/check.html"> 119 | type Back = Template<"wwwroot/icons/back.html"> 120 | 121 | type Icon with 122 | 123 | static member Get(icon: Icon, ?color: string) = 124 | let color = defaultArg color "currentColor" 125 | 126 | match icon with 127 | | Copy -> Icon.Copy().Fill(color).Elt() 128 | | Share -> Icon.Share().Fill(color).Elt() 129 | | Trash -> Icon.Trash().Fill(color).Elt() 130 | | Clipboard -> Icon.Clipboard().Fill(color).Elt() 131 | | Save -> Icon.Save().Fill(color).Elt() 132 | | Text -> Icon.Text().Fill(color).Elt() 133 | | Import -> Icon.Import().Fill(color).Elt() 134 | | Close -> Icon.Close().Fill(color).Elt() 135 | | Check -> Icon.Check().Fill(color).Elt() 136 | | Back -> Icon.Back().Fill(color).Elt() 137 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Components/TrackListItems.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Components.TrackListItems 2 | 3 | open System 4 | open System.Threading.Tasks 5 | open Microsoft.AspNetCore.Components 6 | 7 | open IcedTasks 8 | 9 | open Bolero 10 | open Bolero.Html 11 | open Mandadin.Client 12 | 13 | 14 | type NewItemForm() = 15 | inherit Component() 16 | let mutable objectName = "" 17 | 18 | [] 19 | member val HideDone: bool = false with get, set 20 | 21 | [] 22 | member val OnSubmit: string -> Task = 23 | fun _ -> Task.CompletedTask with get, set 24 | 25 | [] 26 | member val OnHideDoneChange: bool -> unit = ignore with get, set 27 | 28 | override self.Render() = 29 | form { 30 | attr.``class`` "row flex-spaces background-muted border notes-form" 31 | 32 | on.task.submit (fun _ -> 33 | taskUnit { 34 | do! self.OnSubmit objectName 35 | objectName <- "" 36 | }) 37 | 38 | fieldset { 39 | attr.``class`` "form-group" 40 | 41 | label { 42 | attr.``for`` "current-content" 43 | text "Nombre del objeto..." 44 | } 45 | 46 | textarea { 47 | attr.id "current-content" 48 | attr.placeholder objectName 49 | 50 | bind.input.string objectName (fun name -> objectName <- name) 51 | } 52 | 53 | label { 54 | attr.``for`` "paperCheck1" 55 | attr.``class`` "paper-check" 56 | 57 | input { 58 | attr.id "paperCheck1" 59 | attr.name "paperChecks" 60 | attr.``type`` "checkbox" 61 | 62 | bind.``checked`` self.HideDone (fun hideDone -> 63 | self.OnHideDoneChange hideDone) 64 | } 65 | 66 | span { text "Esconder Terminados" } 67 | } 68 | } 69 | 70 | button { 71 | attr.``type`` "submit" 72 | attr.``class`` "paper-btn btn-small" 73 | attr.disabled (String.IsNullOrWhiteSpace objectName) 74 | Icon.Get Save 75 | } 76 | } 77 | 78 | 79 | [] 80 | module TrackListItems = 81 | 82 | let Stringify (items: list) : string = 83 | let isDoneToX (isDone: bool) = if isDone then 'x' else ' ' 84 | 85 | let stringified = 86 | items 87 | |> List.map (fun item -> 88 | sprintf "[ %c ] %s" (isDoneToX item.IsDone) item.Name) 89 | 90 | System.String.Join('\n', stringified) 91 | 92 | type ToolbarState<'State 93 | when 'State: (member TrackListId: string) 94 | and 'State: (member CanShare: bool) 95 | and 'State: (member Items: list)> = 'State 96 | 97 | 98 | type TrackListComponents = 99 | 100 | static member inline Toolbar<'State when ToolbarState<'State>> 101 | ( 102 | state: 'State, 103 | share: IShareService, 104 | ?onBackRequested: unit -> unit 105 | ) = 106 | let onBackRequested = 107 | defaultArg onBackRequested ignore 108 | 109 | div { 110 | attr.``class`` "border" 111 | 112 | section { 113 | attr.``class`` "row flex-center" 114 | h4 { text state.TrackListId } 115 | } 116 | 117 | section { 118 | attr.``class`` "row flex-center" 119 | 120 | button { 121 | attr.``class`` "paper-btn btn-small" 122 | on.click (fun _ -> onBackRequested ()) 123 | Icon.Get Back 124 | } 125 | 126 | cond state.CanShare 127 | <| function 128 | | true -> 129 | button { 130 | attr.``class`` "paper-btn btn-small" 131 | 132 | on.click (fun _ -> 133 | let content = 134 | state.Items |> TrackListItems.Stringify 135 | 136 | share.ShareTracklistItem(state.TrackListId, content) 137 | |> ignore) 138 | 139 | Icon.Get Share 140 | } 141 | | false -> empty () 142 | 143 | button { 144 | attr.``class`` "paper-btn btn-small" 145 | 146 | on.click (fun _ -> 147 | let content = 148 | state.Items |> TrackListItems.Stringify 149 | 150 | share.ToClipboard content |> ignore) 151 | 152 | Icon.Get Copy 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Views/Import.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Views 2 | 3 | open System 4 | open Microsoft.Extensions.Logging 5 | open Microsoft.AspNetCore.Components 6 | open Microsoft.JSInterop 7 | 8 | open Elmish 9 | 10 | open Bolero 11 | open Bolero.Html 12 | open Bolero.Remoting.Client 13 | 14 | open Mandadin.Client 15 | 16 | [] 17 | module Import = 18 | 19 | let inline parseContentString (content: string) = 20 | content.Split(Environment.NewLine) 21 | |> Parse.entries 22 | 23 | [] 24 | type ShareDataPayload = 25 | { Text: string 26 | Title: string 27 | Url: string } 28 | 29 | [] 30 | type State = 31 | { ShareData: ValueOption } 32 | 33 | 34 | type Msg = 35 | | RequestImportData 36 | | RequestImportDataSuccess of ShareDataPayload 37 | | ImportResult of Result 38 | | CreateFromImport of title: string * items: obj array array 39 | | CreateListSuccess of TrackList 40 | | Error of exn 41 | 42 | 43 | let private init (_: 'arg) = 44 | { ShareData = ValueNone }, Cmd.ofMsg RequestImportData 45 | 46 | 47 | let private update 48 | (msg: Msg) 49 | (state: State) 50 | (goToList: string -> unit) 51 | (js: IJSRuntime) 52 | (logger: ILogger) 53 | = 54 | match msg with 55 | | RequestImportData -> 56 | state, 57 | Cmd.OfJS.either 58 | js 59 | "Mandadin.Share.ImportShareData" 60 | [||] 61 | RequestImportDataSuccess 62 | Error 63 | | RequestImportDataSuccess data -> 64 | let share = ValueSome data 65 | { state with ShareData = share }, Cmd.none 66 | | ImportResult result -> 67 | match result with 68 | | Ok(title, content) -> 69 | match parseContentString content with 70 | | Ok items -> state, Cmd.ofMsg (CreateFromImport(title, items)) 71 | | Result.Error errs -> 72 | errs 73 | |> List.iter (fun (line, err) -> 74 | logger.LogDebug( 75 | "Failed at {index} with: {error} for '{line}'", 76 | err.idx, 77 | err.message, 78 | line 79 | )) 80 | 81 | state, Cmd.none 82 | | Result.Error() -> { state with ShareData = ValueNone }, Cmd.none 83 | | CreateFromImport(title, items) -> 84 | state, 85 | Cmd.OfJS.either 86 | js 87 | "Mandadin.Database.ImportList" 88 | [| title; items |] 89 | CreateListSuccess 90 | Error 91 | | CreateListSuccess trackList -> 92 | goToList trackList.Id 93 | state, Cmd.none 94 | | Error err -> 95 | logger.LogDebug("Error: {error}", err) 96 | { state with ShareData = ValueNone }, Cmd.none 97 | 98 | let view (state: State) (dispatch: Dispatch) = 99 | 100 | article { 101 | a { 102 | attr.href "/" 103 | Icon.Get Back 104 | } 105 | 106 | cond state.ShareData 107 | <| function 108 | | ValueNone -> 109 | p { 110 | text 111 | "No pudimos obtener informacion de lo que nos querias compartir 😢" 112 | } 113 | | _ -> empty () 114 | 115 | cond state.ShareData 116 | <| function 117 | | ValueSome data -> 118 | let prefill: Modals.Import.ImportData = 119 | { title = data.Title 120 | content = data.Text } 121 | 122 | comp { 123 | "ImportData" => prefill 124 | 125 | "OnDismiss" 126 | => fun _ -> dispatch (ImportResult(Result.Error())) 127 | 128 | "OnImport" 129 | => fun (data: Modals.Import.ImportData) -> 130 | dispatch (ImportResult(Ok(data.title, data.content))) 131 | } 132 | | ValueNone -> empty () 133 | } 134 | 135 | 136 | type Page() = 137 | inherit ProgramComponent() 138 | 139 | [] 140 | member val LoggerFactory = Unchecked.defaultof with get, set 141 | 142 | [] 143 | member val OnGoToListRequested: string -> unit = ignore with get, set 144 | 145 | override self.Program = 146 | let update msg state = 147 | let logger = 148 | self.LoggerFactory.CreateLogger("Import Page") 149 | 150 | update msg state self.OnGoToListRequested self.JSRuntime logger 151 | 152 | 153 | Program.mkProgram init update view 154 | #if DEBUG 155 | |> Program.withConsoleTrace 156 | #endif 157 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Modals.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open Bolero.Html 4 | open Microsoft.AspNetCore.Components 5 | 6 | [] 7 | module Modals = 8 | open Bolero 9 | 10 | module Import = 11 | open Elmish 12 | 13 | [] 14 | type ImportData = { title: string; content: string } 15 | 16 | [] 17 | type Msg = 18 | | SetTitle of title: string 19 | | SetContent of content: string 20 | | Import 21 | | Dismiss 22 | 23 | type ImportTrackList() as this = 24 | inherit ProgramComponent() 25 | 26 | let update msg importdata = 27 | match msg with 28 | | SetTitle title -> { importdata with title = title } 29 | | SetContent content -> { importdata with content = content } 30 | | Import -> 31 | this.OnImport importdata 32 | importdata 33 | | Dismiss -> 34 | this.OnDismiss() 35 | importdata 36 | 37 | let view (importData) dispatch = 38 | section { 39 | input { 40 | attr.``class`` "modal-state" 41 | attr.id "modal-1" 42 | attr.``type`` "checkbox" 43 | attr.``checked`` true 44 | } 45 | 46 | div { 47 | attr.``class`` "modal" 48 | 49 | label { attr.``class`` "modal-bg" } 50 | 51 | div { 52 | attr.``class`` "modal-body" 53 | 54 | label { 55 | attr.``class`` "btn-close" 56 | on.click (fun _ -> dispatch Dismiss) 57 | Icon.Get Close 58 | } 59 | 60 | h4 { 61 | attr.``class`` "modal-title" 62 | text "Titulo de la nueva lista" 63 | } 64 | 65 | input { 66 | bind.input.string importData.title (fun txt -> 67 | dispatch (SetTitle txt)) 68 | } 69 | 70 | h5 { 71 | attr.``class`` "modal-subtitle" 72 | text """Pega el texto proveniente de otros "Mandadin" abajo""" 73 | br 74 | 75 | text 76 | "NOTA: SI EL NOMBRE ES IGUAL A UNO EXISTENTE SE BORRARA EL CONTENIDO ANTERIOR" 77 | } 78 | 79 | textarea { 80 | attr.``class`` "modal-text" 81 | attr.rows 8 82 | attr.cols 32 83 | 84 | bind.input.string importData.content (fun txt -> 85 | dispatch (SetContent txt)) 86 | } 87 | 88 | button { 89 | attr.``class`` "paper-btn btn-info-outline" 90 | 91 | on.click (fun _ -> dispatch Import) 92 | 93 | Icon.Get Check 94 | text "Importar contenido" 95 | } 96 | 97 | button { 98 | attr.``class`` "paper-btn btn-danger-outline" 99 | on.click (fun _ -> dispatch Dismiss) 100 | Icon.Get Trash 101 | text "Cancelar" 102 | } 103 | } 104 | } 105 | } 106 | 107 | 108 | [] 109 | member val IsOpen = false with get, set 110 | 111 | [] 112 | member val ImportData = { title = ""; content = "" } with get, set 113 | 114 | [] 115 | member val OnDismiss: (unit -> unit) = id with get, set 116 | 117 | [] 118 | member val OnImport: (ImportData -> unit) = ignore with get, set 119 | 120 | override this.Program = 121 | Program.mkSimple (fun _ -> this.ImportData) update view 122 | 123 | 124 | let DeleteResourceModal 125 | (content: string * string * string) 126 | (action: Result -> unit) 127 | = 128 | let (title, subtitle, message) = content 129 | 130 | section { 131 | input { 132 | attr.``class`` "modal-state" 133 | attr.id "modal-1" 134 | attr.``type`` "checkbox" 135 | attr.``checked`` true 136 | } 137 | 138 | div { 139 | attr.``class`` "modal" 140 | label { attr.``class`` "modal-bg" } 141 | 142 | div { 143 | attr.``class`` "modal-body" 144 | 145 | label { 146 | attr.``class`` "btn-close" 147 | on.click (fun _ -> Error() |> action) 148 | Icon.Get Close 149 | } 150 | 151 | h4 { 152 | attr.``class`` "modal-title" 153 | text title 154 | } 155 | 156 | h5 { 157 | attr.``class`` "modal-subtitle" 158 | text subtitle 159 | } 160 | 161 | p { 162 | attr.``class`` "modal-text" 163 | text message 164 | } 165 | 166 | button { 167 | attr.``class`` "paper-btn btn-danger-outline" 168 | on.click (fun _ -> Ok true |> action) 169 | Icon.Get Check 170 | text "Si, Continuar" 171 | } 172 | 173 | button { 174 | attr.``class`` "paper-btn btn-success-outline" 175 | on.click (fun _ -> Ok false |> action) 176 | Icon.Get Trash 177 | text "Cancelar" 178 | } 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Views/Notes.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Views.Notes 2 | 3 | open System 4 | 5 | open Microsoft.JSInterop 6 | open Microsoft.AspNetCore.Components 7 | 8 | open IcedTasks 9 | 10 | open Elmish 11 | open Bolero 12 | open Bolero.Html 13 | open Bolero.Remoting.Client 14 | 15 | open Mandadin.Client 16 | 17 | type NewNoteForm() = 18 | inherit Component() 19 | 20 | let mutable noteContent: string = "" 21 | 22 | [] 23 | member val OnNewNote: (Note -> unit) = ignore with get, set 24 | 25 | [] 26 | member val NoteService: INoteService = 27 | Unchecked.defaultof with get, set 28 | 29 | [] 30 | member val Share: IShareService = 31 | Unchecked.defaultof with get, set 32 | 33 | override self.Render() : Node = 34 | form { 35 | attr.``class`` "row flex-spaces background-muted border notes-form" 36 | 37 | on.task.submit (fun _ -> 38 | taskUnit { 39 | let! created = self.NoteService.CreateNote noteContent 40 | 41 | match created with 42 | | ValueSome note -> 43 | self.OnNewNote note 44 | noteContent <- "" 45 | | ValueNone -> () 46 | }) 47 | 48 | fieldset { 49 | attr.``class`` "form-group" 50 | 51 | label { 52 | attr.``for`` "current-content" 53 | text "Escribe algo..." 54 | } 55 | 56 | textarea { 57 | attr.id "current-content" 58 | attr.placeholder "Escribe algo..." 59 | bind.input.string noteContent (fun text -> noteContent <- text) 60 | } 61 | } 62 | 63 | button { 64 | attr.``type`` "submit" 65 | attr.disabled (String.IsNullOrWhiteSpace noteContent) 66 | text "Guardar" 67 | } 68 | 69 | button { 70 | attr.``type`` "button" 71 | 72 | on.task.click (fun _ -> 73 | taskUnit { 74 | let! content = self.Share.FromClipboard() 75 | noteContent <- content 76 | }) 77 | 78 | Icon.Get Clipboard 79 | } 80 | } 81 | 82 | 83 | type NoteItem() = 84 | inherit Component() 85 | 86 | let textAreaRef = HtmlRef() 87 | 88 | member private self.onTextAreaBlur _ = 89 | taskUnit { 90 | match textAreaRef.Value with 91 | | Some ref -> 92 | let! content = 93 | self.jsRuntime.InvokeAsync("Mandadin.Elements.GetValue", ref) 94 | 95 | if 96 | content <> self.Note.Content 97 | && not (String.IsNullOrWhiteSpace content) 98 | then 99 | self.OnNoteChanged(content, self.Note) 100 | | None -> () 101 | 102 | return () 103 | } 104 | 105 | [] 106 | member val OnNoteChanged: (string * Note -> unit) = ignore with get, set 107 | 108 | [] 109 | member val OnNoteDeleted: (Note -> unit) = ignore with get, set 110 | 111 | [] 112 | member val Note: Note = Unchecked.defaultof with get, set 113 | 114 | [] 115 | member val CanShare: bool = false with get, set 116 | 117 | [] 118 | member val NoteService: INoteService = 119 | Unchecked.defaultof with get, set 120 | 121 | [] 122 | member val Share: IShareService = 123 | Unchecked.defaultof with get, set 124 | 125 | [] 126 | member val jsRuntime: IJSRuntime = 127 | Unchecked.defaultof with get, set 128 | 129 | override self.Render() : Node = 130 | 131 | li { 132 | attr.``class`` "note-list-item m-05" 133 | 134 | textarea { 135 | attr.value self.Note.Content 136 | 137 | on.task.blur self.onTextAreaBlur 138 | 139 | textAreaRef 140 | } 141 | 142 | section { 143 | attr.``class`` "row" 144 | 145 | button { 146 | attr.``class`` "paper-btn btn-small btn-muted-outline" 147 | 148 | on.task.click (fun _ -> 149 | taskUnit { do! self.Share.ToClipboard self.Note.Content }) 150 | 151 | Icon.Get Copy 152 | } 153 | 154 | cond self.CanShare 155 | <| function 156 | | false -> empty () 157 | | true -> 158 | button { 159 | attr.``class`` "paper-btn btn-small btn-muted-outline" 160 | 161 | on.task.click (fun _ -> 162 | taskUnit { do! self.Share.ShareNote self.Note.Content }) 163 | 164 | Icon.Get Share 165 | } 166 | 167 | button { 168 | attr.``class`` "paper-btn btn-small btn-danger-outline" 169 | on.click (fun _ -> self.OnNoteDeleted self.Note) 170 | Icon.Get Trash 171 | } 172 | } 173 | } 174 | 175 | 176 | type Page() = 177 | inherit Component() 178 | let mutable notes: list = list.Empty 179 | let mutable isSaving = false 180 | 181 | member self.Notes 182 | with get () = notes 183 | and set v = 184 | notes <- v 185 | self.StateHasChanged() 186 | 187 | [] 188 | member val CanShare: bool = false with get, set 189 | 190 | [] 191 | member val NoteService: INoteService = 192 | Unchecked.defaultof with get, set 193 | 194 | member private self.onDeleteNote note = 195 | valueTaskUnit { 196 | do! self.NoteService.DeleteNote note 197 | 198 | self.Notes <- 199 | self.Notes 200 | |> List.filter (fun n -> n.Id <> note.Id) 201 | } 202 | |> ignore 203 | 204 | member private self.onNoteChanged(content, note) = 205 | valueTaskUnit { 206 | if isSaving then 207 | return () 208 | 209 | isSaving <- true 210 | let! note = self.NoteService.UpdateNote(content, note) 211 | 212 | match note with 213 | | ValueSome note -> 214 | self.Notes <- 215 | self.Notes 216 | |> List.map (fun n -> if n.Id = note.Id then note else n) 217 | | ValueNone -> () 218 | 219 | isSaving <- false 220 | } 221 | |> ignore 222 | 223 | override self.OnInitializedAsync() = 224 | taskUnit { 225 | let! foundNotes = self.NoteService.GetNotes() 226 | self.Notes <- foundNotes 227 | } 228 | 229 | override self.Render() : Node = 230 | article { 231 | comp { 232 | "OnNewNote" 233 | => (fun note -> self.Notes <- note :: self.Notes) 234 | } 235 | 236 | ul { 237 | attr.``class`` "notes-list" 238 | 239 | virtualize.comp { 240 | virtualize.placeholder (fun _ -> div { text "Cargando..." }) 241 | let! item = virtualize.items self.Notes 242 | 243 | comp { 244 | "Note" => item 245 | "CanShare" => true 246 | "OnNoteChanged" => self.onNoteChanged 247 | 248 | "OnNoteDeleted" => self.onDeleteNote 249 | } 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Coverlet is a free, cross platform Code Coverage Tool 141 | coverage*[.json, .xml, .info] 142 | 143 | # Visual Studio code coverage results 144 | *.coverage 145 | *.coveragexml 146 | 147 | # NCrunch 148 | _NCrunch_* 149 | .*crunch*.local.xml 150 | nCrunchTemp_* 151 | 152 | # MightyMoose 153 | *.mm.* 154 | AutoTest.Net/ 155 | 156 | # Web workbench (sass) 157 | .sass-cache/ 158 | 159 | # Installshield output folder 160 | [Ee]xpress/ 161 | 162 | # DocProject is a documentation generator add-in 163 | DocProject/buildhelp/ 164 | DocProject/Help/*.HxT 165 | DocProject/Help/*.HxC 166 | DocProject/Help/*.hhc 167 | DocProject/Help/*.hhk 168 | DocProject/Help/*.hhp 169 | DocProject/Help/Html2 170 | DocProject/Help/html 171 | 172 | # Click-Once directory 173 | publish/ 174 | 175 | # Publish Web Output 176 | *.[Pp]ublish.xml 177 | *.azurePubxml 178 | # Note: Comment the next line if you want to checkin your web deploy settings, 179 | # but database connection strings (with potential passwords) will be unencrypted 180 | *.pubxml 181 | *.publishproj 182 | 183 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 184 | # checkin your Azure Web App publish settings, but sensitive information contained 185 | # in these scripts will be unencrypted 186 | PublishScripts/ 187 | 188 | # NuGet Packages 189 | *.nupkg 190 | # NuGet Symbol Packages 191 | *.snupkg 192 | # The packages folder can be ignored because of Package Restore 193 | **/[Pp]ackages/* 194 | # except build/, which is used as an MSBuild target. 195 | !**/[Pp]ackages/build/ 196 | # Uncomment if necessary however generally it will be regenerated when needed 197 | #!**/[Pp]ackages/repositories.config 198 | # NuGet v3's project.json files produces more ignorable files 199 | *.nuget.props 200 | *.nuget.targets 201 | 202 | # Microsoft Azure Build Output 203 | csx/ 204 | *.build.csdef 205 | 206 | # Microsoft Azure Emulator 207 | ecf/ 208 | rcf/ 209 | 210 | # Windows Store app package directories and files 211 | AppPackages/ 212 | BundleArtifacts/ 213 | Package.StoreAssociation.xml 214 | _pkginfo.txt 215 | *.appx 216 | *.appxbundle 217 | *.appxupload 218 | 219 | # Visual Studio cache files 220 | # files ending in .cache can be ignored 221 | *.[Cc]ache 222 | # but keep track of directories ending in .cache 223 | !?*.[Cc]ache/ 224 | 225 | # Others 226 | ClientBin/ 227 | ~$* 228 | *~ 229 | *.dbmdl 230 | *.dbproj.schemaview 231 | *.jfm 232 | *.pfx 233 | *.publishsettings 234 | orleans.codegen.cs 235 | 236 | # Including strong name files can present a security risk 237 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 238 | #*.snk 239 | 240 | # Since there are multiple workflows, uncomment next line to ignore bower_components 241 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 242 | #bower_components/ 243 | 244 | # RIA/Silverlight projects 245 | Generated_Code/ 246 | 247 | # Backup & report files from converting an old project file 248 | # to a newer Visual Studio version. Backup files are not needed, 249 | # because we have git ;-) 250 | _UpgradeReport_Files/ 251 | Backup*/ 252 | UpgradeLog*.XML 253 | UpgradeLog*.htm 254 | ServiceFabricBackup/ 255 | *.rptproj.bak 256 | 257 | # SQL Server files 258 | *.mdf 259 | *.ldf 260 | *.ndf 261 | 262 | # Business Intelligence projects 263 | *.rdl.data 264 | *.bim.layout 265 | *.bim_*.settings 266 | *.rptproj.rsuser 267 | *- [Bb]ackup.rdl 268 | *- [Bb]ackup ([0-9]).rdl 269 | *- [Bb]ackup ([0-9][0-9]).rdl 270 | 271 | # Microsoft Fakes 272 | FakesAssemblies/ 273 | 274 | # GhostDoc plugin setting file 275 | *.GhostDoc.xml 276 | 277 | # Node.js Tools for Visual Studio 278 | .ntvs_analysis.dat 279 | node_modules/ 280 | 281 | # Visual Studio 6 build log 282 | *.plg 283 | 284 | # Visual Studio 6 workspace options file 285 | *.opt 286 | 287 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 288 | *.vbw 289 | 290 | # Visual Studio LightSwitch build output 291 | **/*.HTMLClient/GeneratedArtifacts 292 | **/*.DesktopClient/GeneratedArtifacts 293 | **/*.DesktopClient/ModelManifest.xml 294 | **/*.Server/GeneratedArtifacts 295 | **/*.Server/ModelManifest.xml 296 | _Pvt_Extensions 297 | 298 | # Paket dependency manager 299 | .paket/paket.exe 300 | paket-files/ 301 | 302 | # FAKE - F# Make 303 | .fake/ 304 | 305 | # CodeRush personal settings 306 | .cr/personal 307 | 308 | # Python Tools for Visual Studio (PTVS) 309 | __pycache__/ 310 | *.pyc 311 | 312 | # Cake - Uncomment if you are using it 313 | # tools/** 314 | # !tools/packages.config 315 | 316 | # Tabs Studio 317 | *.tss 318 | 319 | # Telerik's JustMock configuration file 320 | *.jmconfig 321 | 322 | # BizTalk build output 323 | *.btp.cs 324 | *.btm.cs 325 | *.odx.cs 326 | *.xsd.cs 327 | 328 | # OpenCover UI analysis results 329 | OpenCover/ 330 | 331 | # Azure Stream Analytics local run output 332 | ASALocalRun/ 333 | 334 | # MSBuild Binary and Structured Log 335 | *.binlog 336 | 337 | # NVidia Nsight GPU debugger configuration file 338 | *.nvuser 339 | 340 | # MFractors (Xamarin productivity tool) working folder 341 | .mfractor/ 342 | 343 | # Local History for Visual Studio 344 | .localhistory/ 345 | 346 | # BeatPulse healthcheck temp database 347 | healthchecksdb 348 | 349 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 350 | MigrationBackup/ 351 | 352 | # Ionide (cross platform F# VS Code tools) working folder 353 | .ionide/ 354 | 355 | ## 356 | ## Visual studio for Mac 357 | ## 358 | 359 | 360 | # globs 361 | Makefile.in 362 | *.userprefs 363 | *.usertasks 364 | config.make 365 | config.status 366 | aclocal.m4 367 | install-sh 368 | autom4te.cache/ 369 | *.tar.gz 370 | tarballs/ 371 | test-results/ 372 | 373 | # Mac bundle stuff 374 | *.dmg 375 | *.app 376 | 377 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 378 | # General 379 | .DS_Store 380 | .AppleDouble 381 | .LSOverride 382 | 383 | # Icon must end with two \r 384 | Icon 385 | 386 | 387 | # Thumbnails 388 | ._* 389 | 390 | # Files that might appear in the root of a volume 391 | .DocumentRevisions-V100 392 | .fseventsd 393 | .Spotlight-V100 394 | .TemporaryItems 395 | .Trashes 396 | .VolumeIcon.icns 397 | .com.apple.timemachine.donotpresent 398 | 399 | # Directories potentially created on remote AFP share 400 | .AppleDB 401 | .AppleDesktop 402 | Network Trash Folder 403 | Temporary Items 404 | .apdisk 405 | 406 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 407 | # Windows thumbnail cache files 408 | Thumbs.db 409 | ehthumbs.db 410 | ehthumbs_vista.db 411 | 412 | # Dump file 413 | *.stackdump 414 | 415 | # Folder config file 416 | [Dd]esktop.ini 417 | 418 | # Recycle Bin used on file shares 419 | $RECYCLE.BIN/ 420 | 421 | # Windows Installer files 422 | *.cab 423 | *.msi 424 | *.msix 425 | *.msm 426 | *.msp 427 | 428 | # Windows shortcuts 429 | *.lnk 430 | 431 | # JetBrains Rider 432 | .idea/ 433 | *.sln.iml 434 | 435 | ## 436 | ## Visual Studio Code 437 | ## 438 | .vscode/* 439 | !.vscode/settings.json 440 | !.vscode/tasks.json 441 | !.vscode/launch.json 442 | !.vscode/extensions.json 443 | 444 | dist/ -------------------------------------------------------------------------------- /src/Mandadin.Client/Services.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Services 2 | 3 | open System 4 | 5 | open Microsoft.JSInterop 6 | open Microsoft.Extensions.Logging 7 | open Microsoft.Extensions.DependencyInjection 8 | 9 | open IcedTasks 10 | open FsToolkit.ErrorHandling 11 | 12 | open Mandadin.Client 13 | 14 | module Share = 15 | let inline factory (services: IServiceProvider) : IShareService = 16 | let jsRuntime = 17 | services.GetService() 18 | 19 | let loggerFactory = 20 | services.GetService() 21 | 22 | let logger = 23 | loggerFactory.CreateLogger() 24 | 25 | { new IShareService with 26 | member _.FromClipboard() = 27 | valueTask { 28 | logger.LogDebug("Getting content from clipboard...") 29 | 30 | try 31 | let! content = 32 | jsRuntime.InvokeAsync( 33 | "Mandadin.Clipboard.ReadTextFromClipboard", 34 | [||] 35 | ) 36 | 37 | return content 38 | with exn -> 39 | logger.LogError( 40 | "Failed to get content from clipboard: {exn}", 41 | exn 42 | ) 43 | 44 | return "" 45 | } 46 | 47 | member _.ShareTracklistItem 48 | ( 49 | listId: string, 50 | content: string 51 | ) : Threading.Tasks.ValueTask = 52 | valueTaskUnit { 53 | logger.LogDebug("Sharing list: {listId}", listId) 54 | logger.LogDebug("Content: '{content}'...", content.Substring(0, 20)) 55 | 56 | do! 57 | jsRuntime.InvokeVoidAsync( 58 | "Mandadin.Share.ShareContent", 59 | [| listId :> obj; content |] 60 | ) 61 | } 62 | 63 | member _.ShareNote(content: string) : Threading.Tasks.ValueTask = 64 | valueTaskUnit { 65 | logger.LogDebug( 66 | "Sharing Content: '{content}'...", 67 | content.Substring(0, 20) 68 | ) 69 | 70 | do! 71 | jsRuntime.InvokeVoidAsync( 72 | "Mandadin.Share.ShareContent", 73 | [| "Nota..." :> obj; content |] 74 | ) 75 | } 76 | 77 | member _.ToClipboard(content: string) : Threading.Tasks.ValueTask = 78 | valueTaskUnit { 79 | logger.LogDebug( 80 | "Copying content: '{content}'...", 81 | content.Substring(0, 20) 82 | ) 83 | 84 | do! 85 | jsRuntime.InvokeVoidAsync( 86 | "Mandadin.Clipboard.CopyTextToClipboard", 87 | [| content :> obj |] 88 | ) 89 | } } 90 | 91 | module Notes = 92 | open System.Threading.Tasks 93 | 94 | let inline factory (services: IServiceProvider) : INoteService = 95 | let jsRuntime = 96 | services.GetService() 97 | 98 | let loggerFactory = 99 | services.GetService() 100 | 101 | let logger = 102 | loggerFactory.CreateLogger() 103 | 104 | 105 | { new INoteService with 106 | member _.CreateNote(content: string) = 107 | valueTask { 108 | logger.LogDebug("Creating note...") 109 | 110 | try 111 | let! note = 112 | jsRuntime.InvokeAsync( 113 | "Mandadin.Database.CreateNote", 114 | [| content :> obj |] 115 | ) 116 | 117 | return ValueSome note 118 | with exn -> 119 | logger.LogError("Failed to create note: {exn}", exn) 120 | 121 | return ValueNone 122 | } 123 | 124 | member _.DeleteNote(note: Note) = 125 | valueTaskUnit { 126 | logger.LogDebug("Deleting note: {note}", note) 127 | 128 | return! 129 | jsRuntime.InvokeVoidAsync( 130 | "Mandadin.Database.DeleteNote", 131 | [| note.Id :> obj; note.Rev |] 132 | ) 133 | } 134 | 135 | member _.UpdateNote(content, note) = 136 | valueTask { 137 | logger.LogDebug("Updating note: {note}", note) 138 | 139 | try 140 | let! updated = 141 | jsRuntime.InvokeAsync( 142 | "Mandadin.Database.UpdateNote", 143 | [| { note with Content = content } :> obj |] 144 | ) 145 | 146 | return ValueSome updated 147 | with exn -> 148 | logger.LogError("Failed to update note: {exn}", exn) 149 | 150 | return ValueNone 151 | } 152 | 153 | member _.GetNotes() = 154 | valueTask { 155 | logger.LogDebug("Getting notes...") 156 | 157 | try 158 | let! notes = 159 | jsRuntime.InvokeAsync>( 160 | "Mandadin.Database.FindNotes", 161 | [||] 162 | ) 163 | 164 | return notes 165 | with exn -> 166 | logger.LogError("Failed to get notes: {exn}", exn) 167 | 168 | return List.empty 169 | } } 170 | 171 | module ListItems = 172 | 173 | 174 | let inline factory (services: IServiceProvider) : ITrackListItemService = 175 | let jsRuntime = 176 | services.GetService() 177 | 178 | let loggerFactory = 179 | services.GetService() 180 | 181 | let logger = 182 | loggerFactory.CreateLogger() 183 | 184 | { new ITrackListItemService with 185 | member _.GetHideDone listId = 186 | valueTask { 187 | 188 | let! hideDone = 189 | jsRuntime.InvokeAsync( 190 | "Mandadin.Database.GetHideDone", 191 | [| listId :> obj |] 192 | ) 193 | 194 | return hideDone 195 | 196 | } 197 | 198 | member _.SetHideDone(listId, hideDone) = 199 | valueTaskUnit { 200 | do! 201 | jsRuntime.InvokeVoidAsync( 202 | "Mandadin.Database.SaveHideDone", 203 | [| listId :> obj; hideDone |] 204 | ) 205 | } 206 | 207 | member self.CreateItem(listId, name) = 208 | taskResult { 209 | logger.LogDebug("Creating item: {listId}, {name}", listId, name) 210 | 211 | do! 212 | String.IsNullOrWhiteSpace name 213 | |> Result.requireFalse EmtptyString 214 | 215 | do! 216 | self.ItemExists(listId, name).AsTask() 217 | |> TaskResult.requireFalse (ExistingItem name) 218 | 219 | try 220 | 221 | let! created = 222 | jsRuntime.InvokeAsync( 223 | "Mandadin.Database.CreateListItem", 224 | [| listId :> obj; name |] 225 | ) 226 | 227 | return created 228 | with exn -> 229 | logger.LogError( 230 | "Failed to create item: {listId}, {name}, error: {exn}", 231 | listId, 232 | name, 233 | exn 234 | ) 235 | 236 | return! exn |> CreationFailed |> Error 237 | } 238 | 239 | 240 | member _.DeleteItem item = 241 | valueTaskUnit { 242 | do! 243 | jsRuntime.InvokeVoidAsync( 244 | "Mandadin.Database.DeleteListItem", 245 | [| item.Id :> obj |] 246 | ) 247 | } 248 | 249 | 250 | member _.GetItems(listId, ?hideDone) = 251 | valueTask { 252 | let hideDone = defaultArg hideDone false 253 | 254 | try 255 | let! items = 256 | jsRuntime.InvokeAsync>( 257 | "Mandadin.Database.GetListItems", 258 | [| listId :> obj; hideDone |] 259 | ) 260 | 261 | return items 262 | with ex -> 263 | logger.LogError( 264 | "Failed to get items for list: {listId}, error: {ex}", 265 | listId, 266 | ex 267 | ) 268 | 269 | return List.empty 270 | } 271 | 272 | member _.ItemExists(listId, name) = 273 | valueTask { 274 | try 275 | let! exists = 276 | jsRuntime.InvokeAsync( 277 | "Mandadin.Database.ListItemExists", 278 | [| listId :> obj; name |] 279 | ) 280 | 281 | return exists 282 | with ex -> 283 | logger.LogError( 284 | "Failed to check if item exists: {listId}, {name}, error: {ex}", 285 | listId, 286 | name, 287 | ex 288 | ) 289 | 290 | return false 291 | } 292 | 293 | member _.UpdateItem item = 294 | valueTask { 295 | try 296 | let! updated = 297 | jsRuntime.InvokeAsync( 298 | "Mandadin.Database.UpdateListItem", 299 | [| item :> obj |] 300 | ) 301 | 302 | return updated 303 | with ex -> 304 | logger.LogError( 305 | "Failed to update item: {item}, error: {ex}", 306 | item, 307 | ex 308 | ) 309 | 310 | return item 311 | } } 312 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Main.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client 2 | 3 | open System 4 | 5 | open Microsoft.Extensions.Logging 6 | open Microsoft.AspNetCore.Components 7 | open Microsoft.AspNetCore.Components.Routing 8 | open Microsoft.JSInterop 9 | 10 | open IcedTasks 11 | 12 | open Elmish 13 | 14 | open Bolero 15 | open Bolero.Html 16 | open Bolero.Remoting.Client 17 | 18 | open Mandadin.Client.Components 19 | open Mandadin.Client.Components.Navbar 20 | open Mandadin.Client.Router 21 | 22 | [] 23 | type AppInit = 24 | { CanShare: bool 25 | Theme: Theme 26 | HasOverlay: bool 27 | Title: string } 28 | 29 | [] 30 | type AppDependencies = 31 | { jsRuntime: IJSRuntime 32 | logger: ILogger } 33 | 34 | [] 35 | type ActionError = 36 | | ThemeChangeFailed of targetTheme: Theme 37 | | TitleChangeFailed of targetTitle: string 38 | | SetViewFailed of targetPage: Page 39 | | InitializationFailed of initError: string 40 | 41 | type Model = 42 | { Page: Page 43 | Theme: Theme 44 | Title: string 45 | CanShare: bool 46 | HasOverlayControls: bool } 47 | 48 | [] 49 | type Message = 50 | | SetView of view: Page 51 | | SetTheme of theme: Theme 52 | | SetTitle of title: string 53 | | SetInitial of init: AppInit 54 | | NotifyFailure of failure: ActionError 55 | 56 | [] 57 | module Mandadin = 58 | 59 | let (|Morning|Evening|Night|Unknown|) = 60 | function 61 | | num when num > 4 && num < 12 -> Morning 62 | | num when num > 11 && num < 20 -> Evening 63 | | num when num > 19 || num < 5 -> Night 64 | | num -> Unknown num 65 | 66 | let inline getGreeting () = 67 | let hour = DateTime.Now.Hour 68 | 69 | match hour with 70 | | Morning -> "Buenos dias!" 71 | | Evening -> "Buenas tardes!" 72 | | Night -> "Buenas noches!" 73 | | Unknown num -> 74 | printfn $"{num}" 75 | "Hola!" 76 | 77 | let inline navigateToList dispatch (route: string) = 78 | Page.ListDetail route |> SetView |> dispatch 79 | 80 | let inline goBack dispatch () = SetView Page.Lists |> dispatch 81 | 82 | let onThemeChangeRequest 83 | { jsRuntime = jsRuntime 84 | logger = logger } 85 | (state: Model) 86 | dispatch 87 | _ 88 | = 89 | valueTaskUnit { 90 | let newTheme = 91 | match state.Theme with 92 | | Theme.Dark -> Theme.Light 93 | | Theme.Light -> Theme.Dark 94 | 95 | try 96 | logger.LogDebug("Requesting New Theme: {value}", newTheme.AsDisplay) 97 | 98 | let! value = 99 | jsRuntime.InvokeAsync( 100 | "Mandadin.Theme.SwitchTheme", 101 | [| box newTheme.AsString |] 102 | ) 103 | 104 | logger.LogDebug("Accepted Theme: {value}", value) 105 | 106 | if value then 107 | SetTheme newTheme |> dispatch 108 | else 109 | NotifyFailure(ThemeChangeFailed newTheme) 110 | |> dispatch 111 | with ex -> 112 | logger.LogWarning("Failed to get theme: {error}", ex.Message) 113 | 114 | NotifyFailure(ThemeChangeFailed newTheme) 115 | |> dispatch 116 | 117 | return () 118 | } 119 | |> ignore 120 | 121 | let tryGetHasOverlay 122 | { jsRuntime = jsRuntime 123 | logger = logger } 124 | = 125 | cancellableValueTask { 126 | let! token = CancellableValueTask.getCancellationToken () 127 | 128 | try 129 | let! value = 130 | jsRuntime.InvokeAsync( 131 | "Mandadin.Theme.HasOverlayControls", 132 | token, 133 | [||] 134 | ) 135 | 136 | logger.LogDebug("Overlay status: {value}", value) 137 | return false 138 | with ex -> 139 | logger.LogWarning("Failed to get overlay status: {error}", ex.Message) 140 | 141 | return false 142 | } 143 | 144 | let tryGetTheme 145 | { jsRuntime = jsRuntime 146 | logger = logger } 147 | = 148 | cancellableValueTask { 149 | let! token = CancellableValueTask.getCancellationToken () 150 | 151 | try 152 | let! value = 153 | jsRuntime.InvokeAsync("Mandadin.Theme.GetTheme", token, [||]) 154 | 155 | logger.LogDebug("Theme: {value}", value) 156 | return value |> Theme.ofString 157 | with ex -> 158 | logger.LogWarning("Failed to get theme: {error}", ex.Message) 159 | 160 | return Theme.Dark 161 | } 162 | 163 | let tryGetCanShare 164 | { jsRuntime = jsRuntime 165 | logger = logger } 166 | = 167 | cancellableValueTask { 168 | let! token = CancellableValueTask.getCancellationToken () 169 | 170 | try 171 | let! value = 172 | jsRuntime.InvokeAsync("Mandadin.Share.CanShare", token, [||]) 173 | 174 | logger.LogDebug("Share status: {value}", value) 175 | return value 176 | with ex -> 177 | logger.LogWarning("Failed to get share status: {error}", ex.Message) 178 | 179 | return false 180 | } 181 | 182 | let setInitialParams dependencies = 183 | cancellableTask { 184 | let! hasOverlay = tryGetHasOverlay dependencies 185 | 186 | let! theme = tryGetTheme dependencies 187 | 188 | let! canShare = tryGetCanShare dependencies 189 | 190 | return 191 | { CanShare = canShare 192 | Theme = theme 193 | HasOverlay = hasOverlay 194 | Title = getGreeting () } 195 | } 196 | 197 | type AppShell() = 198 | inherit ProgramComponent() 199 | 200 | let cancellationTokenSource = 201 | new Threading.CancellationTokenSource() 202 | 203 | let init dependencies cancellationToken = 204 | { Page = Page.Notes 205 | Theme = Theme.Dark 206 | CanShare = false 207 | HasOverlayControls = false 208 | Title = getGreeting () }, 209 | Cmd.OfTask.either 210 | (setInitialParams dependencies) 211 | cancellationToken 212 | SetInitial 213 | (fun err -> NotifyFailure(InitializationFailed err.Message)) 214 | 215 | 216 | let update { jsRuntime = _; logger = logger } message model = 217 | match message with 218 | | SetView view -> { model with Page = view }, Cmd.none 219 | | SetTheme theme -> { model with Theme = theme }, Cmd.none 220 | | SetTitle title -> { model with Title = title }, Cmd.none 221 | | SetInitial { CanShare = canShare 222 | Theme = theme 223 | HasOverlay = hasOverlay 224 | Title = title } -> 225 | { model with 226 | CanShare = canShare 227 | HasOverlayControls = hasOverlay }, 228 | Cmd.batch 229 | [ Cmd.ofMsg (SetTheme theme) 230 | Cmd.ofMsg (SetTitle title) ] 231 | | NotifyFailure err -> 232 | match err with 233 | | ThemeChangeFailed targetTheme -> 234 | logger.LogDebug("Theme Change Error: {error}", targetTheme) 235 | model, Cmd.none 236 | | TitleChangeFailed targetTitle -> 237 | logger.LogDebug("Title Change Error: {error}", targetTitle) 238 | model, Cmd.none 239 | | SetViewFailed targetPage -> 240 | logger.LogDebug("Set View Error: {error}", targetPage) 241 | model, Cmd.none 242 | | InitializationFailed err -> 243 | logger.LogDebug("Initialization Error: {error}", err) 244 | model, Cmd.none 245 | 246 | let view dependencies state dispatch = 247 | article { 248 | attr.``class`` "mandadin" 249 | 250 | cond state.HasOverlayControls 251 | <| function 252 | | true -> TitleBar.View(Some state.Title) 253 | | false -> empty () 254 | 255 | Navbar.View( 256 | state.Theme, 257 | onThemeChangeRequest dependencies state dispatch, 258 | concat { 259 | Router.navLink (Page.Notes, "Notas") 260 | 261 | Router.navLink ( 262 | Page.Lists, 263 | "Listas", 264 | linkMatch = NavLinkMatch.Prefix 265 | ) 266 | } 267 | ) 268 | 269 | main { 270 | attr.``class`` "paper container mandadin-main" 271 | 272 | cond state.Page 273 | <| function 274 | | Page.Import -> 275 | comp { 276 | "OnGoToListRequested" => navigateToList dispatch 277 | } 278 | | Page.Notes -> 279 | comp { "CanShare" => state.CanShare } 280 | | Page.Lists -> 281 | comp { 282 | "OnRouteRequested" => navigateToList dispatch 283 | } 284 | | Page.ListDetail listId -> 285 | comp { 286 | "ListId" => ValueSome listId 287 | "CanShare" => state.CanShare 288 | "OnBackRequested" => goBack dispatch 289 | } 290 | } 291 | 292 | footer { 293 | attr.``class`` "paper row flex-spaces mandadin-footer" 294 | p { text "\u00A9 Tunaxor Apps 2020 - 2024" } 295 | p { text "Mandadin4" } 296 | } 297 | } 298 | 299 | static member val Router = Router.infer SetView (fun m -> m.Page) 300 | 301 | [] 302 | member val LoggerFactory: ILoggerFactory = 303 | Unchecked.defaultof with get, set 304 | 305 | override this.Program = 306 | let dependencies = 307 | { jsRuntime = this.JSRuntime 308 | logger = this.LoggerFactory.CreateLogger("AppShell") } 309 | 310 | let view = view dependencies 311 | let update = update dependencies 312 | 313 | let init _ = 314 | init dependencies cancellationTokenSource.Token 315 | 316 | Program.mkProgram init update view 317 | |> Program.withRouter AppShell.Router 318 | #if DEBUG 319 | |> Program.withConsoleTrace 320 | #endif 321 | 322 | 323 | interface IDisposable with 324 | member _.Dispose() = 325 | if not cancellationTokenSource.IsCancellationRequested then 326 | cancellationTokenSource.Cancel() 327 | 328 | cancellationTokenSource.Dispose() 329 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Views/TrackLists.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Views 2 | 3 | open Microsoft.JSInterop 4 | open Microsoft.AspNetCore.Components 5 | open Microsoft.Extensions.Logging 6 | 7 | open Elmish 8 | open Bolero 9 | open Bolero.Html 10 | open Bolero.Remoting.Client 11 | 12 | open Mandadin.Client 13 | 14 | 15 | [] 16 | module Lists = 17 | open System 18 | 19 | type State = 20 | { TrackLists: list 21 | CurrentListName: string 22 | CanAddCurrentName: bool 23 | ShowConfirmDeleteModal: ValueOption 24 | ShowImportDialog: bool 25 | FromClipboard: ValueOption } 26 | 27 | type Msg = 28 | | SetCurrentListName of string 29 | | RequestRoute of string 30 | 31 | | GetLists 32 | | GetListsSuccess of seq 33 | 34 | | ValidateListName of string 35 | | ValidateListNameSuccess of nameExists: bool * name: string 36 | 37 | | CreateList of string 38 | | CreateFromImport of string * array> 39 | | CreateListSuccess of TrackList 40 | 41 | | DeleteList of TrackList 42 | | DeleteListSuccess of TrackList 43 | 44 | | ShowConfirmDeleteModal of ValueOption 45 | | ShowConfirmDeleteModalAction of TrackList * Result 46 | 47 | | ShowImportDialog of bool 48 | | ShowImportDialogAction of Result 49 | 50 | | FromClipboard 51 | | FromClipboardSuccess of string 52 | 53 | | Error of exn 54 | 55 | let init (_: 'arg) = 56 | { TrackLists = list.Empty 57 | CurrentListName = "" 58 | CanAddCurrentName = false 59 | ShowConfirmDeleteModal = ValueNone 60 | ShowImportDialog = false 61 | FromClipboard = ValueNone }, 62 | Cmd.ofMsg GetLists 63 | 64 | let update 65 | (msg: Msg) 66 | (state: State) 67 | (js: IJSRuntime) 68 | (logger: ILogger) 69 | (onRouteRequested: string -> unit) 70 | = 71 | match msg with 72 | | SetCurrentListName name -> 73 | { state with CurrentListName = name }, Cmd.ofMsg (ValidateListName name) 74 | | RequestRoute listid -> 75 | onRouteRequested listid 76 | state, Cmd.none 77 | | GetLists -> 78 | state, 79 | Cmd.OfJS.either 80 | js 81 | "Mandadin.Database.FindLists" 82 | [||] 83 | GetListsSuccess 84 | Error 85 | | GetListsSuccess items -> 86 | { state with 87 | TrackLists = items |> List.ofSeq }, 88 | Cmd.none 89 | | ValidateListName name -> 90 | state, 91 | Cmd.OfJS.either 92 | js 93 | "Mandadin.Database.ListNameExists" 94 | [| name |] 95 | (fun exists -> ValidateListNameSuccess(exists, name)) 96 | Error 97 | | ValidateListNameSuccess(nameExists, name) -> 98 | if nameExists then 99 | { state with CanAddCurrentName = false }, 100 | Cmd.ofMsg (Error(exn "Name already exists")) 101 | else 102 | { state with 103 | CanAddCurrentName = true && name.Length <> 0 }, 104 | Cmd.none 105 | | CreateList name -> 106 | state, 107 | Cmd.OfJS.either 108 | js 109 | "Mandadin.Database.CreateList" 110 | [| name |] 111 | CreateListSuccess 112 | Error 113 | | CreateListSuccess list -> 114 | { state with 115 | TrackLists = 116 | (list :: state.TrackLists) 117 | |> List.sortBy (fun item -> item.Id) 118 | CurrentListName = "" 119 | ShowImportDialog = false }, 120 | Cmd.none 121 | | FromClipboard -> 122 | state, 123 | Cmd.OfJS.either 124 | js 125 | "Mandadin.Clipboard.ReadTextFromClipboard" 126 | [||] 127 | FromClipboardSuccess 128 | Error 129 | | FromClipboardSuccess content -> 130 | { state with 131 | FromClipboard = ValueSome content }, 132 | Cmd.ofMsg (ShowImportDialog true) 133 | | ShowImportDialog show -> { state with ShowImportDialog = show }, Cmd.none 134 | | ShowImportDialogAction result -> 135 | let cmd = 136 | match result with 137 | | Ok(title, content) -> 138 | match Import.parseContentString content with 139 | | Ok parsed -> Cmd.ofMsg (CreateFromImport(title, parsed)) 140 | | Result.Error errs -> 141 | errs 142 | |> List.iter (fun (line, err) -> 143 | logger.LogDebug( 144 | "Failed at {index} with: {error} for '{line}'", 145 | err.idx, 146 | err.message, 147 | line 148 | )) 149 | 150 | Cmd.none 151 | | _ -> Cmd.ofMsg (ShowImportDialog false) 152 | 153 | { state with FromClipboard = ValueNone }, cmd 154 | | CreateFromImport(title, items) -> 155 | state, 156 | Cmd.OfJS.either 157 | js 158 | "Mandadin.Database.ImportList" 159 | [| title; items |] 160 | CreateListSuccess 161 | Error 162 | | ShowConfirmDeleteModal show -> 163 | { state with 164 | ShowConfirmDeleteModal = show }, 165 | Cmd.none 166 | | ShowConfirmDeleteModalAction(item, result) -> 167 | let cmd = 168 | match result with 169 | | Ok result when result -> Cmd.ofMsg (DeleteList item) 170 | | _ -> Cmd.ofMsg (ShowConfirmDeleteModal ValueNone) 171 | 172 | state, cmd 173 | | DeleteList item -> 174 | state, 175 | Cmd.OfJS.either 176 | js 177 | "Mandadin.Database.DeleteList" 178 | [| item.Id; item.Rev |] 179 | (fun _ -> DeleteListSuccess item) 180 | Error 181 | | DeleteListSuccess item -> 182 | let list = 183 | state.TrackLists 184 | |> List.filter (fun i -> i <> item) 185 | 186 | { state with 187 | TrackLists = list 188 | ShowConfirmDeleteModal = ValueNone }, 189 | Cmd.none 190 | | Error ex -> 191 | eprintfn "Update Error: [%s]" ex.Message 192 | state, Cmd.none 193 | 194 | let private newListForm (state: State) (dispatch: Dispatch) = 195 | let currentContentTxt = 196 | "Nombre de la nueva lista..." 197 | 198 | form { 199 | attr.``class`` "row flex-spaces background-muted border notes-form" 200 | on.submit (fun _ -> CreateList state.CurrentListName |> dispatch) 201 | 202 | fieldset { 203 | attr.``class`` "form-group" 204 | 205 | label { 206 | attr.``for`` "current-content" 207 | text currentContentTxt 208 | } 209 | 210 | textarea { 211 | attr.id "current-content" 212 | attr.placeholder currentContentTxt 213 | 214 | bind.input.string 215 | state.CurrentListName 216 | (SetCurrentListName >> dispatch) 217 | } 218 | } 219 | 220 | button { 221 | attr.``type`` "submit" 222 | attr.disabled (not state.CanAddCurrentName) 223 | Icon.Get Save 224 | } 225 | 226 | button { 227 | attr.``class`` "paper-btn btn-small" 228 | attr.``type`` "button" 229 | on.click (fun _ -> FromClipboard |> dispatch) 230 | Icon.Get Import 231 | } 232 | 233 | } 234 | 235 | let private listItem (item: TrackList) (dispatch: Dispatch) = 236 | 237 | li { 238 | attr.``class`` "tracklist-item row flex-spaces" 239 | attr.key item.Id 240 | 241 | p { 242 | attr.``class`` "m-05" 243 | text item.Id 244 | } 245 | 246 | button { 247 | attr.``class`` "paper-btn btn-small btn-primary-outline" 248 | on.click (fun _ -> RequestRoute item.Id |> dispatch) 249 | Icon.Get Text 250 | } 251 | 252 | button { 253 | attr.``class`` "paper-btn btn-small btn-danger-outline" 254 | on.click (fun _ -> ShowConfirmDeleteModal(ValueSome item) |> dispatch) 255 | Icon.Get Trash 256 | } 257 | } 258 | 259 | let deleteModal state dispatch = 260 | 261 | cond state.ShowConfirmDeleteModal 262 | <| function 263 | | ValueSome item -> 264 | let title = "Borrar Elemento" 265 | 266 | let subtitle = 267 | "esta operacion es irreversible" 268 | 269 | let txt = 270 | $"""Proceder con el borrado de "%s{item.Id}"?""" 271 | 272 | Modals.DeleteResourceModal (title, subtitle, txt) (fun result -> 273 | ShowConfirmDeleteModalAction(item, result) 274 | |> dispatch) 275 | | ValueNone -> empty () 276 | 277 | let view (state: State) (dispatch: Dispatch) = 278 | 279 | article { 280 | cond state.ShowImportDialog 281 | <| function 282 | | false -> empty () 283 | | true -> 284 | comp { 285 | "ImportData" 286 | => match state.FromClipboard with 287 | | ValueSome data -> 288 | let prefill: Modals.Import.ImportData = 289 | { title = ""; content = data } 290 | 291 | prefill 292 | | ValueNone -> { title = ""; content = "" } 293 | 294 | "OnDismiss" 295 | => (fun () -> dispatch (ShowImportDialogAction(Result.Error()))) 296 | 297 | "OnImport" 298 | => (fun (data: Modals.Import.ImportData) -> 299 | dispatch (ShowImportDialogAction(Ok(data.title, data.content)))) 300 | } 301 | 302 | newListForm state dispatch 303 | 304 | deleteModal state dispatch 305 | 306 | ul { 307 | attr.``class`` "tracklist-list child-borders" 308 | 309 | for item in state.TrackLists do 310 | listItem item dispatch 311 | } 312 | } 313 | 314 | 315 | 316 | type Page() = 317 | inherit ProgramComponent() 318 | 319 | [] 320 | member val LoggerFactory = Unchecked.defaultof with get, set 321 | 322 | [] 323 | member val OnRouteRequested: (string -> unit) = ignore with get, set 324 | 325 | override self.Program = 326 | let update msg state = 327 | let logger = 328 | self.LoggerFactory.CreateLogger("Lists Page") 329 | 330 | update msg state self.JSRuntime logger self.OnRouteRequested 331 | 332 | Program.mkProgram init update view 333 | #if DEBUG 334 | |> Program.withConsoleTrace 335 | #endif 336 | -------------------------------------------------------------------------------- /src/Mandadin.Client/Views/TrackListItems.fs: -------------------------------------------------------------------------------- 1 | namespace Mandadin.Client.Views.ListItems 2 | 3 | 4 | open Microsoft.AspNetCore.Components 5 | open Microsoft.Extensions.Logging 6 | open Microsoft.Extensions.DependencyInjection 7 | 8 | open IcedTasks 9 | open FsToolkit.ErrorHandling 10 | 11 | open Elmish 12 | open Bolero 13 | open Bolero.Html 14 | open Bolero.Remoting.Client 15 | 16 | open Mandadin.Client 17 | open Mandadin.Client.Components.TrackListItems 18 | 19 | 20 | [] 21 | type UpdatableItemProp = 22 | | IsDone of isDone: bool 23 | | Name of name: string 24 | 25 | member this.AsString = 26 | match this with 27 | | IsDone isDone -> $"IsDone(%b{isDone})" 28 | | Name name -> $"Name(%s{name})" 29 | 30 | [] 31 | type ItemValidationFailure = 32 | | EmptyItem 33 | | ItemExists of name: string 34 | 35 | member this.AsString = 36 | match this with 37 | | EmptyItem -> "The Item has no name" 38 | | ItemExists name -> $"ItemExists(%s{name})" 39 | 40 | [] 41 | type ItemFailure = 42 | | ItemExists 43 | | FailedToUpdateItem of 44 | failedUpdateItem: TrackListItem * 45 | prop: UpdatableItemProp 46 | | FailedToDeleteItem of failedDeleteItem: TrackListItem 47 | | FailedToCreateItem of failedCreationItem: string 48 | 49 | type Model = 50 | { Items: list 51 | TrackListId: string 52 | CurrentItem: string 53 | CanAddCurrentItem: bool 54 | HideDone: bool 55 | CanShare: bool 56 | ShowConfirmDeleteModal: ValueOption } 57 | 58 | type Message = 59 | | SetHideDone of bool 60 | | SetItemList of list 61 | | SetNewItem of TrackListItem 62 | | UpdateItem of item: TrackListItem 63 | | RemoveItem of TrackListItem 64 | | SetCurrentItem of string 65 | | ShowConfirmDeleteModal of ValueOption 66 | | ItemFailure of ItemFailure 67 | 68 | 69 | [] 70 | module Actions = 71 | 72 | let getItems (items: ITrackListItemService) itemParams dispatch _ = 73 | valueTaskUnit { 74 | let! items = items.GetItems itemParams 75 | items |> SetItemList |> dispatch 76 | } 77 | |> ignore 78 | 79 | let createItem (items: ITrackListItemService) dispatch listId newName = 80 | taskUnit { 81 | printfn "Creating item: %s on list %s" newName listId 82 | 83 | match! items.CreateItem(listId, newName) with 84 | | Ok created -> created |> SetNewItem |> dispatch 85 | | Error error -> 86 | match error with 87 | | EmtptyString -> 88 | "El objeto no puede tener un nombre vacio" 89 | |> FailedToCreateItem 90 | |> ItemFailure 91 | |> dispatch 92 | | ExistingItem name -> 93 | $"El objeto {name} ya existe" 94 | |> FailedToCreateItem 95 | |> ItemFailure 96 | |> dispatch 97 | | CreationFailed _ -> 98 | "Error al crear el objeto" 99 | |> FailedToCreateItem 100 | |> ItemFailure 101 | |> dispatch 102 | } 103 | 104 | let deleteItem 105 | (items: ITrackListItemService, dispatch) 106 | (item: TrackListItem) 107 | (confirm: Result) 108 | = 109 | valueTaskUnit { 110 | try 111 | match confirm with 112 | | Ok true -> do! items.DeleteItem item 113 | | Ok _ 114 | | Error _ -> ShowConfirmDeleteModal ValueNone |> dispatch 115 | with ex -> 116 | item 117 | |> FailedToDeleteItem 118 | |> ItemFailure 119 | |> dispatch 120 | } 121 | |> ignore 122 | 123 | let private updateItem (items: ITrackListItemService, dispatch) item prop = 124 | valueTaskUnit { 125 | let updated = 126 | match prop with 127 | | IsDone isDone -> { item with IsDone = isDone } 128 | | Name name -> { item with Name = name } 129 | 130 | try 131 | let! updated = items.UpdateItem updated 132 | updated |> UpdateItem |> dispatch 133 | with ex -> 134 | FailedToUpdateItem(item, prop) 135 | |> ItemFailure 136 | |> dispatch 137 | } 138 | |> ignore 139 | 140 | let updateIsDone dependencies item isDone = 141 | updateItem dependencies item (IsDone isDone) 142 | 143 | let updateName dependencies item name = 144 | updateItem dependencies item (Name name) 145 | 146 | let onHideDone (items: ITrackListItemService, dispatch) listId hideDone = 147 | valueTaskUnit { 148 | try 149 | do! items.SetHideDone(listId, hideDone) 150 | hideDone |> SetHideDone |> dispatch 151 | with ex -> 152 | hideDone |> SetHideDone |> dispatch 153 | } 154 | |> ignore 155 | 156 | [] 157 | module Cmd = 158 | 159 | let ofGetItems (items: ITrackListItemService) listId hideDone = 160 | let tsk listId = 161 | task { 162 | let! items = items.GetItems(listId, hideDone) 163 | return items 164 | } 165 | 166 | Cmd.OfTask.perform tsk listId SetItemList 167 | 168 | let ofHideDone (items: ITrackListItemService, listId) = 169 | let tsk listId = 170 | task { 171 | let! hideDone = items.GetHideDone listId 172 | return hideDone 173 | } 174 | 175 | Cmd.OfTask.perform tsk listId SetHideDone 176 | 177 | [] 178 | module private Components = 179 | 180 | let ListItem dependencies (item: TrackListItem) = 181 | let _, dispatch = dependencies 182 | 183 | li { 184 | attr.``class`` "listitem-item" 185 | attr.key item.Id 186 | 187 | input { 188 | attr.``type`` "checkbox" 189 | attr.``class`` "listitem-item-checkbox" 190 | attr.id item.Id 191 | 192 | bind.``checked`` item.IsDone (Actions.updateIsDone dependencies item) 193 | } 194 | 195 | input { 196 | bind.input.string item.Name (Actions.updateName dependencies item) 197 | } 198 | 199 | button { 200 | attr.``class`` "paper-btn btn-small btn-danger-outline m-0" 201 | on.click (fun _ -> ShowConfirmDeleteModal(ValueSome item) |> dispatch) 202 | Icon.Get Trash 203 | } 204 | } 205 | 206 | let RemoveItem dpendencies state = 207 | cond state.ShowConfirmDeleteModal 208 | <| function 209 | | ValueSome item -> 210 | let title = "Borrar Elemento." 211 | 212 | let subtitle = 213 | "Esta operacion es irreversible." 214 | 215 | let txt = 216 | $"Proceder con el borrado de '%s{item.Name}'?" 217 | 218 | Modals.DeleteResourceModal 219 | (title, subtitle, txt) 220 | (Actions.deleteItem dpendencies item) 221 | | ValueNone -> empty () 222 | 223 | [] 224 | module ListItems = 225 | 226 | let init (items: ITrackListItemService) (listId: string) (canShare: bool) = 227 | { Items = [] 228 | TrackListId = listId 229 | HideDone = false 230 | CurrentItem = "" 231 | CanAddCurrentItem = false 232 | CanShare = canShare 233 | ShowConfirmDeleteModal = ValueNone }, 234 | Cmd.ofHideDone (items, listId) 235 | 236 | let update 237 | (logger: ILogger, items: ITrackListItemService) 238 | (msg: Message) 239 | (model: Model) 240 | = 241 | 242 | match msg with 243 | | SetHideDone hide -> 244 | { model with HideDone = hide }, 245 | Cmd.ofGetItems items model.TrackListId hide 246 | | SetCurrentItem item -> { model with CurrentItem = item }, Cmd.none 247 | | SetItemList items -> { model with Items = items }, Cmd.none 248 | | SetNewItem item -> 249 | { model with 250 | CurrentItem = "" 251 | Items = 252 | (item :: model.Items) 253 | |> List.sortBy (fun item -> item.Name) }, 254 | Cmd.none 255 | | UpdateItem item -> 256 | { model with 257 | Items = 258 | model.Items 259 | |> List.map (fun i -> if i.Id = item.Id then item else i) }, 260 | Cmd.none 261 | | ShowConfirmDeleteModal show -> 262 | { model with 263 | ShowConfirmDeleteModal = show }, 264 | Cmd.none 265 | | RemoveItem item -> 266 | let items = 267 | model.Items 268 | |> List.filter (fun i -> i.Id <> item.Id) 269 | |> List.sortBy (fun item -> item.Name) 270 | 271 | { model with 272 | Items = items 273 | ShowConfirmDeleteModal = ValueNone }, 274 | Cmd.none 275 | | ItemFailure(error) -> 276 | match error with 277 | | ItemExists -> logger.LogDebug("Item already exists") 278 | | FailedToCreateItem item -> 279 | logger.LogError("Failed to create item: {item}", item) 280 | | FailedToUpdateItem(failedUpdateItem, prop) -> 281 | logger.LogError( 282 | "Failed to update item: {failedUpdateItem} with prop: {prop}", 283 | failedUpdateItem, 284 | prop.AsString 285 | ) 286 | | FailedToDeleteItem(failedDeleteItem) -> 287 | logger.LogDebug( 288 | "Failed to delete item: {failedDeleteItem}", 289 | failedDeleteItem 290 | ) 291 | 292 | model, Cmd.none 293 | 294 | let view dependencies (state: Model) (dispatch: Dispatch) = 295 | let items, share, onBackRequested = 296 | dependencies 297 | 298 | article { 299 | TrackListComponents.Toolbar(state, share, onBackRequested) 300 | 301 | comp { 302 | "HideDone" => state.HideDone 303 | 304 | "OnSubmit" 305 | => Actions.createItem items dispatch state.TrackListId 306 | 307 | "OnHideDoneChange" 308 | => Actions.onHideDone (items, dispatch) state.TrackListId 309 | } 310 | 311 | Components.RemoveItem (items, dispatch) state 312 | 313 | ul { 314 | attr.``class`` "tracklist-list" 315 | 316 | virtualize.comp { 317 | virtualize.placeholder (fun _ -> div { text "Cargando..." }) 318 | let! item = virtualize.items state.Items 319 | Components.ListItem (items, dispatch) item 320 | } 321 | 322 | } 323 | } 324 | 325 | 326 | type Page() = 327 | inherit ProgramComponent() 328 | 329 | [] 330 | member val OnBackRequested: (unit -> unit) = ignore with get, set 331 | 332 | [] 333 | member val ListId: ValueOption = ValueNone with get, set 334 | 335 | [] 336 | member val CanShare: bool = false with get, set 337 | 338 | override this.Program = 339 | 340 | let items = 341 | this.Services.GetService() 342 | 343 | let share = 344 | this.Services.GetService() 345 | 346 | let loggerFactory = 347 | this.Services.GetService() 348 | 349 | let logger = 350 | loggerFactory.CreateLogger() 351 | 352 | let init _ = 353 | ListItems.init items this.ListId.Value this.CanShare 354 | 355 | let update = 356 | ListItems.update (logger, items) 357 | 358 | let view = 359 | ListItems.view (items, share, this.OnBackRequested) 360 | 361 | Program.mkProgram init update view 362 | #if DEBUG 363 | |> Program.withConsoleTrace 364 | #endif 365 | -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/js/database.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('pouchdb')} PouchDB 3 | */ 4 | 5 | /** 6 | * @type {PouchDB.Database<{ content: string }>} 7 | */ 8 | const Notes = new PouchDB("notas"); 9 | 10 | /** 11 | * @type {PouchDB.Database<{}>} 12 | */ 13 | const Lists = new PouchDB("lists"); 14 | 15 | /** 16 | * @type {PouchDB.Database<{isDone: boolean, listId: string; name: string; }>} 17 | */ 18 | const ListItems = new PouchDB("listItems"); 19 | 20 | /** 21 | * @type {PouchDB.Database<{ hideDone: boolean }>} 22 | */ 23 | const HideDone = new PouchDB("hideDone"); 24 | 25 | 26 | (async function(listItems) { 27 | try { 28 | const listIdIndex = listItems.createIndex({ 29 | index: { 30 | fields: ['listId'], 31 | name: 'listIdIndex', 32 | ddoc: 'mandadinddoclistid', 33 | } 34 | }); 35 | const isDoneIndex = listItems.createIndex({ 36 | index: { 37 | fields: ['listId', 'isDone'], 38 | name: 'isDoneIndex', 39 | ddoc: 'mandadinddocisdone', 40 | } 41 | }); 42 | const listItemNameIndex = listItems.createIndex({ 43 | index: { 44 | fields: ['listId', 'name'], 45 | name: 'listItemNameIndex', 46 | ddoc: 'mandadinddoclistitemnameindex' 47 | } 48 | }); 49 | const createIndexesResult = await Promise.all([listIdIndex, isDoneIndex, listItemNameIndex]); 50 | console.log({ createIndexesResult }); 51 | } catch (error) { 52 | console.warn(`Error creating index for ListItems [${error.message}]`); 53 | } 54 | })(ListItems) 55 | 56 | 57 | function mapDocument(doc) { 58 | console.log(doc); 59 | return { 60 | id: doc._id, 61 | rev: doc._rev, 62 | content: doc.content 63 | } 64 | } 65 | 66 | function mapAllDocs({ total_rows, offset, rows }) { 67 | console.log({ total_rows, offset }); 68 | console.table(rows); 69 | return rows.map(({ id, doc }) => ( 70 | { 71 | id: id, 72 | rev: doc._rev, 73 | content: doc.content 74 | } 75 | )); 76 | } 77 | 78 | export function FindNotes() { 79 | return Notes.allDocs({ include_docs: true }) 80 | .then(mapAllDocs) 81 | .then(findNotesResult => { 82 | console.log({ result: findNotesResult }); 83 | return findNotesResult 84 | }); 85 | } 86 | 87 | /** 88 | * 89 | * @param {string} content 90 | * @returns {Promise} 91 | */ 92 | export async function CreateNote(content) { 93 | const note = { content, _id: `${Date.now()}` } 94 | 95 | const createNoteResult = await Notes.put(note); 96 | console.log({ createNoteResult }); 97 | return { id: createNoteResult.id, content, rev: createNoteResult.rev }; 98 | } 99 | 100 | /** 101 | * 102 | * @param {Note} note 103 | * @return {Promise} 104 | */ 105 | export async function UpdateNote(note) { 106 | const toUpdate = { _id: note.id, _rev: note.rev, ...note, id: undefined, rev: undefined }; 107 | const updateNoteResult = await Notes.put(toUpdate); 108 | return { ...note, rev: updateNoteResult.rev } 109 | } 110 | 111 | /** 112 | * 113 | * @param {string} noteid 114 | * @returns {Promise} 115 | */ 116 | export function FindNote(noteid) { 117 | return Notes.get(noteid).then(mapDocument); 118 | } 119 | 120 | /** 121 | * 122 | * @param {string} noteid 123 | * @param {string} noterev 124 | * @returns {Promise<[string, string]>} 125 | */ 126 | export async function DeleteNote(noteid, noterev) { 127 | try { 128 | const { id, ok, rev } = await Notes.remove(noteid, noterev); 129 | if (!ok) { 130 | return Promise.reject(`Failed to delete document with id: [${id}]`); 131 | } 132 | return [id, rev]; 133 | } catch (deleteNoteError) { 134 | console.warn({ deleteNoteError }); 135 | return Promise.reject(deleteNoteError.message); 136 | } 137 | } 138 | 139 | /** 140 | * @returns {Promise} 141 | */ 142 | export async function FindLists() { 143 | return Lists 144 | .allDocs({ include_docs: true }). 145 | then(({ total_rows, offset, rows }) => { 146 | return rows.map(({ id, doc }) => ( 147 | { 148 | id: id, 149 | rev: doc._rev 150 | } 151 | )); 152 | }) 153 | .then(findListResults => { 154 | console.log({ findListResults }); 155 | return findListResults; 156 | }); 157 | } 158 | 159 | /** 160 | * 161 | * @param {string} name 162 | * @return {Promise} 163 | */ 164 | function FindList(name) { 165 | return Lists.get(name).then(doc => ({ 166 | id: doc._id, 167 | rev: doc._rev 168 | })); 169 | } 170 | 171 | /** 172 | * 173 | * @param {string} name 174 | * @returns {Promise} 175 | */ 176 | export async function ListNameExists(name) { 177 | try { 178 | const listNameExistsResult = await FindList(name); 179 | console.log({ listNameExistsResult }); 180 | return true; 181 | } catch (listNameExistsError) { 182 | if (listNameExistsError.status === 404) { 183 | return false; 184 | } 185 | console.warn({ listNameExistsError }); 186 | return true; 187 | } 188 | } 189 | 190 | /** 191 | * 192 | * @param {string} name 193 | * @returns {Promise} 194 | */ 195 | export function CreateList(name) { 196 | return Lists.put({ _id: name }) 197 | .then(result => ({ id: result.id, rev: result.rev })); 198 | } 199 | 200 | /** 201 | * 202 | * @param {string} name 203 | * @param {Array<[boolean, string]>} items 204 | */ 205 | export function ImportList(name, items) { 206 | return CreateList(name) 207 | .then((list) => Promise.all([BulkCreateListItems(list.id, items), list])) 208 | .then(([docs, list]) => { 209 | const errors = docs.filter(doc => doc.error) 210 | if (errors.length > 0) { 211 | console.warn(`Could not import the following docs`, errors); 212 | } 213 | return list; 214 | }) 215 | .catch(importListError => { 216 | console.warn({ importListError }); 217 | return Promise.reject(importListError.message); 218 | }); 219 | } 220 | 221 | /** 222 | * 223 | * @param {string} listId 224 | * @param {Array<[boolean, string]>} items 225 | * @return {Promise} 226 | */ 227 | function BulkCreateListItems(listId, items) { 228 | const toCreate = 229 | items.map(([isDone, name], index) => ({ 230 | _id: `${listId}:${index}:${Date.now()}`, 231 | name, 232 | isDone, 233 | listId 234 | })) 235 | return ListItems.bulkDocs(toCreate); 236 | } 237 | 238 | 239 | 240 | async function DeleteAllListItemsFromList(listId) { 241 | try { 242 | const queryAllResult = await ListItems.find({ 243 | selector: { listId }, 244 | use_index: '_design/mandadinddoclistid' 245 | }) 246 | console.log({ queryAllResult }); 247 | if (queryAllResult.docs && queryAllResult.docs.length > 0) { 248 | const docs = queryAllResult.docs.map(doc => ({ ...doc, _deleted: true })); 249 | const deleteResult = await ListItems.bulkDocs(docs) 250 | console.log({ deleteResult }); 251 | } 252 | return true; 253 | } catch (error) { 254 | console.warn({ DeleteAllListItemsFromListError: error }); 255 | return Promise.reject('Failed to Delete All Documents For List'); 256 | } 257 | } 258 | 259 | export async function DeleteList(listId, rev) { 260 | try { 261 | await DeleteAllListItemsFromList(listId) 262 | const deleteResult = await Lists.remove(listId, rev) 263 | console.log({ deleteResult }); 264 | } catch (deleteListError) { 265 | console.warn({ deleteListError }) 266 | return Promise.reject(deleteListError.message); 267 | } 268 | } 269 | 270 | function buildIndexQuery(listId, hideDone) { 271 | const selector = 272 | hideDone === false ? { listId } : { listId, isDone: false } 273 | 274 | return { 275 | fields: ['_id', '_rev', 'listId', 'isDone', 'name'], 276 | use_index: `_design/${hideDone ? 'mandadinddocisdone' : 'mandadinddoclistid'}`, 277 | selector, 278 | } 279 | } 280 | 281 | 282 | /** 283 | * 284 | * @param {string} listId 285 | * @param {boolean} hideDone 286 | */ 287 | export async function GetListItems(listId, hideDone) { 288 | try { 289 | const index = buildIndexQuery(listId, hideDone) 290 | const { docs } = await ListItems.find(index); 291 | return docs.map(({ _id, _rev, listId, isDone, name }) => 292 | ({ id: _id, rev: _rev, listId, isDone, name })); 293 | } catch (getListItemsError) { 294 | console.warn({ getListItemsError }) 295 | return Promise.reject(getListItemsError.message); 296 | } 297 | } 298 | /** 299 | * 300 | * @param {string} name 301 | * @returns {Promise} 302 | */ 303 | export function ListItemExists(listId, name) { 304 | return ListItems.find({ 305 | selector: { listId, name }, 306 | fields: ['name'], 307 | use_index: '_design/mandadinddoclistitemnameindex' 308 | }) 309 | .then(({ docs }) => docs.length > 0) 310 | .catch(listItemExistsError => { 311 | console.log({ listItemExistsError }); 312 | return Promise.reject(listItemExistsError.message); 313 | }); 314 | } 315 | 316 | export async function CreateListItem(listId, name) { 317 | try { 318 | const { id, ok } = await ListItems.put({ 319 | isDone: false, 320 | _id: `${listId}:${Date.now()}`, 321 | name, 322 | listId 323 | }); 324 | if (!ok) { return Promise.reject('Could not create document'); } 325 | const { _id, isDone, _rev, ...props } = await ListItems.get(id); 326 | return { id: _id, rev: _rev, listId: props.listId, name: props.name, isDone }; 327 | } catch (createListItemError) { 328 | console.warn({ createListItemError }) 329 | return Promise.reject(createListItemError.message); 330 | } 331 | } 332 | 333 | /** 334 | * 335 | * @param {ListItem & { _deleted?: boolean }} item 336 | * @return {Promise} 337 | */ 338 | export async function UpdateListItem(item) { 339 | try { 340 | const { id, rev, ...itemProps } = item 341 | const { ok, ...result } = await ListItems.put({ _id: id, _rev: rev, ...itemProps }) 342 | if (!ok) { return Promise.reject('Failed to update ListItem'); } 343 | return { ...item, ...result }; 344 | } catch (updateListItemError) { 345 | console.warn({ updateListItemError }); 346 | return Promise.reject(updateListItemError.message); 347 | } 348 | } 349 | 350 | /** 351 | * updates the document with the property `_deleted: true` to enable undo actions 352 | * @param {ListItem} item 353 | * @return {Promise} 354 | */ 355 | export function DeleteListItem(item) { 356 | return UpdateListItem({ ...item, _deleted: true }) 357 | .then(item => ({ ...item, _deleted: undefined })); 358 | } 359 | 360 | /** 361 | * 362 | * @param {string} listId 363 | * @returns {Promise} 364 | */ 365 | export function GetHideDone(listId) { 366 | return HideDone.get(listId) 367 | .then(({ hideDone }) => hideDone) 368 | .catch(getHideDoneError => { 369 | if (getHideDoneError.status === 404) { 370 | return false; 371 | } 372 | console.warn({ getHideDoneError }); 373 | return Promise.reject(getHideDoneError.message); 374 | }); 375 | } 376 | 377 | /** 378 | * 379 | * @param {string} listId 380 | * @param {boolean} hideDone 381 | * @returns {Promise} 382 | */ 383 | export function SaveHideDone(listId, hideDone) { 384 | return HideDone.get(listId) 385 | .then(({ _id, _rev }) => HideDone.put({ _id, _rev, hideDone })) 386 | .catch(saveHideDoneError => { 387 | if (saveHideDoneError.status === 404) { 388 | return HideDone.put({ _id: listId, hideDone }); 389 | } 390 | console.warn({ saveHideDoneError }); 391 | return Promise.reject(saveHideDoneError.message); 392 | }) 393 | .catch(saveHideDoneError => { 394 | console.warn({ saveHideDoneError }); 395 | return Promise.reject(saveHideDoneError.message); 396 | }); 397 | } -------------------------------------------------------------------------------- /src/Mandadin.Client/wwwroot/lib/paper.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";@import url("https://fonts.googleapis.com/css?family=Neucha|Patrick+Hand+SC");html{--primary:#41403e;--secondary:#0071de;--success:#86a361;--warning:#ddcd45;--danger:#a7342d;--muted:#868e96;--primary-light:#c1c0bd;--secondary-light:#deefff;--success-light:#d0dbc2;--warning-light:#f5f0c6;--danger-light:#f0cbc9;--muted-light:#e6e7e9;--primary-dark:#000;--secondary-dark:#000;--success-dark:#374427;--warning-dark:#746a15;--danger-dark:#000;--muted-dark:#313538;--primary-light-10:#5b5a57;--secondary-light-10:#128bff;--success-light-10:#9fb681;--warning-light-10:#e5d970;--danger-light-10:#cb453c;--muted-light-10:#a1a8ae;--primary-dark-10:#272625;--secondary-dark-10:#0057ab;--success-dark-10:#6c844d;--warning-dark-10:#cab925;--danger-dark-10:#7f2722;--muted-dark-10:#6c757d;--primary-shaded-50:#c1c0bd;--primary-shaded-70:#f2f2f2;--white-dark:rgba(0,0,0,0.03);--white-dark-light-80:hsla(0,0%,80%,0.03);--light-dark:rgba(0,0,0,0.7);--white:#fff;--main-background:#fff;--main-background-light:#fff;--primary-text:#272625;--secondary-text:#0057ab;--success-text:#6c844d;--warning-text:#cab925;--danger-text:#7f2722;--muted-text:#6c757d;--primary-inverse:#fff}html,html.dark{--black:#000;--shadow-color-regular:rgba(0,0,0,0.2);--shadow-color-hover:rgba(0,0,0,0.3)}html.dark{--primary:#fff;--secondary:#0071de;--success:#189418;--warning:#ddcd45;--danger:#ff8c86;--muted:#868e96;--primary-light:#fff;--secondary-light:#007ef8;--success-light:#1caa1c;--warning-light:#e1d35b;--danger-light:#ffa4a0;--muted-light:#949ba2;--primary-dark:grey;--secondary-dark:#000;--success-dark:#031003;--warning-dark:#746a15;--danger-dark:#a00800;--muted-dark:#313538;--primary-light-10:#fff;--secondary-light-10:#128bff;--success-light-10:#1fc01f;--warning-light-10:#e5d970;--danger-light-10:#ffbcb9;--muted-light-10:#a1a8ae;--primary-dark-10:#e6e6e6;--secondary-dark-10:#0057ab;--success-dark-10:#116811;--warning-dark-10:#cab925;--danger-dark-10:#ff5c53;--muted-dark-10:#6c757d;--primary-shaded-50:#343332;--primary-shaded-70:#2f2e2d;--white-dark:hsla(0,0%,100%,0.03);--white-dark-light-80:hsla(0,0%,100%,0.03);--light-dark:hsla(0,0%,100%,0.7);--white:#fff;--main-background:#41403e;--main-background-light:#c1c0bd;--primary-text:#41403e;--secondary-text:#0057ab;--success-text:#116811;--warning-text:#cab925;--danger-text:#ff5c53;--muted-text:#6c757d;--primary-inverse:#41403e}.text-primary{color:#41403e;color:var(--primary)}.background-primary{background-color:#41403e;background-color:var(--primary-light)}.text-secondary{color:#41403e;color:var(--secondary)}.background-secondary{background-color:#41403e;background-color:var(--secondary-light)}.text-success{color:#41403e;color:var(--success)}.background-success{background-color:#41403e;background-color:var(--success-light)}.text-warning{color:#41403e;color:var(--warning)}.background-warning{background-color:#41403e;background-color:var(--warning-light)}.text-danger{color:#41403e;color:var(--danger)}.background-danger{background-color:#41403e;background-color:var(--danger-light)}.text-muted{color:#41403e;color:var(--muted)}.background-muted{background-color:#41403e;background-color:var(--muted-light)} 2 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:inherit;font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}[hidden],template{display:none}html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}.container.container-xs{max-width:480px}.container.container-sm{max-width:768px}.container.container-md{max-width:992px}.container.container-lg{max-width:1200px}.section{margin-bottom:2rem;margin-top:1rem;word-wrap:break-word}.section:after{color:#8f8d89;content:"~~~";display:block;font-size:1.5rem;position:relative;text-align:center}hr{border:0}hr:after{color:#8f8d89;content:"~~~";display:block;font-size:1.5rem;position:relative;text-align:center;top:-.75rem}.paper{background-color:#41403e;background-color:var(--main-background);border:1px solid #c1c0bd;box-shadow:-1px 5px 35px -9px rgba(0,0,0,.2);margin-bottom:1rem;margin-top:1rem;padding:2rem}@media only screen and (max-width:480px){.paper{margin-bottom:0;margin-top:0;padding:1rem;width:100%}}.row{display:flex;flex-flow:row wrap;margin-bottom:1rem;margin-left:auto;margin-right:auto}.row.flex-right{justify-content:flex-end}.row.flex-center{justify-content:center}.row.flex-edges{justify-content:space-between}.row.flex-spaces{justify-content:space-around}.row.flex-top{align-items:flex-start}.row.flex-middle{align-items:center}.row.flex-bottom{align-items:flex-end}.col{padding:1rem}@media only screen and (max-width:768px){.col{flex:0 0 100%;max-width:100%}}.col-fill{flex:1 1 0;width:auto}@media only screen and (min-width:0){.col-1{flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-2{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-3{flex:0 0 25%;max-width:25%}.col-4{flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-5{flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-6{flex:0 0 50%;max-width:50%}.col-7{flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-8{flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-9{flex:0 0 75%;max-width:75%}.col-10{flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-11{flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-12{flex:0 0 100%;max-width:100%}}@media only screen and (min-width:480px){.xs-1{flex:0 0 8.3333333333%;max-width:8.3333333333%}.xs-2{flex:0 0 16.6666666667%;max-width:16.6666666667%}.xs-3{flex:0 0 25%;max-width:25%}.xs-4{flex:0 0 33.3333333333%;max-width:33.3333333333%}.xs-5{flex:0 0 41.6666666667%;max-width:41.6666666667%}.xs-6{flex:0 0 50%;max-width:50%}.xs-7{flex:0 0 58.3333333333%;max-width:58.3333333333%}.xs-8{flex:0 0 66.6666666667%;max-width:66.6666666667%}.xs-9{flex:0 0 75%;max-width:75%}.xs-10{flex:0 0 83.3333333333%;max-width:83.3333333333%}.xs-11{flex:0 0 91.6666666667%;max-width:91.6666666667%}.xs-12{flex:0 0 100%;max-width:100%}}@media only screen and (min-width:768px){.sm-1{flex:0 0 8.3333333333%;max-width:8.3333333333%}.sm-2{flex:0 0 16.6666666667%;max-width:16.6666666667%}.sm-3{flex:0 0 25%;max-width:25%}.sm-4{flex:0 0 33.3333333333%;max-width:33.3333333333%}.sm-5{flex:0 0 41.6666666667%;max-width:41.6666666667%}.sm-6{flex:0 0 50%;max-width:50%}.sm-7{flex:0 0 58.3333333333%;max-width:58.3333333333%}.sm-8{flex:0 0 66.6666666667%;max-width:66.6666666667%}.sm-9{flex:0 0 75%;max-width:75%}.sm-10{flex:0 0 83.3333333333%;max-width:83.3333333333%}.sm-11{flex:0 0 91.6666666667%;max-width:91.6666666667%}.sm-12{flex:0 0 100%;max-width:100%}}@media only screen and (min-width:992px){.md-1{flex:0 0 8.3333333333%;max-width:8.3333333333%}.md-2{flex:0 0 16.6666666667%;max-width:16.6666666667%}.md-3{flex:0 0 25%;max-width:25%}.md-4{flex:0 0 33.3333333333%;max-width:33.3333333333%}.md-5{flex:0 0 41.6666666667%;max-width:41.6666666667%}.md-6{flex:0 0 50%;max-width:50%}.md-7{flex:0 0 58.3333333333%;max-width:58.3333333333%}.md-8{flex:0 0 66.6666666667%;max-width:66.6666666667%}.md-9{flex:0 0 75%;max-width:75%}.md-10{flex:0 0 83.3333333333%;max-width:83.3333333333%}.md-11{flex:0 0 91.6666666667%;max-width:91.6666666667%}.md-12{flex:0 0 100%;max-width:100%}}@media only screen and (min-width:1200px){.lg-1{flex:0 0 8.3333333333%;max-width:8.3333333333%}.lg-2{flex:0 0 16.6666666667%;max-width:16.6666666667%}.lg-3{flex:0 0 25%;max-width:25%}.lg-4{flex:0 0 33.3333333333%;max-width:33.3333333333%}.lg-5{flex:0 0 41.6666666667%;max-width:41.6666666667%}.lg-6{flex:0 0 50%;max-width:50%}.lg-7{flex:0 0 58.3333333333%;max-width:58.3333333333%}.lg-8{flex:0 0 66.6666666667%;max-width:66.6666666667%}.lg-9{flex:0 0 75%;max-width:75%}.lg-10{flex:0 0 83.3333333333%;max-width:83.3333333333%}.lg-11{flex:0 0 91.6666666667%;max-width:91.6666666667%}.lg-12{flex:0 0 100%;max-width:100%}}.align-top{align-self:flex-start}.align-middle{align-self:center}.align-bottom{align-self:flex-end}.container{margin:0 auto;max-width:960px;position:relative;width:100%}@media only screen and (max-width:992px){.container{width:85%}}@media only screen and (max-width:480px){.container{width:90%}}code{color:#41403e;color:var(--secondary);background-color:#41403e;background-color:var(--primary-shaded-70)}code,kbd{border-radius:3px;font-size:80%;padding:2px 4px}kbd{color:#41403e;color:var(--primary-inverse);background-color:#41403e;background-color:var(--primary)}pre{background-color:#41403e;background-color:var(--primary-shaded-70);border-radius:3px;border:1px solid #41403e;border-color:var(--primary-shaded-50);font-size:80%;line-height:1.5;overflow-x:auto;padding:1em;word-break:break-all;word-wrap:break-word}pre,pre code{color:#41403e;color:var(--inverse-primary);display:block;white-space:pre}pre code{background:transparent;font-size:inherit;padding:initial}html{color:#41403e;color:var(--primary);font-size:20px}a,button,html,input,option,p,select,table,tbody,td,textarea,th,thead,tr{font-family:Neucha,sans-serif}h1,h2,h3,h4,h5,h6{font-family:Patrick Hand SC,sans-serif;font-weight:400}h1{font-size:4rem}h2{font-size:3rem}h3{font-size:2rem}h4{font-size:1.5rem}h5{font-size:1rem}h6{font-size:.8rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}img{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;border:2px solid #41403e;border-color:var(--primary);display:block;height:auto;max-width:100%}img.float-left{float:left;margin:1rem 1rem 1rem 0}img.float-right{float:right;margin:1rem 0 1rem 1rem}img.no-responsive{display:initial;height:auto;max-width:none}img.no-border{border:0;border-radius:0}ol{list-style-type:decimal}ol ol{list-style-type:upper-alpha}ol ol ol{list-style-type:upper-roman}ol ol ol ol{list-style-type:lower-alpha}ol ol ol ol ol{list-style-type:lower-roman}ul{list-style:none;margin-left:0}ul li:before{content:"-"}ul li{text-indent:-7px}ul li .badge,ul li [popover-bottom]:after,ul li [popover-left]:after,ul li [popover-right]:after,ul li [popover-top]:after{text-indent:0}ul li:before{left:-7px;position:relative}ul ul li:before{content:"+"}ul ul ul li:before{content:"~"}ul ul ul ul li:before{content:"⤍"}ul ul ul ul ul li:before{content:"⁎"}ul.inline li{display:inline;margin-left:5px}table{box-sizing:border-box;max-width:100%;overflow-x:auto;width:100%}@media only screen and (max-width:480px){table tbody tr td,table thead tr th{padding:2%}}table thead tr th{line-height:1.5;padding:8px;text-align:left;vertical-align:bottom}table tbody tr td{border-top:1px dashed #d9d9d8;line-height:1.5;padding:8px;vertical-align:top}table.table-hover tbody tr:hover{color:#41403e;color:var(--secondary)}table.table-alternating tbody tr:nth-of-type(2n){color:#82807c}.border{border:2px solid #41403e;border-color:var(--primary)}.border,.border-1,.child-borders>:nth-child(6n+1){border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px}.border-2,.child-borders>:nth-child(6n+2){border-bottom-left-radius:185px 25px;border-bottom-right-radius:20px 205px;border-top-left-radius:125px 25px;border-top-right-radius:10px 205px}.border-3,.child-borders>:nth-child(6n+3){border-bottom-left-radius:225px 15px;border-bottom-right-radius:15px 255px;border-top-left-radius:15px 225px;border-top-right-radius:255px 15px}.border-4,.child-borders>:nth-child(6n+4){border-bottom-left-radius:25px 115px;border-bottom-right-radius:155px 25px;border-top-left-radius:15px 225px;border-top-right-radius:25px 150px}.border-5,.child-borders>:nth-child(6n+5){border-bottom-left-radius:20px 115px;border-bottom-right-radius:15px 105px;border-top-left-radius:250px 15px;border-top-right-radius:25px 80px}.border-6,.child-borders>:nth-child(6n+6){border-bottom-left-radius:15px 225px;border-bottom-right-radius:20px 205px;border-top-left-radius:28px 125px;border-top-right-radius:100px 30px}.child-borders>*{border:2px solid #41403e;border-color:var(--primary)}.border-white{border-color:#41403e;border-color:var(--white)}.border-dotted{border-style:dotted}.border-dashed{border-style:dashed}.border-thick{border-width:5px}.border-primary{border-color:#41403e;border-color:var(--primary)}.border-secondary{border-color:#41403e;border-color:var(--secondary)}.border-success{border-color:#41403e;border-color:var(--success)}.border-warning{border-color:#41403e;border-color:var(--warning)}.border-danger{border-color:#41403e;border-color:var(--danger)}.border-muted{border-color:#41403e;border-color:var(--muted)}.shadow{transition:all 235ms ease 0s;box-shadow:15px 28px 25px -18px rgba(0,0,0,.2)}.shadow.shadow-large{transition:all 235ms ease 0s;box-shadow:20px 38px 34px -26px rgba(0,0,0,.2)}.shadow.shadow-small{transition:all 235ms ease 0s;box-shadow:10px 19px 17px -13px rgba(0,0,0,.2)}.shadow.shadow-hover:hover{transform:translate3d(0,2px,0);box-shadow:2px 8px 8px -5px rgba(0,0,0,.3)}.child-shadows-hover>*,.child-shadows>*{transition:all 235ms ease 0s;box-shadow:15px 28px 25px -18px rgba(0,0,0,.2)}.child-shadows-hover>:hover{transform:translate3d(0,2px,0);box-shadow:2px 8px 8px -5px rgba(0,0,0,.3)}.collapsible{display:flex;flex-direction:column}.collapsible:first-of-type{border-top:1px solid #41403e;border-top-color:var(--muted-light)}.collapsible .collapsible-body{background-color:#41403e;background-color:var(--white-dark-light-80);transition:all 235ms ease-in-out 0s;border-bottom:1px solid #41403e;border-bottom-color:var(--muted-light);margin:0;max-height:0;opacity:0;overflow:hidden;padding:0 .75rem}.collapsible input{display:none}.collapsible input:checked+label{color:#41403e;color:var(--primary)}.collapsible input[id^=collapsible]:checked~div.collapsible-body{margin:0;max-height:960px;opacity:1;padding:.75rem}.collapsible label{color:#41403e;color:var(--primary);border-bottom:1px solid #41403e;border-bottom-color:var(--muted-light);display:inline-block;font-weight:600;margin:0 0 -1px;padding:.75rem;text-align:center}.collapsible label:hover{color:#41403e;color:var(--muted);cursor:pointer}.alert{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;border:2px solid #41403e;border-color:var(--primary);margin-bottom:20px;padding:15px;width:100%}.alert.dismissible{transition:all 235ms ease-in-out 0s;display:flex;justify-content:space-between;max-height:48rem;overflow:hidden}.alert .btn-close{transition:all 235ms ease-in-out 0s;color:#41403e;color:var(--#41403e-light-10);cursor:pointer;margin-left:.75rem}.alert .btn-close:active,.alert .btn-close:focus,.alert .btn-close:hover{color:#41403e;color:var(--#41403e-dark-10)}.alert-primary{color:#41403e;color:var(--primary-text);background-color:#41403e;background-color:var(--primary-light);border-color:#41403e;border-color:var(--primary)}.alert-primary .btn-close{color:#41403e;color:var(--#41403e-light-10)}.alert-primary .btn-close:active,.alert-primary .btn-close:focus,.alert-primary .btn-close:hover{color:#41403e;color:var(--#41403e-dark-10)}.alert-secondary{color:#41403e;color:var(--secondary-text);background-color:#41403e;background-color:var(--secondary-light);border-color:#41403e;border-color:var(--secondary)}.alert-secondary .btn-close{color:#41403e;color:var(--#0071de-light-10)}.alert-secondary .btn-close:active,.alert-secondary .btn-close:focus,.alert-secondary .btn-close:hover{color:#41403e;color:var(--#0071de-dark-10)}.alert-success{color:#41403e;color:var(--success-text);background-color:#41403e;background-color:var(--success-light);border-color:#41403e;border-color:var(--success)}.alert-success .btn-close{color:#41403e;color:var(--#86a361-light-10)}.alert-success .btn-close:active,.alert-success .btn-close:focus,.alert-success .btn-close:hover{color:#41403e;color:var(--#86a361-dark-10)}.alert-warning{color:#41403e;color:var(--warning-text);background-color:#41403e;background-color:var(--warning-light);border-color:#41403e;border-color:var(--warning)}.alert-warning .btn-close{color:#41403e;color:var(--#ddcd45-light-10)}.alert-warning .btn-close:active,.alert-warning .btn-close:focus,.alert-warning .btn-close:hover{color:#41403e;color:var(--#ddcd45-dark-10)}.alert-danger{color:#41403e;color:var(--danger-text);background-color:#41403e;background-color:var(--danger-light);border-color:#41403e;border-color:var(--danger)}.alert-danger .btn-close{color:#41403e;color:var(--#a7342d-light-10)}.alert-danger .btn-close:active,.alert-danger .btn-close:focus,.alert-danger .btn-close:hover{color:#41403e;color:var(--#a7342d-dark-10)}.alert-muted{color:#41403e;color:var(--muted-text);background-color:#41403e;background-color:var(--muted-light);border-color:#41403e;border-color:var(--muted)}.alert-muted .btn-close{color:#41403e;color:var(--#868e96-light-10)}.alert-muted .btn-close:active,.alert-muted .btn-close:focus,.alert-muted .btn-close:hover{color:#41403e;color:var(--#868e96-dark-10)}.alert-state{display:none}.alert-state:checked+.dismissible{border-width:0;margin:0;max-height:0;opacity:0;padding-bottom:0;padding-top:0}article .article-title{font-size:3rem}article .article-meta{color:#41403e;color:var(--muted-text);font-size:15px}article .article-meta a{color:#41403e;color:var(--muted-text);background-image:none}article .article-meta a:hover{color:#41403e;color:var(--light-dark)}article .text-lead{font-size:30px;line-height:1.3;margin:35px}article button:not(:first-of-type){margin-left:2rem}@media only screen and (max-width:480px){article button:not(:first-of-type){margin-left:0}}article p{line-height:1.6}.badge{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;color:#41403e;color:var(--white);background-color:#41403e;background-color:var(--muted);border:2px solid transparent;display:inline-block;font-size:75%;font-weight:700;line-height:1;padding:.25em .4em;text-align:center;vertical-align:baseline;white-space:nowrap}.badge.primary{background-color:#41403e;background-color:var(--primary)}.badge.secondary{background-color:#41403e;background-color:var(--secondary)}.badge.success{background-color:#41403e;background-color:var(--success)}.badge.warning{background-color:#41403e;background-color:var(--warning)}.badge.danger{background-color:#41403e;background-color:var(--danger)}.badge.muted{background-color:#41403e;background-color:var(--muted)}ul.breadcrumb{list-style:none;padding:10px 16px}ul.breadcrumb li{display:inline;font-size:20px}ul.breadcrumb li:before{content:""}ul.breadcrumb li a{color:#41403e;color:var(--secondary);background-image:none;text-decoration:none}ul.breadcrumb li a:hover{text-decoration:underline}ul.breadcrumb li+li:before{content:"/ ";padding:8px}.paper-btn,[type=button],button{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;transition:all 235ms ease 0s;box-shadow:15px 28px 25px -18px rgba(0,0,0,.2);transition:all 235ms ease-in-out 0s;color:#41403e;color:var(--primary);background-color:#41403e;background-color:var(--main-background);align-self:center;background-image:none;border:2px solid #41403e;border-color:var(--primary);cursor:pointer;display:inline-block;font-size:1rem;outline:none;padding:.75rem}@media only screen and (max-width:520px){.paper-btn,[type=button],button{display:inline-block;margin:0 auto 1rem;text-align:center}}.paper-btn.btn-large,[type=button].btn-large,button.btn-large{transition:all 235ms ease 0s;box-shadow:20px 38px 34px -26px rgba(0,0,0,.2);font-size:2rem;padding:1rem}.paper-btn.btn-small,[type=button].btn-small,button.btn-small{transition:all 235ms ease 0s;box-shadow:10px 19px 17px -13px rgba(0,0,0,.2);font-size:.75rem;padding:.5rem}.paper-btn.btn-block,[type=button].btn-block,button.btn-block{display:block;width:100%}.paper-btn:hover,[type=button]:hover,button:hover{transform:translate3d(0,2px,0);box-shadow:2px 8px 8px -5px rgba(0,0,0,.3)}.paper-btn:focus,[type=button]:focus,button:focus{border:2px solid #41403e;border-color:var(--secondary);box-shadow:2px 8px 4px -6px rgba(0,0,0,.3)}.paper-btn:active,[type=button]:active,button:active{border-color:rgba(0,0,0,.2);transition:none}.paper-btn.disabled,.paper-btn[disabled],[type=button].disabled,[type=button][disabled],button.disabled,button[disabled]{cursor:not-allowed;opacity:.5}a{color:#41403e;color:var(--secondary);background-image:linear-gradient(5deg,transparent 65%,#0071de 80%,transparent 90%),linear-gradient(165deg,transparent 5%,#0071de 15%,transparent 25%),linear-gradient(165deg,transparent 45%,#0071de 55%,transparent 65%),linear-gradient(15deg,transparent 25%,#0071de 35%,transparent 50%);background-position:0 90%;background-repeat:repeat-x;background-size:4px 3px}a,a:visited{text-decoration:none}a:visited{color:#41403e;color:var(--primary)}.paper-btn.btn-primary,[type=button].btn-primary,button.btn-primary{color:#41403e;color:var(--primary-text);background-color:#41403e;background-color:var(--primary-light);border-color:#41403e;border-color:var(--primary)}.paper-btn.btn-primary:hover:active,[type=button].btn-primary:hover:active,button.btn-primary:hover:active{background-color:#a8a6a3}.paper-btn.btn-secondary,[type=button].btn-secondary,button.btn-secondary{color:#41403e;color:var(--secondary-text);background-color:#41403e;background-color:var(--secondary-light);border-color:#41403e;border-color:var(--secondary)}.paper-btn.btn-secondary:hover:active,[type=button].btn-secondary:hover:active,button.btn-secondary:hover:active{background-color:#abd6ff}.paper-btn.btn-success,[type=button].btn-success,button.btn-success{color:#41403e;color:var(--success-text);background-color:#41403e;background-color:var(--success-light);border-color:#41403e;border-color:var(--success)}.paper-btn.btn-success:hover:active,[type=button].btn-success:hover:active,button.btn-success:hover:active{background-color:#b7c9a1}.paper-btn.btn-warning,[type=button].btn-warning,button.btn-warning{color:#41403e;color:var(--warning-text);background-color:#41403e;background-color:var(--warning-light);border-color:#41403e;border-color:var(--warning)}.paper-btn.btn-warning:hover:active,[type=button].btn-warning:hover:active,button.btn-warning:hover:active{background-color:#ede49b}.paper-btn.btn-danger,[type=button].btn-danger,button.btn-danger{color:#41403e;color:var(--danger-text);background-color:#41403e;background-color:var(--danger-light);border-color:#41403e;border-color:var(--danger)}.paper-btn.btn-danger:hover:active,[type=button].btn-danger:hover:active,button.btn-danger:hover:active{background-color:#e6a5a1}.paper-btn.btn-muted,[type=button].btn-muted,button.btn-muted{color:#41403e;color:var(--muted-text);background-color:#41403e;background-color:var(--muted-light);border-color:#41403e;border-color:var(--muted)}.paper-btn.btn-muted:hover:active,[type=button].btn-muted:hover:active,button.btn-muted:hover:active{background-color:#caced1}.paper-btn.btn-primary-outline,[type=button].btn-primary-outline,button.btn-primary-outline{background-color:#fff;border-color:#a8a6a3;color:#41403e}.paper-btn.btn-primary-outline:hover,[type=button].btn-primary-outline:hover,button.btn-primary-outline:hover{background-color:#c1c0bd;border-color:#41403e}.paper-btn.btn-primary-outline:hover:active,[type=button].btn-primary-outline:hover:active,button.btn-primary-outline:hover:active{background-color:#a8a6a3}.paper-btn.btn-secondary-outline,[type=button].btn-secondary-outline,button.btn-secondary-outline{background-color:#fff;border-color:#abd6ff;color:#0057ab}.paper-btn.btn-secondary-outline:hover,[type=button].btn-secondary-outline:hover,button.btn-secondary-outline:hover{background-color:#deefff;border-color:#0071de}.paper-btn.btn-secondary-outline:hover:active,[type=button].btn-secondary-outline:hover:active,button.btn-secondary-outline:hover:active{background-color:#abd6ff}.paper-btn.btn-success-outline,[type=button].btn-success-outline,button.btn-success-outline{background-color:#fff;border-color:#b7c9a1;color:#6c844d}.paper-btn.btn-success-outline:hover,[type=button].btn-success-outline:hover,button.btn-success-outline:hover{background-color:#d0dbc2;border-color:#86a361}.paper-btn.btn-success-outline:hover:active,[type=button].btn-success-outline:hover:active,button.btn-success-outline:hover:active{background-color:#b7c9a1}.paper-btn.btn-warning-outline,[type=button].btn-warning-outline,button.btn-warning-outline{background-color:#fff;border-color:#ede49b;color:#cab925}.paper-btn.btn-warning-outline:hover,[type=button].btn-warning-outline:hover,button.btn-warning-outline:hover{background-color:#f5f0c6;border-color:#ddcd45}.paper-btn.btn-warning-outline:hover:active,[type=button].btn-warning-outline:hover:active,button.btn-warning-outline:hover:active{background-color:#ede49b}.paper-btn.btn-danger-outline,[type=button].btn-danger-outline,button.btn-danger-outline{background-color:#fff;border-color:#e6a5a1;color:#7f2722}.paper-btn.btn-danger-outline:hover,[type=button].btn-danger-outline:hover,button.btn-danger-outline:hover{background-color:#f0cbc9;border-color:#a7342d}.paper-btn.btn-danger-outline:hover:active,[type=button].btn-danger-outline:hover:active,button.btn-danger-outline:hover:active{background-color:#e6a5a1}.paper-btn.btn-muted-outline,[type=button].btn-muted-outline,button.btn-muted-outline{background-color:#fff;border-color:#caced1;color:#6c757d}.paper-btn.btn-muted-outline:hover,[type=button].btn-muted-outline:hover,button.btn-muted-outline:hover{background-color:#e6e7e9;border-color:#868e96}.paper-btn.btn-muted-outline:hover:active,[type=button].btn-muted-outline:hover:active,button.btn-muted-outline:hover:active{background-color:#caced1}.card{transition:all 235ms ease 0s;box-shadow:15px 28px 25px -18px rgba(0,0,0,.2);-webkit-backface-visibility:hidden;backface-visibility:hidden;border:2px solid #41403e;border-color:var(--muted-light);display:flex;flex-direction:column;position:relative;will-change:transform;word-wrap:break-word}.card:hover{transform:translate3d(0,2px,0);box-shadow:2px 8px 8px -5px rgba(0,0,0,.3)}.card .card-footer,.card .card-header{background-color:#41403e;background-color:var(--white-dark);border-color:#41403e;border-color:var(--muted-light);padding:.75rem 1.25rem}.card .card-header{border-bottom-style:solid;border-bottom-width:2px}.card .card-footer{border-top-style:solid;border-top-width:2px}.card .card-body{flex:1 1 auto;padding:1.25rem}.card .card-body .card-title,.card .card-body h4{margin-bottom:.5rem;margin-top:0}.card .card-body .card-subtitle,.card .card-body h5{color:#0071de;margin-bottom:.5rem;margin-top:0}.card .card-body .card-text,.card .card-body p{margin-bottom:1rem;margin-top:0}.card .card-body .card-link+.card-link,.card .card-body a+a{margin-left:1.25rem}.card .image-bottom,.card .image-top,.card img{border:0;border-radius:0}input,select,textarea{color:#41403e;color:var(--primary);background:transparent;border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;border:2px solid #41403e;border-color:var(--primary);display:block;font-size:1rem;outline:none;padding:.5rem}input:focus,select:focus,textarea:focus{border:2px solid #41403e;border-color:var(--secondary)}select{height:2.35rem}.disabled,input.disabled,input[disabled],select.disabled,select[disabled],textarea.disabled,textarea[disabled]{cursor:not-allowed;opacity:.5}.form-group{margin-bottom:1rem}.form-group>label,.form-group legend{display:inline-block;margin-bottom:.5rem}.form-group .input-block{width:100%}.form-group textarea{max-height:90vh;max-width:100%}.form-group textarea.no-resize{resize:none}.form-group .paper-check,.form-group .paper-radio{cursor:pointer;display:block;margin-bottom:.5rem}.form-group .paper-check input,.form-group .paper-radio input{border:0;height:1px;margin:-1px;opacity:0;overflow:hidden;padding:0;position:absolute;width:1px}.form-group .paper-check input+span,.form-group .paper-radio input+span{display:block}.form-group .paper-check input+span:before,.form-group .paper-radio input+span:before{border:2px solid #41403e;border-color:var(--primary);content:"";display:inline-block;height:1rem;margin-right:.75em;position:relative;vertical-align:-.25em;width:1rem}.form-group .paper-check input[type=radio]+span:before,.form-group .paper-radio input[type=radio]+span:before{border-bottom-left-radius:.7rem 1rem;border-bottom-right-radius:1rem .9rem;border-top-left-radius:1rem 1rem;border-top-right-radius:1rem .6rem}.form-group .paper-check input[type=radio]:checked+span:before,.form-group .paper-radio input[type=radio]:checked+span:before{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath fill='%230071de' d='M49.346 46.341c-3.79-2.005 3.698-10.294 7.984-8.89 8.713 2.852 4.352 20.922-4.901 20.269-4.684-.33-12.616-7.405-14.38-11.818-2.375-5.938 7.208-11.688 11.624-13.837 9.078-4.42 18.403-3.503 22.784 6.651 4.049 9.378 6.206 28.09-1.462 36.276-7.091 7.567-24.673 2.277-32.357-1.079-11.474-5.01-24.54-19.124-21.738-32.758 3.958-19.263 28.856-28.248 46.044-23.244 20.693 6.025 22.012 36.268 16.246 52.826-5.267 15.118-17.03 26.26-33.603 21.938-11.054-2.883-20.984-10.949-28.809-18.908C9.236 66.096 2.704 57.597 6.01 46.371c3.059-10.385 12.719-20.155 20.892-26.604C40.809 8.788 58.615 1.851 75.058 12.031c9.289 5.749 16.787 16.361 18.284 27.262.643 4.698.646 10.775-3.811 13.746'/%3E%3C/svg%3E") 0 no-repeat}.form-group .paper-check input[type=checkbox]+span:before,.form-group .paper-radio input[type=checkbox]+span:before{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px}.form-group .paper-check input[type=checkbox]:checked+span:before,.form-group .paper-radio input[type=checkbox]:checked+span:before{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath stroke='%230071de' stroke-width='16' d='M13 62c.61 1.6 1.304 2.304 1.757 2.757l2.076 2.076c.542.542 1.042 1.06 1.829 1.824.578.56 1.005.97 2.026 1.744.559.424 1.191.84 1.884 1.284 1.165.746 1.598 1.002 2.5 1.551.47.286 1.437.869 1.93 1.165.998.6 1.997 1.198 2.494 1.499.985.598 1.47.896 1.947 1.2 1.397.89 1.837 1.197 2.7 1.796.422.292 1.24.877 2.056 1.419a33.039 33.039 0 002.38 1.438c.744.409 1.451.758 2.378 1.226.761.383 1.55.828 2.407 1.41.731.497 1.496 1.083 2.279 1.258.355.08.147-.955.357-1.868.181-.787.982-1.214 1-2.079.02-.995.08-1.8.425-2.561.436-.96.54-1.668.797-2.682.188-.747.397-1.312.778-2.624.209-.718.415-1.486.708-2.28.155-.418.774-1.731 1.348-2.641.306-.484.65-.97 1.007-1.474.747-1.06 1.153-1.602 1.937-2.771.406-.606.803-1.235 1.205-1.877.407-.65.814-1.312 1.231-1.975.42-.668.834-1.343 1.73-2.648.448-.65.915-1.284 1.387-1.91.47-.623.947-1.236 1.422-1.846.94-1.21 1.861-2.409 2.303-3.01a84.919 84.919 0 002.46-3.543c1.106-1.685 1.441-2.236 1.777-2.771.328-.525.963-1.546 1.274-2.04a89.78 89.78 0 011.51-2.325c.591-.864 1.18-1.68 1.465-2.075.55-.761 1.317-1.823 1.779-2.49.439-.634.853-1.252 1.457-2.157.596-.891.965-1.468 1.515-2.23.584-.809 1.125-1.402 1.838-2.123.613-.62.451-1.483.704-2.347.257-.878.755-1.625 1-2.41.251-.803.763-1.394 1.332-2.254.546-.824.735-1.671 1.316-2.336.556-.636 1.386-1.226 1.859-1.9.508-.724.789-1.4 1.603-1.567l.712-.49' fill='none'/%3E%3C/svg%3E") 0 no-repeat}.form-group .paper-switch-2-label,.form-group .paper-switch-label{cursor:pointer;float:left}.form-group .paper-switch-label{margin:-6px 10px 0 0}.form-group .paper-switch-2-label{margin:0 10px 0 0}.form-group .paper-switch,.form-group .paper-switch-2{display:block;float:left;margin:0 10px 0 0;position:relative}.form-group .paper-switch-2 input,.form-group .paper-switch input{height:0;opacity:0;width:0}.form-group .paper-switch-2 input:checked+.paper-switch-slider,.form-group .paper-switch input:checked+.paper-switch-slider{background-color:#41403e;background-color:var(--success-light)}.form-group .paper-switch-2 input:checked+.paper-switch-slider:before,.form-group .paper-switch input:checked+.paper-switch-slider:before{transform:translateX(26px)}.form-group .paper-switch-2 input:focus+.paper-switch-slider,.form-group .paper-switch input:focus+.paper-switch-slider{box-shadow:0 0 3px #0071de}.form-group .paper-switch-2 .paper-switch-slider,.form-group .paper-switch .paper-switch-slider{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;border:2px solid #41403e;border-color:var(--primary);bottom:0;cursor:pointer;left:0;position:absolute;right:0;top:0;transition:.4s}.form-group .paper-switch-2 .paper-switch-slider:before,.form-group .paper-switch .paper-switch-slider:before{background:#41403e;background:var(--secondary);border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;content:"";left:4px;position:absolute;transition:.4s}.form-group .paper-switch-2 .paper-switch-slider.round,.form-group .paper-switch .paper-switch-slider.round{border-bottom-left-radius:.7rem 1rem;border-bottom-right-radius:1rem .9rem;border-top-left-radius:1rem 1rem;border-top-right-radius:1rem .6rem;border:2px solid #41403e;border-color:var(--primary)}.form-group .paper-switch-2 .paper-switch-slider.round:before,.form-group .paper-switch .paper-switch-slider.round:before{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath fill='%230071de' d='M49.346 46.341c-3.79-2.005 3.698-10.294 7.984-8.89 8.713 2.852 4.352 20.922-4.901 20.269-4.684-.33-12.616-7.405-14.38-11.818-2.375-5.938 7.208-11.688 11.624-13.837 9.078-4.42 18.403-3.503 22.784 6.651 4.049 9.378 6.206 28.09-1.462 36.276-7.091 7.567-24.673 2.277-32.357-1.079-11.474-5.01-24.54-19.124-21.738-32.758 3.958-19.263 28.856-28.248 46.044-23.244 20.693 6.025 22.012 36.268 16.246 52.826-5.267 15.118-17.03 26.26-33.603 21.938-11.054-2.883-20.984-10.949-28.809-18.908C9.236 66.096 2.704 57.597 6.01 46.371c3.059-10.385 12.719-20.155 20.892-26.604C40.809 8.788 58.615 1.851 75.058 12.031c9.289 5.749 16.787 16.361 18.284 27.262.643 4.698.646 10.775-3.811 13.746'/%3E%3C/svg%3E") 0 no-repeat;border-bottom-left-radius:.7rem 1rem;border-bottom-right-radius:1rem .9rem;border-top-left-radius:1rem 1rem;border-top-right-radius:1rem .6rem;left:4px}.form-group .paper-switch{height:12px;width:60px}.form-group .paper-switch .paper-switch-slider:before{bottom:-6px;height:20px;width:20px}.form-group .paper-switch .paper-switch-slider.round:before{bottom:-7px;height:23px;width:23px}.form-group .paper-switch-2{height:22px;width:50px}.form-group .paper-switch-2 .paper-switch-slider.round:before,.form-group .paper-switch-2 .paper-switch-slider:before{bottom:2px;height:14px;width:14px}.form-group .paper-switch-tile{cursor:pointer;display:block;float:left;height:80px;margin:40px 0 0 40px;perspective:1000px;position:relative;transform:translate(-50%,-50%);transform-style:preserve-3d;width:80px}.form-group .paper-switch-tile:hover .paper-switch-tile-card{box-shadow:2px 8px 4px -5px rgba(0,0,0,.2);transform:rotateX(30deg)}.form-group .paper-switch-tile:hover:checked+.paper-switch-tile-card{background-color:transparent;box-shadow:0 10px 15px -15px rgba(0,0,0,.9);transform:rotateX(150deg)}.form-group .paper-switch-tile input{display:none}.form-group .paper-switch-tile input:checked+.paper-switch-tile-card{transform:rotateX(180deg)}.form-group .paper-switch-tile-card{background-color:transparent;border-color:transparent;height:100%;position:relative;transform-style:preserve-3d;transition:all .6s;width:100%}.form-group .paper-switch-tile-card div{-webkit-backface-visibility:hidden;backface-visibility:hidden;box-shadow:2px 8px 8px -5px rgba(0,0,0,.3);height:100%;line-height:70px;position:absolute;text-align:center;width:100%}.form-group .paper-switch-tile-card .paper-switch-tile-card-back{transform:rotateX(180deg)}.form-group input[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-width:0;padding:0}.form-group input[type=range]::-webkit-slider-runnable-track{background:#41403e;background:var(--secondary);border-radius:18px;border:1px solid #41403e;border-color:var(--primary);box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;cursor:pointer;height:8px;margin:10px 0;width:100%}.form-group input[type=range]::-webkit-slider-thumb{background:#41403e;background:var(--white);-webkit-appearance:none;appearance:none;border-bottom-left-radius:.7rem 1rem;border-bottom-right-radius:1rem .9rem;border-top-left-radius:1rem 1rem;border-top-right-radius:1rem .6rem;border:1px solid #41403e;border-color:var(--primary);box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;cursor:pointer;height:36px;margin-top:-14px;width:16px}.form-group input[type=range]::-moz-range-track{background:#41403e;background:var(--secondary);border-color:#41403e;border-color:var(--primary);border-radius:18px;box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;cursor:pointer;height:8px;width:100%}.form-group input[type=range]::-moz-range-thumb{background:#41403e;background:var(--white);border-bottom-left-radius:.7rem 1rem;border-bottom-right-radius:1rem .9rem;border-top-left-radius:1rem 1rem;border-top-right-radius:1rem .6rem;border:1px solid #41403e;border-color:var(--primary);box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;cursor:pointer;height:36px;width:16px}.form-group input[type=range]::-ms-track{background:transparent;border-color:transparent;border-width:16px 0;color:transparent;cursor:pointer;height:8px;width:100%}.form-group input[type=range]::-ms-fill-lower,.form-group input[type=range]::-ms-fill-upper{background:#41403e;background:var(--secondary);border-radius:18px;border:1px solid #41403e;border-color:var(--primary);box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d}.form-group input[type=range]::-ms-thumb{background:#41403e;background:var(--white);border-bottom-left-radius:.7rem 1rem;border-bottom-right-radius:1rem .9rem;border-top-left-radius:1rem 1rem;border-top-right-radius:1rem .6rem;border:1px solid #41403e;box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;cursor:pointer;height:36px;width:16px}fieldset.form-group{border:0;padding:0}.modal{transition:opacity 235ms ease-in-out 0s;background:rgba(0,0,0,.6);flex:1 1 auto;opacity:0;position:fixed;text-align:left;visibility:hidden;word-wrap:break-word;z-index:12}.modal,.modal-bg{bottom:0;left:0;right:0;top:0}.modal-bg{cursor:pointer;position:absolute}.modal .modal-body{color:#41403e;color:var(--primary);background:#41403e;background:var(--main-background);transition:all 235ms ease-in-out 0s;-webkit-backface-visibility:hidden;backface-visibility:hidden;border:2px solid;left:50%;padding:1.25rem;position:absolute;top:0;transform:translate(-50%,-50%)}@media only screen and (max-width:768px){.modal .modal-body{box-sizing:border-box;width:90%}}.modal .btn-close{color:#41403e;color:var(--primary-light);transition:all 235ms ease-in-out 0s;cursor:pointer;font-size:30px;height:1.1rem;position:absolute;right:1rem;text-decoration:none;top:1rem;width:1.1rem}.modal .btn-close:active,.modal .btn-close:focus,.modal .btn-close:hover{color:#41403e;color:var(--primary)}.modal .modal-title,.modal h4{margin-bottom:.5rem;margin-top:0}.modal .modal-subtitle,.modal h5{color:#41403e;color:var(--secondary);margin-bottom:.5rem;margin-top:0}.modal .modal-text,.modal p{margin-bottom:1rem;margin-top:0}.modal .modal-link+.modal-link,.modal a+a{margin-left:1.25rem}.modal .paper-btn{background:#41403e;background:var(--main-background);display:inline-block;text-decoration:none}.modal .modal-link,.modal a{background-image:linear-gradient(5deg,transparent 65%,#0071de 80%,transparent 90%),linear-gradient(165deg,transparent 5%,#0071de 15%,transparent 25%),linear-gradient(165deg,transparent 45%,#0071de 55%,transparent 65%),linear-gradient(15deg,transparent 25%,#0071de 35%,transparent 50%);background-position:0 90%;background-repeat:repeat-x;background-size:4px 3px;cursor:pointer;text-decoration:none}.modal .modal-link:focus,.modal .modal-link:hover,.modal .modal-link:visited,.modal a:focus,.modal a:hover,.modal a:visited{color:#41403e;color:var(--primary);text-decoration:none}.modal-state{display:none}.modal-state:checked+.modal{opacity:1;visibility:visible}.modal-state:checked+.modal .modal-body{top:50%}[popover-bottom],[popover-left],[popover-right],[popover-top]{margin:24px;position:relative}[popover-bottom]:hover:after,[popover-left]:hover:after,[popover-right]:hover:after,[popover-top]:hover:after{opacity:1;transition:opacity .2s ease-out}[popover-bottom]:after,[popover-left]:after,[popover-right]:after,[popover-top]:after{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;transition:opacity 235ms ease-in-out 0s;background-color:#41403e;background-color:var(--light-dark);border:2px solid #41403e;border-color:var(--primary);color:#fff;font-size:.7em;left:50%;min-width:80px;opacity:0;padding:4px 2px;position:absolute;text-align:center;top:-6px;transform:translateX(-50%) translateY(-100%)}[popover-left]:before{left:0;margin-left:-12px;top:50%;transform:translateY(-50%) rotate(-90deg)}[popover-left]:after{content:attr(popover-left);left:0;margin-left:-8px;top:50%;transform:translateX(-100%) translateY(-50%)}[popover-right]:before{left:100%;margin-left:1px;top:50%;transform:translatey(-50%) rotate(90deg)}[popover-right]:after{content:attr(popover-right);left:100%;margin-left:8px;top:50%;transform:translateX(0) translateY(-50%)}[popover-top]:before{left:50%}[popover-top]:after{content:attr(popover-top);left:50%}[popover-bottom]:before{margin-top:8px;top:100%;transform:translateX(-50%) translatey(-100%) rotate(-180deg)}[popover-bottom]:after{content:attr(popover-bottom);margin-top:8px;top:100%;transform:translateX(-50%) translateY(0)}.progress{border-bottom-left-radius:20px 115px;border-bottom-right-radius:15px 105px;border-top-left-radius:250px 15px;border-top-right-radius:25px 80px;border:2px solid;box-shadow:2px 8px 8px -5px rgba(0,0,0,.3);height:1.2rem;overflow:hidden;width:100%}.progress .bar{border-bottom-left-radius:20px 115px;border-bottom-right-radius:15px 105px;border-top-left-radius:250px 15px;border-top-right-radius:25px 80px;transition:all 235ms ease-in-out 0s;background-color:#41403e;background-color:var(--primary-light);border-color:#41403e;border-color:var(--primary);border-right:2px solid;display:flex;flex-direction:column;font-size:.6rem;height:100%;justify-content:center;text-align:center;width:0}.progress .bar.striped{background:repeating-linear-gradient(45deg,#c1c0bd,#c1c0bd .25rem,#a8a6a3 0,#a8a6a3 .5rem)}.progress .bar.primary{background-color:#41403e;background-color:var(--primary-light)}.progress .bar.primary.striped{background:repeating-linear-gradient(45deg,#c1c0bd,#c1c0bd .25rem,#a8a6a3 0,#a8a6a3 .5rem)}.progress .bar.secondary{background-color:#41403e;background-color:var(--secondary-light)}.progress .bar.secondary.striped{background:repeating-linear-gradient(45deg,#deefff,#deefff .25rem,#abd6ff 0,#abd6ff .5rem)}.progress .bar.success{background-color:#41403e;background-color:var(--success-light)}.progress .bar.success.striped{background:repeating-linear-gradient(45deg,#d0dbc2,#d0dbc2 .25rem,#b7c9a1 0,#b7c9a1 .5rem)}.progress .bar.warning{background-color:#41403e;background-color:var(--warning-light)}.progress .bar.warning.striped{background:repeating-linear-gradient(45deg,#f5f0c6,#f5f0c6 .25rem,#ede49b 0,#ede49b .5rem)}.progress .bar.danger{background-color:#41403e;background-color:var(--danger-light)}.progress .bar.danger.striped{background:repeating-linear-gradient(45deg,#f0cbc9,#f0cbc9 .25rem,#e6a5a1 0,#e6a5a1 .5rem)}.progress .bar.muted{background-color:#41403e;background-color:var(--muted-light)}.progress .bar.muted.striped{background:repeating-linear-gradient(45deg,#e6e7e9,#e6e7e9 .25rem,#caced1 0,#caced1 .5rem)}.progress .bar.w-0{width:0}.progress .bar.w-1{width:1%}.progress .bar.w-2{width:2%}.progress .bar.w-3{width:3%}.progress .bar.w-4{width:4%}.progress .bar.w-5{width:5%}.progress .bar.w-6{width:6%}.progress .bar.w-7{width:7%}.progress .bar.w-8{width:8%}.progress .bar.w-9{width:9%}.progress .bar.w-10{width:10%}.progress .bar.w-11{width:11%}.progress .bar.w-12{width:12%}.progress .bar.w-13{width:13%}.progress .bar.w-14{width:14%}.progress .bar.w-15{width:15%}.progress .bar.w-16{width:16%}.progress .bar.w-17{width:17%}.progress .bar.w-18{width:18%}.progress .bar.w-19{width:19%}.progress .bar.w-20{width:20%}.progress .bar.w-21{width:21%}.progress .bar.w-22{width:22%}.progress .bar.w-23{width:23%}.progress .bar.w-24{width:24%}.progress .bar.w-25{width:25%}.progress .bar.w-26{width:26%}.progress .bar.w-27{width:27%}.progress .bar.w-28{width:28%}.progress .bar.w-29{width:29%}.progress .bar.w-30{width:30%}.progress .bar.w-31{width:31%}.progress .bar.w-32{width:32%}.progress .bar.w-33{width:33%}.progress .bar.w-34{width:34%}.progress .bar.w-35{width:35%}.progress .bar.w-36{width:36%}.progress .bar.w-37{width:37%}.progress .bar.w-38{width:38%}.progress .bar.w-39{width:39%}.progress .bar.w-40{width:40%}.progress .bar.w-41{width:41%}.progress .bar.w-42{width:42%}.progress .bar.w-43{width:43%}.progress .bar.w-44{width:44%}.progress .bar.w-45{width:45%}.progress .bar.w-46{width:46%}.progress .bar.w-47{width:47%}.progress .bar.w-48{width:48%}.progress .bar.w-49{width:49%}.progress .bar.w-50{width:50%}.progress .bar.w-51{width:51%}.progress .bar.w-52{width:52%}.progress .bar.w-53{width:53%}.progress .bar.w-54{width:54%}.progress .bar.w-55{width:55%}.progress .bar.w-56{width:56%}.progress .bar.w-57{width:57%}.progress .bar.w-58{width:58%}.progress .bar.w-59{width:59%}.progress .bar.w-60{width:60%}.progress .bar.w-61{width:61%}.progress .bar.w-62{width:62%}.progress .bar.w-63{width:63%}.progress .bar.w-64{width:64%}.progress .bar.w-65{width:65%}.progress .bar.w-66{width:66%}.progress .bar.w-67{width:67%}.progress .bar.w-68{width:68%}.progress .bar.w-69{width:69%}.progress .bar.w-70{width:70%}.progress .bar.w-71{width:71%}.progress .bar.w-72{width:72%}.progress .bar.w-73{width:73%}.progress .bar.w-74{width:74%}.progress .bar.w-75{width:75%}.progress .bar.w-76{width:76%}.progress .bar.w-77{width:77%}.progress .bar.w-78{width:78%}.progress .bar.w-79{width:79%}.progress .bar.w-80{width:80%}.progress .bar.w-81{width:81%}.progress .bar.w-82{width:82%}.progress .bar.w-83{width:83%}.progress .bar.w-84{width:84%}.progress .bar.w-85{width:85%}.progress .bar.w-86{width:86%}.progress .bar.w-87{width:87%}.progress .bar.w-88{width:88%}.progress .bar.w-89{width:89%}.progress .bar.w-90{width:90%}.progress .bar.w-91{width:91%}.progress .bar.w-92{width:92%}.progress .bar.w-93{width:93%}.progress .bar.w-94{width:94%}.progress .bar.w-95{width:95%}.progress .bar.w-96{width:96%}.progress .bar.w-97{width:97%}.progress .bar.w-98{width:98%}.progress .bar.w-99{width:99%}.progress .bar.w-100{width:100%}.progress .bar.w-0,.progress .bar.w-100{border-right:0}.tabs .content{display:none;flex-basis:100%;padding:.75rem 0 0}.tabs input{display:none}.tabs input:checked+label{color:#41403e;color:var(--primary);border-bottom:3px solid #41403e;border-bottom-color:var(--secondary)}.tabs input[id$=tab1]:checked~div[id$=content1],.tabs input[id$=tab2]:checked~div[id$=content2],.tabs input[id$=tab3]:checked~div[id$=content3],.tabs input[id$=tab4]:checked~div[id$=content4],.tabs input[id$=tab5]:checked~div[id$=content5]{display:block}.tabs label{color:#41403e;color:var(--primary-light);display:inline-block;font-weight:600;margin:0 0 -1px;padding:.75rem;text-align:center}.tabs label:hover{color:#41403e;color:var(--muted);cursor:pointer}.margin{margin:1rem}.margin-top{margin-top:1rem}.margin-top-large{margin-top:2rem}.margin-top-small{margin-top:.5rem}.margin-top-none{margin-top:0}.margin-right{margin-right:1rem}.margin-right-large{margin-right:2rem}.margin-right-small{margin-right:.5rem}.margin-right-none{margin-right:0}.margin-bottom{margin-bottom:1rem}.margin-bottom-large{margin-bottom:2rem}.margin-bottom-small{margin-bottom:.5rem}.margin-bottom-none{margin-bottom:0}.margin-left{margin-left:1rem}.margin-left-large{margin-left:2rem}.margin-left-small{margin-left:.5rem}.margin-left-none{margin-left:0}.margin-large{margin:2rem}.margin-small{margin:.5rem}.margin-none{margin:0}.padding{padding:1rem}.padding-top{padding-top:1rem}.padding-top-large{padding-top:2rem}.padding-top-small{padding-top:.5rem}.padding-top-none{padding-top:0}.padding-right{padding-right:1rem}.padding-right-large{padding-right:2rem}.padding-right-small{padding-right:.5rem}.padding-right-none{padding-right:0}.padding-bottom{padding-bottom:1rem}.padding-bottom-large{padding-bottom:2rem}.padding-bottom-small{padding-bottom:.5rem}.padding-bottom-none{padding-bottom:0}.padding-left{padding-left:1rem}.padding-left-large{padding-left:2rem}.padding-left-small{padding-left:.5rem}.padding-left-none{padding-left:0}.padding-large{padding:2rem}.padding-small{padding:.5rem}.padding-none{padding:0}nav{background-color:#41403e;background-color:var(--main-background);display:flex;padding:.3rem;position:relative;width:100%;z-index:100}@media only screen and (max-width:768px){nav{display:block}}nav .bar1,nav .bar2,nav .bar3{background-color:#41403e;background-color:var(--primary);border-color:#41403e;border-color:var(--primary);color:#41403e;color:var(--primary);border-bottom-left-radius:15px 5px;border-bottom-right-radius:15px 3px;margin:6px 0;transition:.4s;width:2rem}nav .collapsible input[id^=collapsible]:checked+button .bar1,nav .collapsible input[id^=collapsible]:checked+label .bar1{transform:rotate(-45deg) translate(-9px,7px)}nav .collapsible input[id^=collapsible]:checked+button .bar2,nav .collapsible input[id^=collapsible]:checked+label .bar2{opacity:0}nav .collapsible input[id^=collapsible]:checked+button .bar3,nav .collapsible input[id^=collapsible]:checked+label .bar3{transform:rotate(45deg) translate(-8px,-9px)}nav.split-nav{justify-content:space-between}nav.fixed{left:0;position:fixed;right:0;top:0}nav div{margin:0 1rem}nav ul.inline{margin-bottom:0;margin-top:10px;padding:0}nav ul.inline li{display:inline-block;margin:0 .5rem}@media only screen and (max-width:768px){nav ul.inline li{display:block;margin:1rem 0}}nav a{color:#41403e;color:var(--primary);background-image:none;border-bottom-left-radius:15px 3px;border-bottom-right-radius:15px 5px;border-bottom:5px solid #41403e;border-bottom-color:var(--primary);padding-bottom:.1rem}nav a:hover{border-color:#41403e;border-bottom:5px solid;border-color:var(--primary-light)}nav ul.inline li a{font-size:1.3rem}nav ul.inline li:before{content:""}@media only screen and (max-width:992px){nav ul{text-align:center}}nav .nav-brand h1,nav .nav-brand h2,nav .nav-brand h3,nav .nav-brand h4,nav .nav-brand h5,nav .nav-brand h6{margin:0 0 .2rem}@media only screen and (max-width:768px){nav .collapsible{width:100%}}nav .collapsible input[id^=collapsible]:checked~div.collapsible-body{margin:0;max-height:960px;opacity:1;padding:0}nav .collapsible .collapsible-body,nav .collapsible:first-of-type{border:0}@media only screen and (min-width:769px){nav .collapsible .collapsible-body,nav .collapsible:first-of-type{display:contents}}nav div.collapsible-body{padding:none}nav .collapsible label{border-bottom-left-radius:15px 255px;border-bottom-right-radius:225px 15px;border-top-left-radius:255px 15px;border-top-right-radius:15px 225px;border:2px solid #41403e;border-color:var(--primary)}nav .collapsible>button{border:0}nav .collapsible>button,nav .collapsible>label{background-color:#41403e;background-color:var(--main-background);display:none;font-size:.5rem;margin-right:1rem;padding:.25rem;position:absolute;right:0;top:.2rem}@media only screen and (max-width:768px){nav .collapsible>button,nav .collapsible>label{display:block}} --------------------------------------------------------------------------------