├── .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 |
--------------------------------------------------------------------------------
/src/Mandadin.Client/wwwroot/icons/check.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/Mandadin.Client/wwwroot/icons/close.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Mandadin.Client/wwwroot/icons/copy.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Mandadin.Client/wwwroot/icons/save.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/Mandadin.Client/wwwroot/icons/clipboard.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
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}}
--------------------------------------------------------------------------------