Initial commit after a bunch of development.

main
Zed A. Shaw 2 years ago
parent b6865e5e32
commit 9d3d169b22
  1. 277
      .eslintrc.json
  2. 23
      .gitignore
  3. 62
      README.md
  4. 13
      admin/App.svelte
  5. 52
      admin/Header.svelte
  6. 48
      admin/Layout.svelte
  7. 116
      admin/bando/Bandolier.svelte
  8. 224
      admin/bando/Components.svelte
  9. 152
      admin/bando/Djenterator.svelte
  10. 194
      admin/bando/IconFinder.svelte
  11. 21
      admin/bando/demos/Accordion.svelte
  12. 7
      admin/bando/demos/Accordion.svelte.md
  13. 26
      admin/bando/demos/AspectRatio.svelte
  14. 12
      admin/bando/demos/AspectRatio.svelte.md
  15. 51
      admin/bando/demos/Badge.svelte
  16. 3
      admin/bando/demos/Badge.svelte.md
  17. 25
      admin/bando/demos/ButtonGroup.svelte
  18. 1
      admin/bando/demos/ButtonGroup.svelte.md
  19. 27
      admin/bando/demos/Calendar.svelte
  20. 1
      admin/bando/demos/Calendar.svelte.md
  21. 37
      admin/bando/demos/Callout.svelte
  22. 8
      admin/bando/demos/Callout.svelte.md
  23. 46
      admin/bando/demos/Cards.svelte
  24. 1
      admin/bando/demos/Cards.svelte.md
  25. 25
      admin/bando/demos/Carousel.svelte
  26. 1
      admin/bando/demos/Carousel.svelte.md
  27. 14
      admin/bando/demos/Chat.svelte
  28. 1
      admin/bando/demos/Chat.svelte.md
  29. 53
      admin/bando/demos/Code.svelte
  30. 25
      admin/bando/demos/Code.svelte.md
  31. 16
      admin/bando/demos/Countdown.svelte
  32. 1
      admin/bando/demos/Countdown.svelte.md
  33. 6
      admin/bando/demos/Darkmode.svelte
  34. 21
      admin/bando/demos/Darkmode.svelte.md
  35. 71
      admin/bando/demos/DataTable.svelte
  36. 3
      admin/bando/demos/DataTable.svelte.md
  37. 252
      admin/bando/demos/FairPay.svelte
  38. 16
      admin/bando/demos/FairPay.svelte.md
  39. 52
      admin/bando/demos/Flipper.svelte
  40. 13
      admin/bando/demos/Flipper.svelte.md
  41. 78
      admin/bando/demos/Form.svelte
  42. 10
      admin/bando/demos/Form.svelte.md
  43. 10
      admin/bando/demos/HLSVideo.svelte
  44. 63
      admin/bando/demos/Icon.svelte
  45. 5
      admin/bando/demos/Icon.svelte.md
  46. 56
      admin/bando/demos/IconImage.svelte
  47. 27
      admin/bando/demos/LiveStream.svelte
  48. 5
      admin/bando/demos/LiveStream.svelte.md
  49. 17
      admin/bando/demos/LoggedIn.svelte
  50. 16
      admin/bando/demos/LoggedIn.svelte.md
  51. 13
      admin/bando/demos/Login.svelte
  52. 5
      admin/bando/demos/Login.svelte.md
  53. 51
      admin/bando/demos/Markdown.svelte
  54. 32
      admin/bando/demos/Modal.svelte
  55. 8
      admin/bando/demos/Modal.svelte.md
  56. 42
      admin/bando/demos/OGPreview.svelte
  57. 32
      admin/bando/demos/OGPreview.svelte.md
  58. 33
      admin/bando/demos/Pagination.svelte
  59. 1
      admin/bando/demos/Pagination.svelte.md
  60. 6
      admin/bando/demos/Panels.svelte
  61. 21
      admin/bando/demos/PlaceHolder.svelte
  62. 3
      admin/bando/demos/PlaceHolder.svelte.md
  63. 33
      admin/bando/demos/Progress.svelte
  64. 13
      admin/bando/demos/Progress.svelte.md
  65. 26
      admin/bando/demos/Sidebar.svelte
  66. 6
      admin/bando/demos/Sidebar.svelte.md
  67. 35
      admin/bando/demos/SidebarCSS.svelte
  68. 3
      admin/bando/demos/SidebarCSS.svelte.md
  69. 15
      admin/bando/demos/SnapImage.svelte
  70. 29
      admin/bando/demos/SnapImage.svelte.md
  71. 13
      admin/bando/demos/Spinner.svelte
  72. 16
      admin/bando/demos/Spinner.svelte.md
  73. 39
      admin/bando/demos/StackLayer.svelte
  74. 25
      admin/bando/demos/StackLayer.svelte.md
  75. 48
      admin/bando/demos/Switch.svelte
  76. 4
      admin/bando/demos/Switch.svelte.md
  77. 22
      admin/bando/demos/Tabs.svelte
  78. 5
      admin/bando/demos/Tabs.svelte.md
  79. 32
      admin/bando/demos/Tiles.svelte
  80. 1
      admin/bando/demos/Tiles.svelte.md
  81. 35
      admin/bando/demos/Toast.svelte
  82. 4
      admin/bando/demos/Toast.svelte.md
  83. 19
      admin/bando/demos/Toastier.svelte
  84. 21
      admin/bando/demos/Toastier.svelte.md
  85. 66
      admin/bando/demos/Tooltip.svelte
  86. 12
      admin/bando/demos/Tooltip.svelte.md
  87. 16
      admin/bando/demos/Video.svelte
  88. 5
      admin/bando/demos/Video.svelte.md
  89. 23
      admin/bando/demos/WTVideo.svelte
  90. 24
      admin/bando/demos/WTVideo.svelte.md
  91. 10
      admin/main.js
  92. 66
      admin/pages/Create.svelte
  93. 286
      admin/pages/DocsBrowser.svelte
  94. 84
      admin/pages/EmailConfig.svelte
  95. 147
      admin/pages/EmailDNS.svelte
  96. 61
      admin/pages/EmailSend.svelte
  97. 103
      admin/pages/Errors.svelte
  98. 104
      admin/pages/Home.svelte
  99. 151
      admin/pages/ReadUpdate.svelte
  100. 117
      admin/pages/Routes.svelte
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,277 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"plugins": ["svelte3"],
"overrides": [
{"files": "*.svelte", "processor": "svelte3/svelte3"}
],
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "module"
},
"rules": {
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": [ "warn", "never" ],
"array-callback-return": "error",
"array-element-newline": "off",
"arrow-body-style": "off",
"arrow-parens": "off",
"arrow-spacing": [
"error",
{
"after": true,
"before": true
}
],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": [
"error",
"1tbs"
],
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": "off",
"comma-spacing": [
"warn",
{
"after": true,
"before": false
}
],
"comma-style": [
"error",
"last"
],
"complexity": "error",
"computed-property-spacing": [
"error",
"never"
],
"consistent-return": "error",
"consistent-this": "error",
"curly": "off",
"default-case": "error",
"dot-location": "off",
"dot-notation": "off",
"eol-last": "error",
"eqeqeq": "off",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "error",
"func-style": [
"error",
"expression"
],
"function-paren-newline": "off",
"generator-star-spacing": "error",
"global-require": "off",
"guard-for-in": "warn",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": [
"error",
"beside"
],
"indent": "off",
"indent-legacy": "off",
"init-declarations": "warn",
"jsx-quotes": "error",
"key-spacing": "warn",
"keyword-spacing": "off",
"line-comment-position": "off",
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "off",
"lines-around-directive": "error",
"lines-between-class-members": "error",
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": "off",
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "error",
"multiline-comment-style": "off",
"new-cap": ["off"],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "error",
"no-array-constructor": "error",
"no-async-promise-executor": "error",
"no-await-in-loop": "off",
"no-bitwise": "error",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "error",
"no-confusing-arrow": "error",
"no-console": "warn",
"no-continue": "error",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "off",
"no-empty-function": "off",
"no-eq-null": "off",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-extra-parens": "off",
"no-floating-decimal": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "off",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-misleading-character-class": "error",
"no-mixed-operators": "error",
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "off",
"no-multi-str": "error",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "error",
"no-negated-condition": "off",
"no-negated-in-lhs": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-path-concat": "error",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "off",
"no-process-exit": "off",
"no-proto": "error",
"no-prototype-builtins": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "warn",
"no-return-await": "off",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": "off",
"no-shadow-restricted-names": "error",
"no-spaced-func": "error",
"no-sync": "warn",
"no-tabs": "off",
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
"no-trailing-spaces": "off",
"no-undef-init": "off",
"no-undefined": "off",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": ["error", {"functions": true, "classes": false}],
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": "error",
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"no-with": "error",
"nonblock-statement-body-position": "error",
"object-curly-newline": "off",
"object-curly-spacing": "off",
"object-shorthand": "warn",
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "error",
"operator-linebreak": "error",
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "error",
"prefer-const": "off",
"prefer-destructuring": "off",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-reflect": "off",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"quote-props": "off",
"quotes": "off",
"radix": "error",
"require-atomic-updates": "warn",
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
"rest-spread-spacing": "error",
"semi": "off",
"semi-spacing": "error",
"semi-style": [
"error",
"last"
],
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "error",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": [
"error",
"never"
],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": [
"error",
"always"
],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": [ "off", "never" ],
"template-tag-spacing": "error",
"unicode-bom": [
"error",
"never"
],
"valid-jsdoc": "error",
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "error",
"yield-star-spacing": "error",
"yoda": [
"error",
"never"
]
}
}

23
.gitignore vendored

@ -0,0 +1,23 @@
/node_modules/
/public/
/rendered/build/
/rendered/public/
.*.sw*
.DS_Store
*.sqlite3*
debug/
static/thumbs
static/videos
secrets/*
coverage/
.coverage
static/module
client/config.js
emails/config.js
media
tests/fixtures
rendered/wip
junk/
static/images/sample_video.mp4
static/js/webtorrent.debug.js

@ -1,3 +1,61 @@
# bandolier-website
# Introduction
This is the code that runs https://bandolier.learnjsthehardway.com/ for you to review. It uses the [bandolier-template](https://git.learnjsthehardway.com/learn-javascript-the-hard-way/bandolier-template) to create the documentation for the project.
The Bandolier (aka `bando`) is an educational web framework featured in the [Learn JavaScript the Hard Way](https://learnjsthehardway.com) course. The Bandolier contains all of the features a full stack developer would need to learn, but with smaller easier to understand implementations that are fully visible in the project. It includes implementations of:
* JSON API backends.
* Multi-page and Single-page web UIs.
* Queues for offloading processing.
* Video support with HLS, HTML, and WebTorrent Video.
* Built-in Database administration and a simple ORM.
* Authentication with passport.
* Websockets with socket.io.
* Pre-made CSS components and a simple CSS design method for non-designers.
* Unit testing and UI test automation.
* Automation commands to automate the boring tasks.
* Helpful video processing in JavaScript.
* Template generators to get started with code.
* Full but simple database administrator in the browser.
* Basic [Discord](https://discord.com) integration.
* Custom documentation generation for APIs using [acorn](https://github.com/acornjs/acorn).
* Includes common things you need like [lucide](lucide.dev) icons, Email DNS testing, web log analysis, in an easy to extend administrator tool.
* All implemented in reasonably sized pieces of code you can study on your own.
You can think of `bando` as a belt full of simple useful web development code and practices that help you learn how to get things done. The idea is that everything in here is "similar" to features in other full stack web frameworks, but simplified so you can study them. Once you learn what's here you should be able to learn most other features in other frameworks.
## Educational and Capable
Even though The `bando` is designed to be studied it doesn't mean it's useless. It's taken from real code I use for all of my websites now, and contains nearly everything you'll find in most any other framework. It's designed to be as performant, secure, and simple as possible. While it might not have every feature conceivable, it does have the ones you need to make most websites.
It also features a lot of [custom components](/client/#/bando/components/) that you don't find in other web frameworks. You'll find examples of handling [HLS Video Streaming](/client/#/bando/components/HLSVideo/), [Fast Blurred Image Loading](/client/#/bando/components/SnapImage/), [ffmpeg video compression](/client/#/docs/commands/convert.js), [Passport.js authentication](/client/#/docs/lib/auth.js), and much more. So while each feature doesn't cover every conceivable edge case, the framework does include a huge number of things everyone should learn for modern web development.
## 80% Done For You
A lot of the important things are also done for you while still letting you change them if you need to. For example, you shouldn't have to figure out password based authentication and encrypted password storage in 2023. So many other frameworks make people reinvent authentication when it's a completely solved problem and also a delicate thing to get right. In the `bando` authentication is already implemented and uses standard `Passport.js` with `bcrypt` passport storage, since that's the most recommended setup in 2023. If you want to use something else--say `scrypt`--then you can base your changes on my simple implementation and use the tests I have, rather than make everything from scratch.
Another example is payments. The `bando` contains working production code for [Paypal](https://paypal.com), [Stripe](https://stripe.com), and [BTCPayServer](https://btcpayserver.org). If you want to support something else then it should be easy to study my implementations and then write your own.
In general, the norm with `bando` is it contains most of the mundane things you might need to get started, and then replace later when you have a need for more features.
## You Can Change or Remove Almost Everything
The `bando` is designed for you to learn new technologies without having to create an entire web application from scratch. If you want to try [Actix](https://actix.rs) as a backend JSON server then just make it return the same JSON on the same URLs in `api/` and it should work. Don't like the ORM? You could probably replace it fairly easily. Want to learn how to use React? Just start your own React app in a new directory, and slowly bring over what you need from `client/` but in React form. Don't like my CSS? All of the HTML is very simple and semantic, so you can craft almost any classless CSS you want. Converting to something like [bootstrap](https://getbootstrap.com) would be some work, but you can use the `client/components` as a list of things to replicate in your version. Probably the most difficult thing to replace is the is the `client/` directory, since `admin/` and `rendered/` use code from `client/components`, but if you really want to learn a front-end framework then replicating all of the components in `client/components` would be an incredible way to learn.
The reason this works is because the code in `api/` doesn't really depend on anything in `client/` or `admin/`. If it uses something in `queue/` it's through a library so you can replace it easily, or possibly even use the [Bull queue](https://github.com/OptimalBits/bull) from your favorite backend language. Bull uses redis, and you could probably replicate it. There's also a large test suite in `tests/` that you can keep running to make sure your changes are working.
Finally, all of the code is reasonably sized. Most files are about 100 - 500 lines of code with not too much convolution or deep references. You could probably pick a random file and spend a few hours to study it. Many of the source files also teach a different technique. For example, the [client/fsm.js](/client/#/docs/client/fsm.js) file implements a simple Finite State Machine, which is used to make HLS video work in [client/components/HLSVideo.svelte](/client/#/bando/components/HLSVideo/). Then in [client/components/WTVideo.svelte](/client/#/bando/components/WTVideo/) there's more traditional callback style for handling WebTorrent videos so you can learn both techniques.
Since all of this code is right there, and there's lots of tests, you can feel free to experiment while you study it. When you want to use it for something real, you can use what's there, and improve what you need for your application.
## Current Status
This is a first rough ALPHA release to get the project published and get some victi`^H^H^H^H^H`...users testing it and telling me how it works so far. You aren't expected to be able to figure it out since I haven't completely documented it, but you should feel free to explore the code, study what's there, and see what you can do. The next release will have reference documentation and more code cleaning, then after that videos demonstrating the important features and showing how to use the tools included.
After that, further development will be focused on supporting the [Learn JavaScript the Hard Way](https://learnjsthehardway.com) course where students will use `bando` to create their own projects, and also learn how to implement the important elements on this stack. Once the course is done I'll start writing more public documentation for the framework as well as more demonstrations of using it to implement common websites.
## About `bandolier-template`
The [bandolier-template](https://git.learnjsthehardway.com/learn-javascript-the-hard-way/bandolier-template) is the template project that's checked out and configured when you run the `npx bando-up` command from [ljsthw-bandolier](https://git.learnjsthehardway.com/learn-javascript-the-hard-way/ljsthw-bandolier#ljsthw-bandolier). This is where the code really lives. The [Quick Start](/client/#/docs/quick-start/) will show you how to install everything and get a few first tasks done to test it out.
## About `bandolier-website`
Th [bandolier-website](https://git.learnjsthehardway.com/learn-javascript-the-hard-way/bandolier-website) repository is the code for the [bando.learnjsthehardway.com](https://bando.learnjsthehardway.com) website. This demonstration site allows access to some `/admin/` features such as documentation, routes browsing, and components browsing, but doesn't allow access to more sensitive things like the database. This is to demonstrate the split between `/client/` and `/admin/` for people curious about how they work.

@ -0,0 +1,13 @@
<script>
import Router from 'svelte-spa-router';
import routes from '$/admin/routes.js';
import Bandolier from '$/admin/bando/Bandolier.svelte';
import Reloader from "$/client/components/Reloader.svelte";
</script>
<Router {routes}/>
{#if process.env.DANGER_ADMIN === "1"}
<Reloader />
<Bandolier shown={ false }/>
{/if}

@ -0,0 +1,52 @@
<script>
import { logout_user } from '$/client/api.js';
import Icon from '$/client/components/Icon.svelte';
import Darkmode from '$/client/components/Darkmode.svelte';
import {link} from 'svelte-spa-router';
import { user } from "$/client/stores.js";
import { register_enabled } from "$/client/config.js";
export let fixed = false;
</script>
<header class:fixed>
<nav>
{#if $user.authenticated}
<a href="/client/#/"><Icon name="home" tooltip="Back to app." size="36" /></a>
<ul>
<li><a href="/" on:click|preventDefault={ logout_user } data-testid="logout-link"><Icon name="log-out" tooltip="Log out."/></a></li>
<li><a data-testid="user-profile-button" href="/profile/" use:link><Icon name="settings" tooltip="Settings."/></a></li>
<li><a href="/feed.rss"><Icon name="rss" tooltip="RSS Feed." tip_position="bottom-left" /></a></li>
<li><a href="https://learnjsthehardway.com/blog/"><Icon name="book" tooltip="Blog posts." /></a></li>
{#if $user.admin }
<li><a href="/admin/#/"><Icon name="keyboard" tooltip="Admin Dashboard."/></a></li>
{/if}
<li><a href="/" on:click|preventDefault={ logout_user } data-testid="logout-link"><Icon name="log-out" tooltip="Log out
."/></a></li>
<li><Darkmode /></li>
</ul>
{:else}
<!-- don't use:link because we want them to go to the landing page if they aren't logged in. -->
<a href="/"><Icon name="home" size="36" /></a>
<ul>
{#if register_enabled}
<li>
<a id="register-button" href="/register/top/">
<span class="mobile">
<Icon name="dollar-sign" tooltip="Register."/>
</span>
<span class="mobile-hide">
<button type="button">Register</button>
</span>
</a>
</li>
{/if}
<li><a href="/login/" use:link><Icon name="log-in" tooltip="Log in." /></a></li>
<li><a href="https://learnjsthehardway.com/blog/"><Icon name="book" tooltip="Blog posts." /></a></li>
<li><a href="/live/" use:link><Icon name="video" tooltip="Livestreams." /></a></li>
<li><Darkmode /></li>
</ul>
{/if}
</nav>
</header>

@ -0,0 +1,48 @@
<script>
import LoggedIn from '$/client/components/LoggedIn.svelte';
import Spinner from '$/client/components/Spinner.svelte';
import Footer from '$/client/Footer.svelte';
import Header from '$/admin/Header.svelte';
export let fixed = false;
export let footer = true;
export let header = true;
export let authenticated = false;
export let testid = "page";
export let centered = false;
export let fullscreen = false;
export let fullwidth = false;
export let auth_optional = false;
export let horizontal = false;
</script>
{#if authenticated || auth_optional}
{#if header}
<Header fixed={fixed}/>
{/if}
<LoggedIn optional={ auth_optional } redirect="/client/#/login" show_required_page={ false }>
<main class:horizontal class:fullwidth class:fullscreen class:centered slot="yes" data-testid={ testid }>
<slot></slot>
</main>
<main class:horizontal class:fullwidth class:fullscreen class:centered slot="no" data-testid={ testid }>
<Spinner color="var(--value8)" />
</main>
</LoggedIn>
{#if footer}
<Footer />
{/if}
{:else}
{#if header}
<Header fixed={fixed}/>
{/if}
<main class:horizontal class:fullwidth class:fullscreen class:centered data-testid={ testid }>
<slot></slot>
</main>
{#if footer}
<Footer />
{/if}
{/if}

@ -0,0 +1,116 @@
<script>
import Icon from '$/client/components/Icon.svelte';
import { onMount } from 'svelte';
import { fade } from "svelte/transition";
import { log } from "$/client/logging.js";
import api from '$/client/api.js';
export let shown = true;
let errors = [];
$: has_errors = errors.length > 0;
const rephresh = async () => {
let [status, data] = await api.get('/api/devtools/info');
if(status === 200) {
errors = data.errors;
log.debug("errors", errors);
} else {
log.error("Failed getting /api/devtools/devinfo", status, data);
}
}
const handle_keypress = (event) => {
if(event.ctrlKey && event.altKey) {
if(event.key == "b" || event.keyCode == 66) {
rephresh();
shown = !shown;
}
} else if(event.key === "Escape") {
shown = false;
}
}
onMount(() => rephresh());
</script>
<svelte:window on:keydown={ handle_keypress } />
<style>
bando {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: repeat(5, 1fr);
grid-gap: 0.5rem;
width: 450px;
position: fixed;
bottom: 0;
right: 1rem;
background-color: var(--red);
opacity: 1;
z-index: 10000;
padding: 1rem;
color: var(--value9);
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
bando#errors {
display: flex;
align-items: start;
justify-content: start;
}
bando#errors ul {
margin: 0px;
padding: 0.3em;
list-style-type: none;
}
bando a {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--value9);
width: min-content;
}
</style>
{#if shown}
<bando transition:fade|local>
<a href="/admin/#/icons/">
<Icon name="feather" light={ true } size="64" />
<span>Icons</span>
</a>
<a href="/admin/#/bando/components/">
<Icon name="layout" light={ true } size="64" />
<span>Library</span>
</a>
<a href="/admin/#/djenterator/">
<Icon name="edit" light={ true } size="64" />
<span>Templates</span>
</a>
<a href="/admin/#/routes/">
<Icon name="code" light={ true } size="64" />
<span>Routes</span>
</a>
<a href="/admin/#/errors/">
<Icon name="alert-circle" light={ true } size="64" />
<span>Errors</span>
</a>
</bando>
{:else if has_errors}
<bando id="errors" transition:fade|local>
<a href="/admin/#/errors/">
<Icon name="alert-circle" light={ true } size="64" />
<span>Errors</span>
</a>
<ul>
{#each errors as error, i}
<li>{ error.location.file}</li>
{/each}
</ul>
</bando>
{/if}

@ -0,0 +1,224 @@
<script>
import Accordion from "./demos/Accordion.svelte";
import AspectRatio from "./demos/AspectRatio.svelte";
import Badge from "./demos/Badge.svelte";
import ButtonGroup from "./demos/ButtonGroup.svelte";
import Calendar from "./demos/Calendar.svelte";
import Callout from "./demos/Callout.svelte";
import Cards from "./demos/Cards.svelte";
import Carousel from "./demos/Carousel.svelte";
import Chat from "./demos/Chat.svelte";
import Code from "$/client/components/Code.svelte";
import CodeDemo from "./demos/Code.svelte";
import CodeFormatter from "$/client/components/CodeFormatter.svelte";
import Countdown from "./demos/Countdown.svelte";
import Darkmode from "./demos/Darkmode.svelte";
import DataTable from "./demos/DataTable.svelte";
import FairPay from "./demos/FairPay.svelte";
import Flipper from "./demos/Flipper.svelte";
import Form from "./demos/Form.svelte";
import HLSVideo from "./demos/HLSVideo.svelte";
import Icon from "$client/components/Icon.svelte";
import IconDemo from "./demos/Icon.svelte";
import IconImage from "./demos/IconImage.svelte";
import LiveStream from "./demos/LiveStream.svelte";
import LoggedIn from "./demos/LoggedIn.svelte";
import Login from "./demos/Login.svelte";
import Markdown from "$/client/components/Markdown.svelte";
import MarkdownDemo from "./demos/Markdown.svelte";
import Modal from "./demos/Modal.svelte";
import OGPreview from "./demos/OGPreview.svelte";
import Pagination from "./demos/Pagination.svelte";
import PlaceHolder from "./demos/PlaceHolder.svelte";
import Progress from "./demos/Progress.svelte";
import Sidebar from "$/client/components/Sidebar.svelte";
import SidebarCSS from "./demos/SidebarCSS.svelte";
import SidebarDemo from "./demos/Sidebar.svelte";
import SnapImage from "./demos/SnapImage.svelte";
import Spinner from "./demos/Spinner.svelte";
import StackLayer from "./demos/StackLayer.svelte";
import Switch from "./demos/Switch.svelte";
import Tabs from "./demos/Tabs.svelte";
import Tiles from "./demos/Tiles.svelte";
import Toast from "./demos/Toast.svelte";
import Toastier from "./demos/Toastier.svelte";
import Tooltip from "./demos/Tooltip.svelte";
import Video from "./demos/Video.svelte";
import WTVideo from "./demos/WTVideo.svelte";
import Layout from "../Layout.svelte";
import { link, replace } from "svelte-spa-router";
export let params = {};
/* WARNING: If you put any component that uses /api/login into the first slot
* or set active: true on them it will require a login.
*/
let panels = [
{title: "Accordion", active: false, icon: "align-justify", component: Accordion},
{title: "AspectRatio", active: false, icon: "copy", component: AspectRatio},
{title: "Badge", active: false, icon: "award", component: Badge},
{title: "ButtonGroup", active: false, icon: "server", component: ButtonGroup},
{title: "Calendar", active: false, icon: "calendar", component: Calendar},
{title: "Callout", active: false, icon: "file-plus", component: Callout},
{title: "Cards", active: false, icon: "credit-card", component: Cards},
{title: "Carousel", active: false, icon: "repeat", component: Carousel},
{title: "Chat", active: false, icon: "message-circle", component: Chat},
{title: "Code", active: false, icon: "code", component: CodeDemo},
{title: "Countdown", active: false, icon: "clock", component: Countdown},
{title: "Darkmode", active: false, icon: "sunrise", component: Darkmode},
{title: "DataTable", active: false, icon: "grid", component: DataTable},
{title: "FairPay", active: false, icon: "dollar-sign", component: FairPay},
{title: "Flipper", active: false, icon: "layers", component: Flipper},
{title: "Form", active: false, icon: "database", component: Form},
{title: "Icon", active: false, icon: "feather", component: IconDemo},
{title: "IconImage", active: false, icon: "image", component: IconImage},
{title: "LiveStream", active: false, icon: "cast", component: LiveStream},
{title: "LoggedIn", active: false, icon: "log-out", component: LoggedIn},
{title: "Login", active: false, icon: "log-in", component: Login},
{title: "Markdown", active: false, icon: "file", component: MarkdownDemo},
{title: "Modal", active: false, icon: "maximize", component: Modal},
{title: "OGPreview", active: false, icon: "external-link", component: OGPreview},
{title: "Pagination", active: false, icon: "skip-forward", component: Pagination},
{title: "PlaceHolder", active: false, icon: "image", component: PlaceHolder},
{title: "Progress", active: false, icon: "thermometer", component: Progress},
{title: "Sidebar", active: false, icon: "sidebar", component: SidebarDemo},
{title: "SidebarCSS", active: false, icon: "sidebar", component: SidebarCSS},
{title: "SnapImage", active: false, icon: "camera", component: SnapImage},
{title: "Spinner", active: false, icon: "rotate-cw", component: Spinner},
{title: "StackLayer", active: false, icon: "layers", component: StackLayer},
{title: "Switch", active: false, icon: "check-square", component: Switch},
{title: "Tabs", active: false, icon: "folder", component: Tabs},
{title: "Tiles", active: false, icon: "camera", component: Tiles},
{title: "Toast", active: false, icon: "message-square", component: Toast},
{title: "Toastier", active: false, icon: "message-square", component: Toastier},
{title: "Tooltip", active: false, icon: "help-circle", component: Tooltip},
{title: "HLSVideo", active: false, icon: "video", component: HLSVideo},
{title: "Video", active: false, icon: "video", component: Video},
{title: "WTVideo", active: false, icon: "video", component: WTVideo},
];
panels.forEach(p => (p.code = p.code || `/bando/demos/${p.title}.svelte`));
const select_named = () => {
if(params.name) {
return panels.find(p => p.title === params.name) || panels[0];
} else {
return panels[0];
}
}
let selected = select_named();
selected.active = true;
let show = "DEMO";
const load_docs = async (from) => {
const res = await fetch(`${from.code}.md`);
return res.status == 200 ? res.text() : undefined;
}
const sidebar_select = (event) => {
const {index, item} = event.detail;
show = "DEMO";
selected = item;
panels = panels.map((x, i) => {
x.active = i == index;
return x;
});
replace(`/bando/components/${selected.title}/`);
}
</script>
<style>
div[slot="top"] span {
display: none;
}
contents {
padding: 0.5rem;
width: 100%;
max-height: 100vh;
overflow-y: auto;
}
tabs {
margin-bottom: 1rem;
}
@media only screen and (max-width: 900px) {
div[slot="top"] h3 {
display: none;
}
div[slot="top"] span {
display: inline-block;
padding-top: 0.3rem;
}
div[slot="bottom"] {
display: none;
}
}
left {
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
max-width: min-content;
width: min-content;
min-width: min-content;
}
</style>
<Layout fullscreen={ true } header={false} footer={ false } testid="page-bando-demos">
<left>
<Sidebar on:select={ sidebar_select } menu={ panels }>
<div slot="top">
<h3><a href="/" use:link><Icon name="arrow-left-circle" /> Components</h3>
<span><Icon name="home" size="36" /></span>
</div>
<div slot="bottom">
<p>Code is in <b>admin/bando/demos</b></p>
</div>
</Sidebar>
</left>
<contents>
<tabs>
<a data-testid="tab-demo" class:active={ show == "DEMO" } on:click={ () => show = "DEMO" }>
<Icon name="eye" size="36px"/> Demo
</a>
<a data-testid="tab-docs" class:active={ show == "DOCS" } on:click={ () => show = "DOCS" }>
<Icon name="book-open" size="36px"/> Docs
</a>
<a data-testid="tab-code" class:active={ show == "CODE" } on:click={ () => show = "CODE" }>
<Icon name="code" size="36px" /> Code
</a>
</tabs>
<component data-testid="demo-{ selected.title }">
{#if show == "CODE"}
<Code src={ selected.code } language="javascript" />
{:else if show == "DOCS"}
{#await load_docs(selected) then docs }
{#if docs}
<Markdown content={ docs } />
{:else}
<h1>No Docs</h1>
<p>This component is not documented yet.</p>
{/if}
{/await}
{:else}
<h1>{selected.title}</h1>
<svelte:component this={selected.component} />
{/if}
</component>
</contents>
</Layout>
<CodeFormatter />

@ -0,0 +1,152 @@
<script>
import template from "lodash/template";
import Icon from "$/client/components/Icon.svelte";
import Toasts from "$/client/components/Toasts.svelte";
import Code from "$/client/components/Code.svelte";
import { log } from "$/client/logging.js";
import Layout from "../Layout.svelte";
import api from '$/client/api.js';
import { onMount } from "svelte";
export let selected_template;
let showing_rendered = false;
let results = "";
let source = "";
let variable_json = "{ }";
let generators = [];
let variables = {};
let renderer = () => source;
let notice = "";
let last_good = "";
let send_toast;
let language = "javascript";
const language_list = {
"html": "html",
"svelte": "javascript",
"sh": "shell",
"js": "javascript"
}
const list_generators = async () => {
let [status, data] = await api.get('/api/devtools/djenterator');
if(status == 200) {
generators = data;
selected_template = generators[0];
} else {
log.debug("failed to load generators", status);
}
}
const render_template = () => {
try {
// avoid rendering when the current template doesn't match the renderer
if(selected_template == renderer._template) {
variables = variable_json ? JSON.parse(variable_json): {};
results = renderer(variables);
notice = "";
last_good = results;
}
return true;
} catch(err) {
log.error(err);
notice = err.message;
results = last_good;
return false;
}
}
const load_variables = async (template_name) => {
let res = await fetch(`/djenterator/${template_name}.vars`);
if(res.status == 200) {
variable_json = await res.text();
} else {
variable_json = '{}';
notice = `No ${template_name}.vars file found. ${res.status}`;
}
}
const detect_language = (name) => {
const ext = name.split(".")[1];
language = language_list[ext];
}
const load_template = async (template_name) => {
let res = await fetch(`/djenterator/${template_name}`);
if(res.status == 200) {
source = await res.text();
last_good = source;
detect_language(template_name);
try {
renderer = template(source);
// tag this template renderer so that we don't try to render it against the wrong one
renderer._template = template_name;
render_template();
} catch(error) {
log.error(error);
notice = `${error.message}`;
results = source;
}
} else {
notice = `Error loading ${template_name}: ${res.status}`;
}
}
const copy_code = () => {
navigator.clipboard.writeText(results).then(() => {
notice = "Code copied to clipboard.";
}, () => {
notice = "Failed copying to clipboard.";
});
}
$: if(variable_json) render_template();
// this reload the templates when you click on a new one
const re_render = async (what) => {
await load_variables(what);
await load_template(what);
}
$: if(selected_template) re_render(selected_template);
onMount(async () => await list_generators());
</script>
<style>
textarea.editor {
height: 15em;
background-color: var(--value0);
color: var(--value9);
}
</style>
<Layout fullscreen={ true } header={ false } footer={ false } testid="page-bando-djenterator">
<blockstart style="width: 100%">
<block style="--w: 100%">
<select bind:value={ selected_template }>
{#each generators as template}
<option value={ template }>{ template }</option>
{/each}
</select>
<textarea class="editor" bind:value={ variable_json }></textarea>
{#if notice}
<callout class="error">{notice}</callout>
{/if}
{#if results}
<Code on:copy={ send_toast("Copied to clipboard!") } content={ results } language={ language } />
{/if}
</block>
</blockstart>
<Toasts bind:send_toast fade_after={ 10000 } orientation="bottom right" />
</Layout>

@ -0,0 +1,194 @@
<script>
import Icon from "$/client/components/Icon.svelte";
import { onMount } from "svelte";
import { log } from "$/client/logging.js";
import api from "$/client/api.js";
import { defer } from "$/client/helpers.js";
import Toast from "$/client/components/Toasts.svelte";
let all_icons = [];
let icons_by_letters = {"a": []};
let selected_letter = "a";
let inactive = false;
let icons = [];
export let size=48;
let search = "";
export let labels=true;
export let tight=false;
let load_promise = defer();
let send_toast;
const order_pages = (in_icons) => {
const letters = {};
for(let icon_name of in_icons) {
const first = icon_name[0];
letters[first] = letters[first] || [];
letters[first].push(icon_name);
}
return letters;
}
const search_icons = async (pattern) => {
await load_promise; // lord I hate Svelte's lifecycle
if(pattern.trim() === "") {
icons_by_letters = order_pages(all_icons);
icons = all_icons;
selected_letter = Object.keys(icons_by_letters)[0];
} else {
icons = all_icons.filter(i => i.includes(pattern));
if(icons.length > 0) {
icons_by_letters = order_pages(icons);
selected_letter = Object.keys(icons_by_letters)[0];
}
}
}
$: search_icons(search);
const gen_code = (name) => {
let results = `<Icon name="${name}" size="${size}" />`;
navigator.clipboard.writeText(results).then(() => {
send_toast(`${name} copied to clipboard.`);
}, () => {
send_toast(`${name} copy FAILED.`);
});
}
onMount(async () => {
const [status, data] = await api.get("/icons/index.json");
if(status === 200) {
all_icons = data;
icons_by_letters = order_pages(all_icons);
load_promise.resolve();
} else {
log.error("Invalid response", status, data);
load_promise.reject();
}
});
</script>
<style>
icons {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: auto;
row-gap: 1rem;
}
icons.tight {
grid-template-columns: repeat(10, 1fr);
}
icons icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
search-bar {
display: flex;
justify-content: space-evenly;
flex-wrap: nowrap;
height: 5ch;
min-height: 5ch;
}
search-bar input#size {
width: 6ch;
}
search-bar input#search {
min-width: 30ch;
max-width: 30ch;
width: 30ch;
}
search-bar span {
padding-right: 1rem;
}
content {
display: flex;
flex-direction: column;
padding-top: 1rem;
}
display {
display: flex;
flex-direction: column;
align-items: stretch;
}
display letters {
display: grid;
grid-template-rows: 1fr 1fr;
grid-template-columns: repeat(13, 1fr);
margin-bottom: 1rem;
}
display letters letter {
font-size: 1.5em;
font-weight: 600;
text-align: center;
padding: 1rem;
border: 1px solid var(--value5);
}
display letters letter.selected {
background-color: var(--color-bg-secondary);
}
@media only screen and (max-width: 900px) {
icons {
grid-template-columns: repeat(4, 1fr);
}
}
@media only screen and (max-width: 600px) {
icons {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
<content>
<search-bar>
<span on:click={ () => inactive = !inactive }>
<Icon tooltip="Toggle inactive look." name={ inactive ? 'eye' : 'eye-off'} size="24" />
</span>
<input placeholder="Search names..." bind:value={ search } id="search" >
<div>Pixel Size:</div>
<input bind:value={ size } id="size" >
</search-bar>
{#if icons.length > 0}
<display>
<letters>
{#each Object.keys(icons_by_letters) as letter}
<letter class:selected={ letter === selected_letter } on:click={ () => selected_letter = letter }>{ letter }</letter>
{/each}
</letters>
<icons class:tight={ tight }>
{#each icons_by_letters[selected_letter] as name}
<icon on:click={ () => gen_code(name) }>
<Icon name={ name } size={ size } inactive={inactive}/>
{#if labels}
<span>{ name }</span>
{/if}
</icon>
{/each}
</icons>
</display>
{:else}
<h1>No Icons match "{ search }"</h1>
{/if}
<Toast bind:send_toast orientation="bottom right" />
</content>

@ -0,0 +1,21 @@
<script>
import Tabs from "$/client/components/Tabs.svelte";
import Calendar from "./Calendar.svelte";
import Cards from "./Cards.svelte";
import Login from "./Login.svelte";
import { log } from "$/client/logging.js";
export let panels = [
{title: "Calendar", active: true, icon: "calendar", component: Calendar},
{title: "Cards", active: false, icon: "credit-card", component: Cards},
{title: "Login", active: false, icon: "log-in", component: Login},
];
let selected = panels[0];
const tab_select = (event) => {
log.debug("SELECTED TAB", event.detail);
}
</script>
<Tabs panels={ panels } on:select={ tab_select } bind:selected vertical={true} />

@ -0,0 +1,7 @@
An accordion is just the `Tabs` component with `vertical` set to true:
```
<Tabs panels={ panels } on:select={ tab_select } bind:selected vertical={true} />
```
See the documentation for `Tabs` for how to use it.

@ -0,0 +1,26 @@
<style>
aspect-test {
display: flex;
flex-direction: column;
}
.top-box p {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--value5);
font-size: 2.5vw;
padding: 1rem;
}
</style>
<aspect-test>
<div class="top-box" style="--aspect-ratio: 16/9;">
<p>Every single visual experience that has a bounding plane has to deal with aspect ratios, whether that's a photo through a 1:1 ratio medium format camera or a 16:9 ratio 4k movie. If there's a visual bounding box around the scene then everything in the scene must deal with the ratio of the width to the height.</p>
</div>
<div style="--aspect-ratio: 10/2; background-color: var(--value7); font-size: 1.5vw;">
<p>CSS is one of the only visual systems that has no concept of aspect ratios despite the performance benefits of knowing the aspect of a block before the contents of the block are available. If you know the aspect ratio of a block, and you know the width of the page, then you can render the block immediately because you only need <b>one</b> dimension to render both.</p>
</div>
</aspect-test>

@ -0,0 +1,12 @@
Because CSS doesn't really support aspect ratios (despite a standard that says it does) you'll have a difficult time positioning and sizing many elements on the page. The `--aspect-ratio` hack I've put into `static/global.css` does a decent job of creating a fake aspect ratio system without too many changes to your HTML. You only need to add:
```
style="--aspect-ratio: 16/9"
```
And the magic CSS should do what you want. It's a trick I've taken from [an article on CSS tricks](https://css-tricks.com/aspect-ratio-boxes/#using-custom-properties) that gives an excellent solution that doesn't require crazy changed to your CSS or HTML. You can also use `16/9` or decimal notation `1.4`.
Limitations
===
As you change the size of this demo you'll see the limitation of this method. As the boxes shrink the text will explode out of the "box" that CSS has, or the boxes will cover other boxes. It's best to have only 1 or 2 elements that use `--aspect-ration` and test it at various sizes.

@ -0,0 +1,51 @@
<script>
import Icon from "$/client/components/Icon.svelte";
</script>
<style>
box {
width: 200px;
height: 100px;
background-color: var(--color-bg-inverted);
color: var(--color-text-inverted);
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 0.5rem;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
}
</style>
<p>A simple badge with a number:</p>
<badge>1</badge>
<p>A badge with a tiny icon in it:</p>
<badge><Icon name="inbox" size="18px" width="1px" /></badge>
<p>Top left position with <b>class="top-left"</b>:</p>
<box>
<b>Top Left</b>
<badge class="top-left"><Icon name="inbox" size="14px" width="1px" /></badge>
</box>
<p>Bottom left position with <b>class="bottom-left"</b>:</p>
<box>
<b>Bottom Left</b>
<badge class="bottom-left"><Icon name="inbox" size="14px" width="1px" /></badge>
</box>
<p>Top right position with <b>class="top-right"</b>:</p>
<box>
<b>Top Right</b>
<badge class="top-right"><Icon name="inbox" size="14px" width="1px" /></badge>
</box>
<p>Bottom right position with <b>class="bottom-right"</b>:</p>
<box>
<b>Bottom Right</b>
<badge class="bottom-right"><Icon name="inbox" size="14px" width="1px" /></badge>
</box>

@ -0,0 +1,3 @@
Badges are simple little notification icons placed on elements. The ones implemented here are _very_ minimalist, with just enough space to work with the [feather icons](https://feathericons.com) included with the project.
You can place most single character elements inside a badge but test that your font will scale appropriately on different screen sizes.

@ -0,0 +1,25 @@
<script>
import Icon from "$/client/components/Icon.svelte";
</script>
<style>
button-group.vertical {
width: min-content;
}
</style>
<p>This is also shown at the bottom the Card demo. It's just a compact strip for buttons.</p>
<button-group>
<button id="ok"><Icon name="check-circle" color="var(--color-accent)" size="48" /></button>
<button><Icon name="x-circle" color="var(--color-bg)" size="48"/></button>
<button><Icon name="alert-circle" color="var(--color-bg)" size="48"/></button>
</button-group>
<h2>Vertical</h2>
<button-group class="vertical">
<button id="ok"><Icon name="check-circle" color="var(--color-accent)" size="48" /></button>
<button><Icon name="x-circle" color="var(--color-bg)" size="48"/></button>
<button><Icon name="alert-circle" color="var(--color-bg)" size="48"/></button>
</button-group>

@ -0,0 +1 @@
ButtonGroup will bundle a strip of buttons together with one or more marked "active". It will correctly curve the corners of the first and last button, and can be run horizontally or vertically.

@ -0,0 +1,27 @@
<script>
import Calendar from "$/client/components/Calendar.svelte";
let message = "";
const format_date = (d) => d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const date_selected = (event) => {
message = `Date is: ${format_date(event.detail)}`;
}
const next_month = (event) => {
message = `Next! First of Month: ${format_date(event.detail.fom)}`;
}
const prev_month = (event) => {
message = `Previous! First of Month: ${format_date(event.detail.fom)}`;
}
</script>
<Calendar on:select={ date_selected } on:next={ next_month } on:previous={ prev_month } />
{#if message}
<h4>{ message }</h4>
{/if}

@ -0,0 +1 @@
This is a _very_ basic implementation of a calendar that you might use in a fancy date picker. It does _not_ take into account the myriad of different date systems in the world, but it wouldn't be hard to change it to meet your needs. Consider it a starting point for your own Calendar if you need one.

@ -0,0 +1,37 @@
<script>
import Icon from "$/client/components/Icon.svelte";
</script>
<style>
callout {
margin: 1rem;
width: 50%;
}
</style>
<callout>
<span>This is a normal callout example.</span>
</callout>
<callout class="success">
<span>This is a success callout example.</span>
</callout>
<callout class="warning">
<span>This is a warning callout example.</span>
</callout>
<callout class="alert">
<span>This is an alert callout example.</span>
</callout>
<callout class="info">
<span>This is an info callout example.</span>
</callout>
<h2>Callout with Badges</h2>
<callout class="alert">
<span>This is an alert callout example with a badge.</span>
<badge class="top-right"><Icon name="alert-triangle" width="2px" size="18px" /></badge>
</callout>

@ -0,0 +1,8 @@
The Callout is simply a way to focus the user on a piece of text they should see. There's different levels of callout you can use:
* `class="alert"` -- An alert style you can use to tell of impending doom. Change the variable `--red` or `--color-error` in the CSS to change it's color from the default monochrome.
* `class="success"` -- For success messages. Change `--color-good` or `--green` to change it in the CSS.
* `class="warning"` -- For warning messages. Change `--color-warning` or `--orange`.
* `class="info"` -- For info messages. Change `--color-info` or `--yellow`.
When I say "change color" I mean go into the file `static/monochrome.css` or `static/color.css` to change how it looks.

@ -0,0 +1,46 @@
<script>
import Icon from '$/client/components/Icon.svelte';
import PlaceHolder from "$/client/components/PlaceHolder.svelte";
</script>
<style>
content {
display: flex;
flex-direction: column;
flex: flex-shrink;
flex-grow: 1;
justify-content: center;
}
button#ok {
background-color: var(--color-bg-secondary);
}
card {
width: 600px;
}
</style>
<content>
<card>
<top>
<PlaceHolder width={ 16 * 60 } height={ 9 * 60 } />
</top>
<middle>
<h4>
Card Example
</h4>
<p>Lorem ipsum dolor sit amet, consectetur
adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
</middle>
<bottom>
<button-group>
<button id="ok"><Icon name="check-circle" color="var(--color-accent)" size="48" /></button>
<button><Icon name="x-circle" color="var(--color-bg)" size="48"/></button>
</button-group>
</bottom>
</card>
<p>This is another example of simply using CSS to implement basic components. Rather than implement Svelte compoentns with slots it is sometimes <b>much</b> easier to CSS and plain HTML. To see how to use cards look at <b>admin/bando/demos/Cards.svelte</b>. I also show you how to restyle the buttons if you want them to be larger and square like you find in many panel examples.</p>
</content>

@ -0,0 +1 @@
Cards are used everywhere on the web, many times without you even knowing it. They allow you to show an image or similar media, some text, then some buttons. A variant of `Cards` is `Tiles` that are a horizontal layout of the same information. I generally use `Card` when I want a large display of the image or video, and `Tile` when I want a small display with a thumbnail or icon.

@ -0,0 +1,25 @@
<script>
import Carousel from "$/client/components/Carousel.svelte";
import IconImage from "$/client/components/IconImage.svelte";
import { log } from "$/client/logging.js";
export let panels = [
{caption: "Anchors Away", active: true, component: IconImage,
props: { name: "anchor", hue: "red", background_hue: "green" } },
{caption: "Money Money Money", active: false, component: IconImage,
props: { name: "dollar-sign", hue: "blue", background_hue: "purple", pattern: "triangles-sm" } },
{caption: "We Should Talk", active: false, component: IconImage,
props: { name: "message-circle", hue: "green", background_hue: "orange", pattern: "lines-sm" } },
];
let selected = panels[0];
const tab_select = (event) => {
// this is where you can grab selection information
log.debug("SELECTED TAB", event.detail);
}
</script>
<Carousel panels={ panels } on:select={ tab_select } bind:selected />
<h3>You're looking at the {selected.caption} panel which has a {selected.props.name} icon.</h3>

@ -0,0 +1 @@
The `Carousel` is found mostly on landing pages and even then it's considered not very useful. The problem with this UI component is it makes it difficult to navigate the images, and if it's automated then users will have a hard time accessing something you show them when it passes by. If you're using it I suggest using it only on the landing page, and then if it auto-rotates have it stop when users touch it for the first time.

@ -0,0 +1,14 @@
<script>
import Chat from "$/client/components/Chat.svelte";
import Markdown from "$/client/components/Markdown.svelte";
</script>
<style>
div {
max-width: 600px;
}
</style>
<div>
<Chat />
</div>

@ -0,0 +1 @@
The `Chat` is a small mini chat you might find on live streaming websites. It's not full featured but the client and backend are simple enough that you can change them to do what you want. To make it work you may need to look at `socket/chat.js` and make sure that's running correctly. If you don't use `Chat` on your site then consider removing `socket/chat.js` and edit `client/stores.js` to remove all [socket-io](https://socket.io) references. This will save you about 50k in the downloaded size of your app.

@ -0,0 +1,53 @@
<script>
import Code from "$/client/components/Code.svelte";
import Spinner from "$/client/components/Spinner.svelte";
import CodeFormatter from "$/client/components/CodeFormatter.svelte";
let code = "";
let test_code = "/test.js";
let message = "";
let timer;
const load_code = async () => {
let res = await fetch(test_code);
code = res.status == 200 ? await res.text() : `Error getting /test.js: ${res.status}`;
}
const code_copied = () => {
if(timer) clearTimeout(timer);
message = "You copied it!";
timer = setTimeout(() => message = "", 2000);
}
let code_promise = load_code();
</script>
{#await code_promise}
<Spinner />
{:then}
<p>You can display code with color, line numbers, and clipboard copying with <code>Code</code>, and you can click on the code to copy it to your clipboard:</p>
<Code content={ code } on:copy={ code_copied } language="javascript" />
<hr>
<p>It's designed similar to the <code>Markdown</code> component in that you can put the code in <code>content=""</code>, the inner slot, or as a <code>src=""</code> url to load.</p>
<Code on:copy={ code_copied } language="javascript">
{ code }
</Code>
<hr/>
<p>Here is the same code in <code>test.js</code> being loaded via a <code>src=""</code>.<p>
</p>
<Code src={ test_code } on:copy={ code_copied } language="javascript"/>
{#if message}
<toast class="bottom-right">
{ message }
</toast>
{/if}
<CodeFormatter />
{/await}

@ -0,0 +1,25 @@
The `Code` component simplifies displaying code with line numbers and letting people copy the code to their clipboard. It will get the code from a URL, wrap it in a `<pre><code>..</code></pre>` block, and then convert each line to an inner `<span>` so it will receive a line number using CSS.
The CSS that makes this magic happen is:
<pre>
<code data-language="css">
pre code span::before {
counter-increment: line;
content: counter(line);
display: inline-block;
padding: 0 0.3rem;
margin-right: 0.5rem;
border-right: 1px solid var(--color-inactive);
min-width: 3ch;
text-align: right;
color: var(--color-inactive);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}</code></pre>
This uses the `::before`` selector to add a line number counter to the front of each span, and then create a thin line. This is why you can use your mouse to select the lines of code but the _line numbers_ won't be selected (even if some browsers show you they are being selected).
To make it easier on people it will also just copy the code when you click on it and then do a `Toast` alert.

@ -0,0 +1,16 @@
<script>
import Countdown from "$/client/components/Countdown.svelte";
</script>
<style>
background {
background-color: var(--color-bg-inverted);
display: flex;
justify-content: center;
align-items: center;
}
</style>
<background>
<Countdown starts_on={ new Date(Date.now() + 20000) } />
</background>

@ -0,0 +1 @@
In my `LiveStream` usage I found I needed to tell people when the stream was starting with a countdown timer. This will implement a simple panel that you can layer over the video using the `Stacked/Layer` CSS.

@ -0,0 +1,6 @@
<script>
import Darkmode from "$/client/components/Darkmode.svelte";
</script>
<Darkmode />

@ -0,0 +1,21 @@
This simple little component will create a dark or light switch that changes the CSS and remembers the users setting. The technique uses this line from `static/colors.css` or `static/monochrome.css`:
```
[data-theme="dark"] {
```
This changes the CSS using the variables in that block to alter their colors to darker versions. Then in the JavaScript the theme is stored in the browser's local storage to remember it:
```
const set_theme = () => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
```
When the user comes back their theme is loaded with:
```
theme = localStorage.getItem('theme') ? localStorage.getItem('theme') : 'light';
set_theme()
```

@ -0,0 +1,71 @@
<script>
import DataTable from "$/client/components/DataTable.svelte";
import Icon from "$/client/components/Icon.svelte";
import { link } from 'svelte-spa-router';
import { log } from "$/client/logging.js";
import { onMount } from "svelte";
let rows = [
{_url: "/", id: 1, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 2, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 3, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 4, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 5, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 7, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 7, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 8, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 9, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 10, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 11, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 12, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 13, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 14, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 15, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 16, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 17, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 18, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 19, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 20, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
{_url: "/", id: 21, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"},
];
let column_names = Object.keys(rows[0]);
let pagination = { currentPage: 1, lastPage: 1 }
const full_query = async (event) => {
log.debug("QUERY", event.detail);
}
const search_query = async (event) => {
let search_text = event.detail;
log.debug("SEARCH", search_text);
}
const clear_search = async (event) => {
log.debug("CLEAR", event.detail);
}
const cell_click = (event) => {
log.debug("CELL", event.detail);
}
onMount(async () => {
await full_query();
});
</script>
<DataTable rows={ rows }
columns={ column_names }
bind:pagination
on:full_query={ full_query }
on:search_query={ search_query }
on:clear_search={ clear_search }
on:cell_click={ cell_click }>
<a href="/admin/table/" use:link><Icon name="arrow-left" size="36" /></a>
<a href="/admin/table/create/table/" use:link><Icon name="file-plus" size="36" /></a>
</DataTable>

@ -0,0 +1,3 @@
The `DataTable` simply takes an [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) of objects and displays them using a typical table with rows and columns. It also provides pagination and search features by default.
It's intended to be a starting point for your own table display if you need one, and it's used in the `admin/` management system.

@ -0,0 +1,252 @@
<script>
import { link } from 'svelte-spa-router';
import { fade } from 'svelte/transition';
import { onMount } from "svelte";
import api from "$/client/api.js";
import FormField from "$/client/components/FormField.svelte";
import Icon from "$/client/components/Icon.svelte";
import IconImage from "$/client/components/IconImage.svelte";
import Paypal from "$/client/components/Paypal.svelte";
import BTCPay from "$/client/components/BTCPay.svelte";
import Layout from "$/client/Layout.svelte";
import { fake_payments } from "$/client/config.js";
import { log } from "$/client/logging.js";
const quips = {
"0": "Awww, really? Alright then.",
"10": "That's a good start.",
"20": "Even more fair.",
"30": "Sweet!",
"40": "Yes! Love it!",
"50": "Booyah!",
"60": "Really?! Thank you!",
"70": "Whoa! No way!",
"80": "Fantastic! THANK YOU!",
"90": "NO WAY! YES!",
"100": "I love you!"
}
const form = {
amount: 10,
_errors: {},
_valid: true
}
// BUG: we have to duplicate this logic here because of how paypal works
$: form._valid = form.amount >= 0 && form.amount <= 100;
const amount_by = 10;
let paid_in_full = false;
let payment_failed = false;
const change_amount = () => {
form.amount = form.amount < 100 ? form.amount + amount_by : 0;
}
const payment_finished = (event) => {
const { system, status } = event.detail;
log.assert(status === "complete", "Payment status should be complete with finished event", system, event.detail);
paid_in_full = true;
}
const payment_canceled = (event) => {
log.debug("CANCELED", event);
}
const payment_error = () => {
payment_failed = true;
}
const payment_loading = (event) => {
log.debug("LOADING", event);
}
const pay_nothing = async () => {
// posting to /api/user/payments is how you do a free purchase
let [status, data] = await api.post("/api/user/payments", { amount: form.amount }, api.logout_user);
if(status === 200) {
paid_in_full = true;
} else {
log.error("status", status, "data", data);
payment_failed = true;
form._errors.main = data.message || data.error || "Payment Error.";
}
}
// this is a demo of how to check a user's purchase quickly
const already_paid = async () => {
let [status, data] = await api.get("/api/user/payments");
if(status === 200 && data.paid === true) {
paid_in_full = true;
} else {
log.debug("GET to /api/user/payments returned", status, data);
}
}
onMount(async () => {
await already_paid();
});
</script>
<style>
form#purchase {
width: min-content;
}
label.slider {
cursor: pointer;
display: flex;
height: 4rem;
width: 100%;
background-color: var(--color-bg-secondary);
user-select: none;
-webkit-user-select: none;
}
label.slider::after {
display: flex;
justify-content: center;
align-items: center;
width: calc(1% * var(--amount));
min-width: 2ch;
background-color: var(--color-bg-tertiary);
height: 4rem;
counter-reset: amount var(--amount);
content: '$' counter(amount);
transition: 0.5s;
}
input#amount {
display: none;
}
card {
width: 400px;
border-radius: var(--border-radius) var(--border-radius) 0px 0px;
}
card#failed top {
background-color: var(--color-bg-secondary);
text-align: center;
padding: 1rem;
}
payments {
display: flex;
flex-direction: column;
}
payments.disabled {
filter: blur(5px);
}
form card bottom {
padding: 0.5rem;
}
callout {
border-radius: 0px 0px var(--border-radius) var(--border-radius);
}
</style>
<Layout centered={ true } authenticated={ true }>
{#if paid_in_full}
<card in:fade|local id="paid">
<top>
<IconImage name="dollar-sign" />
</top>
<middle>
<h1>Welcome!</h1>
<p>Thank you for your purchase. You can now enjoy the entire
site.
</p>
</middle>
<bottom>
<button-group>
<button data-testid="payment-done-button"><a href="/" use:link>Start Browsing</a></button>
</button-group>
</bottom>
</card>
{:else}
{#if payment_failed}
<card in:fade|local id="failed">
<top><h1 data-testid="payment-error">Payment Error!</h1></top>
<middle>
<p style="font-size: 1.5em;">There was an error processing your payment. Please try again later.</p>
<p>You can email <a href="mailto:help@xor.academy">help@xor.academy</a> to get help with this purchase.</p>
</middle>
<bottom>
<button-group>
<button type="button"><a href="/" use:link><Icon name="arrow-left-circle" size="36" /> Cancel</a></button>
<button data-testid="payment-tryagain" on:click={ () => payment_failed = false }><Icon name="credit-card" size="36" light={ true } /> Try Again</button>
</button-group>
</bottom>
</card>
{:else}
<form method="POST" id="purchase">
<card>
<top>
{#if payment_failed }
<h1>Payment Error!</h1>
{:else}
<h1>What's a Fair Price?</h1>
{/if}
</top>
<middle>
<FormField form={ form } field="amount" label="{ quips[form.amount] || `Bad Value! ${form.amount}` }">
<label data-testid="payment-amount" class="slider"
for="amount" style="--amount: { form.amount };"
on:click={ () => change_amount() }>
</label>
<input type="numeric" id="amount" bind:value={ form.amount } />
</FormField>
{#if form._valid}
<p>Click on the slider to change <b>how much you think this course is worth</b>, then
<b>select your payment method</b>.</p>
{:else}
<p><b>You've caused an error in this form. Please email help@xor.academy and tell them how you did this.</b></p>
{/if}
</middle>
<bottom>
<payments class:disabled={ !form._valid }>
{#if form.amount > 0}
<Paypal credit_card={ true } amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form._valid }/>
<BTCPay amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form._valid } />
{:else}
<button type="button" data-testid="button-paynothing" on:click|preventDefault={ pay_nothing }>Pay Nothing</button>
{/if}
</payments>
</bottom>
</card>
{#if fake_payments}
<callout class="warning">
Payments are fake right now. Use a fake CC from Paypal,
and use testnet coins for BTC/LTC.
</callout>
{/if}
</form>
{/if}
{/if}
</Layout>

@ -0,0 +1,16 @@
The FairPay component is more of a demo than an actual component. It shows how to use the
`Paypal.svelte` and `BTCPay.svelte` components. The `FairPay.svelte` implements a "pay what you
want" style of payments.
Why the $10 increments? There's a form of credit card crime called "carding" where people with
stolen numbers test their cards on any purchase that is $1. They'll get large numbers of possibly
valid credit cards and they need to test them. The best way to test them is to attempt a very small
purchase, and if the purchase works then they mark the card valid.
If you allow people to pay any amount as a donation then carders will raid your purchase form and
rack up $1 fraudulent charges. That may not seem to bad, but your payment processor will then
charge _you_ $15 or more to handle the charge back. Even if you offer to refund the purchase and
they do no work. This means if you receive $100 of fraudulent charges you would owe $1500 in fees.
The easiest way to prevent this is to simply only allow increments of $10. $10 is too high for most
carder testers, so this will deter them (at least until everyone does this).

@ -0,0 +1,52 @@
<style>
card {
width: 400px;
}
</style>
<script>
import IconImage from "$/client/components/IconImage.svelte";
let flipped = false;
</script>
<flipper class:flipped={ flipped }>
<inner>
<card class="front">
<top>
<IconImage name="arrow-left" />
</top>
<middle>
<h4>
Front Card
</h4>
<p>This is the front facing card. Click the button to flip it.</p>
</middle>
<bottom>
<button-group>
<button on:click={ () => flipped = !flipped }>Flip It!</button>
</button-group>
</bottom>
</card>
<card class="back">
<top>
<IconImage name="arrow-right" />
</top>
<middle>
<h4>
Back Card
</h4>
<p>This is the back facing card. Click the button to flip it back.</p>
</middle>
<bottom>
<button-group>
<button on:click={ () => flipped = !flipped }>Flip it Back!</button>
</button-group>
</bottom>
</card>
</inner>
</flipper>

@ -0,0 +1,13 @@
The `Flipper` shows two panels on the front and back of and lets you "flip" them around. This is a
decent way to restrict the user interface between two options, but it will most likely have problems
with accessibility without some additional `aria` attributes.
The technique uses the `transform-style: preserve-3d` to make sure that the browser treats it like a
3D space. Then it uses `transform: rotateY(180deg)` to do the flipping action.
You finally need these two lines to maintain the back and front:
```
-webkit-backface-visibility: hidden; /* Safari */
backface-visibility: hidden;
```

@ -0,0 +1,78 @@
<script>
import FormField from '$/client/components/FormField.svelte';
import api from "$/client/api.js";
let form = {
full_name: "",
password: "",
subscribe: '0',
email: "",
notes: "",
_valid: false,
_errors: {main: ""},
}
// normally the rules are maintained on the /api/ and then added to the
// form when there's an error. See client/pages/Register.svelte for a good example
form._rules = {
full_name: "required",
password: "required",
subscribe: "required",
email: "required|email",
notes: "",
}
</script>
<style>
main {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 3rem;
}
</style>
<main>
<form action="/api/user/profile" method="POST">
<card>
<top>
<h1>Register</h1>
<error>{ form._errors.main }</error>
</top>
<middle>
<FormField form={ form } field="full_name" label="Full Name">
<input type="text" id="full_name" bind:value={ form.full_name } name="full_name">
</FormField>
<FormField form={ form } field="password" label="Password">
<input type="password" id="password" bind:value={ form.password } name="password">
</FormField>
<FormField form={ form } field="email" label="Email">
<input type="email" id="email" bind:value={ form.email } name="email">
</FormField>
<FormField form={ form } field="notes" label="Notes">
<textarea bind:value={ form.notes } name="notes" id="notes"></textarea>
</FormField>
<FormField form={ form } field="subscribe" label="Want Notifications?">
<input type="checkbox" id="subscribe" name="subscribe" bind:checked={ form.subscribe }>
</FormField>
</middle>
<bottom>
<button-group>
<button data-testid="register-button"
on:click|preventDefault={ () => form = api.validate(form) }>Register</button>
</button-group>
</bottom>
</card>
</form>
</main>

@ -0,0 +1,10 @@
This demonstrates how to use the `FormField` component to construct forms with nicer error messages. There are a lot of things you need to do for nice error messages on forms, and `FormField` handles most of them. You need to set variables for it, then place your input inside:
```
<FormField form={ form } field="subscribe" label="Want Notifications?">
<input type="checkbox" id="subscribe" name="subscribe" bind:checked={ form.subscribe }>
</FormField>
```
All of the input fields work mostly the same but with Svelte you need to use `bind:checked={
variable }` instead of `bind:value`.

@ -0,0 +1,10 @@
<script>
import HLSVideo from "$/client/components/HLSVideo.svelte";
let source = "https://learnjsthehardway.com/media/sample_video.mp4";
let poster = "https://learnjsthehardway.com/media/sample_image.jpg";
let video_background = "rgba(0,0,0,0)";
</script>
<container class="pattern-lines-sm">
<HLSVideo poster={ poster } background_color={video_background } source={ source } starts_on={ new Date(Date.now() + 10000) } />
</container>

@ -0,0 +1,63 @@
<script>
import Icon from "$/client/components/Icon.svelte";
import Code from "$/client/components/Code.svelte";
let size = 64;
let fill = "none";
let color = "var(--color)";
let width = "2px";
let shadow = "";
let tooltip = "This is a tip!";
let tip_position = "bottom";
</script>
<style>
icons {
display: flex;
flex-direction: column;
}
icons div {
display: flex;
flex-direction: row;
font-size: 2em;
}
icons div input,
icons div select {
font-size: 1em;
}
</style>
<icons>
<div>size: <input name="size" bind:value={ size } /></div>
<div>fill: <input name="fill" bind:value={ fill } /></div>
<div>width: <input name="width" bind:value={ width } /></div>
<div>color: <input name="color" bind:value={ color } /></div>
<div>shadow: <input name="shadow" type="checkbox" bind:checked={ shadow } /></div>
<div>tooltip: <input name="tooltip" bind:value={ tooltip } /></div>
<div>tip_position:
<select name="tip_position" bind:value={ tip_position }>
<option>bottom</option>
<option>left</option>
<option>right</option>
<option>top</option>
<option>bottom-left</option>
<option>bottom-right</option>
<option>top-left</option>
<option>top-right</option>
</select>
</div>
<Icon name="keyboard" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} />
<Icon name="alarm-clock" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} />
<Icon name="activity" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} />
<Icon name="apple" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} />
<Icon name="angry" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} />
<Icon name="anchor" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} />
</icons>
<hr>
<h4>Code Sample using <code>anchor</code></h4>
<Code language="html" content='<Icon name="anchor"&#13; color="{color}"&#13; size="{size}"&#13; fill="{fill}"&#13; width="{width}"&#13; shadow="{shadow}"&#13; tooltip="{tooltip}"&#13; tip_position="{tip_position}" />' />
<a href="/admin/#/icons"><button type="button">See all icons and search them.</button></a>

@ -0,0 +1,5 @@
# Icons by <a href="https://lucide.dev">lucide</a>
The Bandolier includes the great icon collection from <a href="https://lucide.dev">lucide</a> to get you started. Probably every icon you could possibly need is in this collection, and there's a nice Icon Finder tool to find what you need at <a href="/admin/#/icons/">/admin/#/icons/</a>.
Using `Icon` is very simple. You find the name, and can set a size, whether it's inactive or not, and a few other options to control the appearance. In the Icon Finder you can click on an icon to copy the code for it.

@ -0,0 +1,56 @@
<script>
import IconImage from "$/client/components/IconImage.svelte";
let index = 0;
let grayscale=0;
const icon_patterns = [
{name: "archive", pattern: "dots-sm", hue: "green", background_hue: "orange"},
{name: "dollar-sign", pattern: "grid-sm", hue: "orange", background_hue: "blue"},
{name: "arrow-up-circle", pattern: "vertical-lines-md", hue: "purple", background_hue: "black"},
{name: "camera", pattern: "triangles-lg", hue: "pink", background_hue: "green"},
];
let props = icon_patterns[index];
const change = () => {
index += 1;
props = icon_patterns[index % icon_patterns.length];
}
</script>
<style>
card {
max-width: 600px;
}
</style>
<card>
<top>
<!-- this is how you assign a set of properties to a component using the {...} syntax -->
<IconImage {...props } grayscale={ grayscale } />
</top>
<middle>
<h4>IconImage Component</h4>
<p>The IconImage is kind of a fancy <b>PlaceHolder</b> that embeds an <b>Icon</b> in a pattern field with color. It's good enough to use for quite a while until you can design a better asset. The patterns come from <a href="https://bansal.io/pattern-css" target="_">pattern.css</a>.
</p>
<code>&lt;IconImage name="{ props.name }" pattern="{ props.pattern }" hue="{ props.hue }" background_hue="{ props.background_hue }" grayscale="{ grayscale }" /&gt;</code>
<br>
</middle>
<bottom>
<button-group>
<button on:click={ change }>Change It!</button>
<button on:click={ () => grayscale = grayscale == 0 ? 1 : 0 }>
{#if grayscale}
Color It!
{:else}
Gray It!
{/if}
</button>
</button-group>
</bottom>
</card>

@ -0,0 +1,27 @@
<script>
import Video from "$/client/components/Video.svelte";
import Markdown from "$/client/components/Markdown.svelte";
let video_background = "rgba(0,0,0,0)";
let stream = {
"video_source": "HLSJS_SOURCE",
"video_poster": "/images/header.svg",
"starts_on": "Jun 11 2021 12:20:22 GMT-0400"
}
</script>
<style>
container {
display: flex;
flex-direction: column;
width: 100%;
min-width: 770px;
margin-right: 0.5rem;
}
</style>
<container class="pattern-dots-sm">
<Video poster={ stream.poster } background_color={video_background } source={ stream.source } />
</container>

@ -0,0 +1,5 @@
You can use the `Video` component to stream any HLS stream. On Apple's Safari this works natively and doesn't require any additional code. On other platforms you have to use the [HLS.js library](https://github.com/video-dev/hls.js/) in `static/js/hls.js` to decode and play the HLS protocol.
The `Video` component automatically negotiates the complex dance of figuring out when to load HLS.js.
This demo won't work without you modifying it with an HLS stream (or other stream supported by the `<video>` tag. Change the stream variable in `admin/bando/demos/LiveStream.svelte` to enable one.

@ -0,0 +1,17 @@
<script>
import { user } from "$/client/stores.js";
import LoggedIn from "$/client/components/LoggedIn.svelte";
</script>
<LoggedIn>
<callout slot="yes">
<h1>You are logged in.</h1>
</callout>
<callout class="error" slot="no">
<h1>You are NOT logged in</h1>
</callout>
</LoggedIn>
<button on:click={ () => $user.authenticated = !$user.authenticated }>Change Login</button>

@ -0,0 +1,16 @@
The LoggedIn component checks if the user is logged in by doing a GET to "/api/login" and if that API endpoint returns 401 or 403 it will perform redirects for you. It updates the `$user.authenticated` Svelte store (from `client/stores.js`) and then as your app changes that setting it will adapt and display different content.
Use this component to:
1. Block a page's content from unauthenticated users.
2. Redirect users to your a new location if they are not authenticated.
3. Show a standard redirect page when it's redirecting.
In this test you are simply changing the $user.authenticated to see how the LoggedIn component handles it dynamically. You should still add a check for `$user.authenticated` in your own `onMount` since svelte will run them no matter what.
<callout class="warning">
<span>
Currently you should only use LoggedIn as a major page blocking component rather than fine grained individual elements. It makes a network request to `/api/login` to confirm the user's logged in status, and if you have more than one each instance will do this check. If you see repeated calls to `/api/login` then check how your using this component on your pages.
</span>
</callout>

@ -0,0 +1,13 @@
<script>
import Login from "$/client/components/Login.svelte";
</script>
<style>
shrink {
display: flex;
}
</style>
<shrink>
<Login />
</shrink>

@ -0,0 +1,5 @@
The `Login` is both in `client/pages/Login.svelte` and also in `client/components/Login.svelte`. The
one in `pages` is more like this demo and simply uses the `component`. The reason for the split is
so you can prompt for a login with a `Modal` in situations where tearing the user away from what
they're viewing might be wrong. A good example of this is if people are watching a video but need
to log in to comment or chat.

@ -0,0 +1,51 @@
<script>
import Markdown from "$/client/components/Markdown.svelte";
import Spinner from "$/client/components/Spinner.svelte";
let md = "";
let test_md = "/test.md";
const load_md = async () => {
let res = await fetch(test_md);
md = res.status == 200 ? await res.text() : `Error getting /test.md: ${res.status}`;
}
let md_promise = load_md();
</script>
{#await md_promise}
<Spinner />
{:then}
<callout>
<Markdown content={ md } />
</callout>
<hr>
<p>
Because Svelte does a fairly correct JavaScript parse of the <code>content=&lcub;&rcub;</code>
you can use the ` (back-tick) syntax to write your markdown directly.
</p>
<callout>
<Markdown content={`
Write Me Right
===
You can write the markdown directly in the source too using the JavaScript
template syntax.
`}/>
</callout>
{/await}
<p>Originally I had this much more complicated using the Svelte slots, but it turns out
to be very complicated and also doesn't work with <code>rendered</code> pages at all.
This style works with rendered pages, which is probably the more common way you would
use Markdown.
</p>
<h2>Security Warning</h2>
<p>There is <b>NO</b> HTML sanitization on this output, so do <b>NOT</b> render
user inupt without adding some form of sanitization like with <a href="https://github.com/cure53/DOMPurify">dompurify</a>. Consult the <a href="https://github.com/developit/snarkdown">snardown</a> documentation for more information.
</p>

@ -0,0 +1,32 @@
<script>
import Modal from "$/client/components/Modal.svelte";
let modal_open = false;
</script>
<style>
test-panel {
display: flex;
flex-direction: column;
padding: 1rem;
background: var(--color-bg);
border-radius: var(--border-radius);
opacity: 100%;
width: 400px;
}
</style>
{#if modal_open}
<Modal on:close={ () => modal_open = false }>
<test-panel>
<h1>Modal Open</h1>
<p>
You can put anything inside the modal, and it also handles ESC for closing the
modal by sending an <b>on:close</b> event.
</p>
<button on:click={ () => modal_open = false }>CLOSE ME (ESC)</button>
</test-panel>
</Modal>
{:else}
<button on:click={ () => modal_open = true }>OPEN MODAL</button>
{/if}

@ -0,0 +1,8 @@
The `Modal` component is useful for short notices or UI elements when you don't want to remove the
user from a view they need to continue seeing. A good example usage is logging in while they watch
a video. Transitioning to an entirely new page just to ask for username/password ruins their video
viewing experience.
Try changing this `admin/bando/demos/Model.svelte` file to not use the `{#if}` when the model
opens. You'll see that the button to open the modal stays around until you hover off. Just a quirk
of how the modal is layered and probably fixed with `z-index` if you run into it.

@ -0,0 +1,42 @@
<script>
import OGPreview from "$/client/components/OGPreview.svelte";
import { base_host } from "$/client/config.js";
let og = {
"title": "LJSTHW OG Card Test", // title of the article
"description": "A simple test of the OG style of sharing cards.", // description for inside preview
"image": `${ base_host }/images/zed.png`, // image to display, 5mb/1200/627 max
"url": `${ base_host }/client/#/bando/demos/OGPreview`, // URL to article
"type": "website", // not mentioned on linked in but needed
}
let twitter = {
"card": "summary", // must be summary, summary_large_image, app, player
"creator": "@lzsthw", // @username of content creator
"description": og.description, // max 200 chars
"image": og.image,
"image:alt": "A drawing of Zed by Zed.", // max 420 chars image alt
"site": "@lzsthw", // @username of site
"title": og.title,
}
</script>
<OGPreview og={ og } twitter={ twitter} />
<style>
card {
max-width: 600px;
}
</style>
<card>
<top>
<!-- this is how you assign a set of properties to a component using the {...} syntax -->
<img src="/images/zed.png" />
</top>
<middle>
<h4>OG Twitter Test</h4>
<p>If you visit <a href="https://cards-dev.twitter.com/validator">the Twitter validator tool</a> and enter an internet accessible link to this demo it will show the above image and a description.
</p>
</middle>
</card>

@ -0,0 +1,32 @@
<callout class="warning">
<span>
This is mostly only a demo because Twitter doesn't actually render JavaScript client
side so it won't see these meta tags. If you want this to work, put it on a page in
<code>rendered/pages/</code> and don't Hydrate it.
</span>
</callout>
You can create a special page/link for Twitter, Facebook, and LinkedIn that will render a summary+image for the page. Uses its own preview-card feature [docs](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started) while Facebook and LinkedIn use the [OpenGraph](https://ogp.me/) specification. OpenGraph is much simpler, really only supporting things like links, images, etc. Twitter's is more capable since it allows you to link to images, embed videos, live streams, and link to apps in Apple and Google stores.
The component starts with a base set of attributes for OG and Twitter cards, which you can augment by simply updating the object used. All the component does is walk through the `twiter` or `og` keys and create the necessary `<meta>` tag for the head.
Svelte:Head Sort-of-Bug
===
One thing to keep in mind is if you place these `<meta>` tags directly in a page then they'll get duplicated on each page visit. This is because Svelte is updating the DOM for the page, but seems to not consider the `<svelte:head>` as idempotent as other elements in the DOM. The fix is to move all uses of `<svelte:head>` into a component so Svelte properly updates them on page changes.
The code for `OGPreview.svelte` does this, which means you *do not* put it into a `<svelte:head>`. Instead you do this:
<pre><code data-language="javascript">
&lt;OGPreview og={ og } twitter={ twitter} />
</code></pre>
Then `OGPreview` does everything for you. If you run into this bug in your own development use the `client/components/HeaderOnce.svelte` component like this:
```
<HeaderOnce>
<meta-tags-here>
</HeaderOnce>
```
Those tags will then technically be in a "component" so they'll update as you change pages rather than get duplicated for each page.

@ -0,0 +1,33 @@
<script>
import Pagination from "$/client/components/Pagination.svelte";
let log = [];
let pagination = {
currentPage: 1,
total: 236,
lastPage: 12,
perPage: 20,
from: 20,
to: 80,
}
const page_changed = () => {
log.push(`New page ${pagination.currentPage} of ${pagination.lastPage}`);
log = log;
}
</script>
<p>Total Pages: <input name="page_count" bind:value={ pagination.lastPage } type="number" ></p>
<Pagination bind:pagination on:change={ page_changed }/>
<h3>Change Log</h3>
<p>A simple log of page changes only as a demo of handling the <b>changed</b> callback.</p>
<ul>
{#each log as line}
<li>{ line }</li>
{/each}
</ul>

@ -0,0 +1 @@
A reasonably complete `Pagination` system that will work with the [knex-paginate by felixmosh](https://github.com/felixmosh/knex-paginate) module. It tries to replicate the behavior of pagination system found on many websites that abreviates the pages when you reach the middle but still allow for jumping to the first or last.

@ -0,0 +1,6 @@
<script>
import Panels from "$/client/components/Panels.svelte";
</script>
<Panels />

@ -0,0 +1,21 @@
<script>
import PlaceHolder from "$/client/components/PlaceHolder.svelte";
let width=640;
let height=480;
</script>
<style>
span {
display: flex;
flex-direction: row;
justify-content: space-evenly;
}
</style>
<span>
<input bind:value={ width } placeholder="Width"> X
<input bind:value={ height } placeholder="Height">
</span>
<PlaceHolder width={ width } height={ height }/>

@ -0,0 +1,3 @@
The `PlaceHolder` is a simple JavaScript generated placeholder image for when you're starting your
design. I prefer the `ImageIcon` instead of this one since it gives a better start and feedback on
the design.

@ -0,0 +1,33 @@
<script>
import ProgressMeter from "$/client/components/ProgressMeter.svelte";
let progress = 50;
</script>
<style>
progress {
width: 50%;
}
meter {
width: 50%;
}
input {
width: 5ch;
}
</style>
<p>HTML has a default <b>progress</b> tag you can use and it looks like this:</p>
<progress value={ progress } max="100"></progress>
<p>There's also <b>meter</b> tags that look a little different.</p>
<meter value={ progress } max="100">
</meter>
<p>Since those tags are already taken, ours will be called...<b>progress-meter</b>!</p>
<ProgressMeter percent={ progress } />
<br/>
<input placeholder="Percent Progress" type="numeric" bind:value={ progress } />

@ -0,0 +1,13 @@
The `Progress` demo uses either the standard tags found in browsers, or the
`client/components/Progress.svelte` component if you want a simple custom progress meter.
Your OS (or browser) should have native controls for progress, but there are two HTML tags
available:
1. `<progress>` -- It shows a ... progress.
2. `<mete>` -- It shows a ... meter which is apparently...totally different?
Go with whatever works for you, and if you want to altern the look then use the `<Progress/>` tag.
In the `static/global.css` file this is listed as a `<progress-meter>` tag to void conflicts with
`<progress>` or `<meter>`.

@ -0,0 +1,26 @@
<script>
import Sidebar from "$/client/components/Sidebar.svelte";
export let menu = [
{title: "Calendar", active: false, icon: "calendar"},
{title: "Cards", active: false, icon: "credit-card"},
{title: "Countdown", active: false, icon: "clock"},
{title: "Darkmode", active: false, icon: "sunrise"},
{title: "Form", active: false, icon: "database"},
{title: "Video", active: false, icon: "video"},
{title: "Sidebar", active: false, icon: "sidebar"},
];
</script>
<main>
<Sidebar menu={ menu }>
<div slot="top">
<h1>Header</h1>
</div>
<div slot="bottom">
<h3>Footer</h3>
</div>
</Sidebar>
</main>

@ -0,0 +1,6 @@
This `Sidebar` is a very simple version of the ones you find online. It doesn't support nested
accordion style structures and only a simple list of Icon+Title elements.
Look in `admin/bando/Components.svelte` to see a real use of the sidebar to load components
dynamically. It uses `<svelte:component>` to render the chosen component, which comes up quite
often in applications.

@ -0,0 +1,35 @@
<script>
import Icon from "$/client/components/Icon.svelte";
export let menu = [
{title: "Calendar", active: false, icon: "calendar"},
{title: "Cards", active: false, icon: "credit-card"},
{title: "Countdown", active: false, icon: "clock"},
{title: "Darkmode", active: false, icon: "sunrise"},
{title: "Form", active: false, icon: "database"},
{title: "Video", active: false, icon: "video"},
{title: "Sidebar", active: false, icon: "sidebar"},
];
</script>
<main>
<sidebar>
<top><h1>CSS Only</h1></top>
<items>
{#each menu as item}
<a data-testid="sidebar-link-{ item.title }"
class:with-icon={ item.icon !== undefined }
class:without-icon={ !item.icon}
class:active={ item.active }
href="/client/#/bando/components/SidebarCSS/">
{#if item.icon}
<Icon size="24" name={ item.icon } light={ item.active } /> <span class="with-icon">{ item.title }</span>
{:else}
<span class="without-icon">{ item.title }</span>
{/if}
</a>
{/each}
</items>
</sidebar>
</main>

@ -0,0 +1,3 @@
This is an example of using the CSS directly for a Sidebar. You need this in any situation where you can't create a set of components to put inside the `Sidebar` component. It replicates the menu in the [Svelte version](/client/#/bando/components/Sidebar/) using the css from the `static/global.css` file.
It also shows how to enable the collapsing down to "icon only mode" using the `with-icon` or `without-icon` classes. Try changing some of the `icon` properties int he `menu` variable to see how that works. Also try shrinking the window down so it's less than 600px wide to see how the icons replace the text.

@ -0,0 +1,15 @@
<script>
import SnapImage from "$/client/components/SnapImage.svelte";
const image = "https://learnjsthehardway.com/media/sample_image.jpg";
let source = "";
const colors = [[44, 77, 26], [127, 187, 60], [189, 160, 115], [88, 153, 44], [25, 31, 14], [124, 151, 61], [89, 115, 43], [65, 122, 34], [108, 149, 173], [89, 119, 109]];
</script>
<SnapImage width="1600" height="900" colors={ colors } src={ source } />
<br />
<button on:click={ () => source = image }>Click to Load</button>

@ -0,0 +1,29 @@
`SnapImage` creates a temporary image using SVG that looks _kind of_ like the incoming image, and
when that image finally loads fades it into view. This helps with _perceived_ render speed in the
following ways:
1. The aspect ratio of the image is placed before the page loads, reducing or eliminating render shift.
2. A block of responsive SVG loads instantly which tricks the browser into fully rendering the whole page making it seem faster.
3. The rendered colors look enough like the image for the brief time they're present that people percieve the image as loading faster even when it's not.
By default `SnapImage` will use a set of random monochrome colors if none are given. You can change the options used to generate the random colors by setting `random_options` like this:
```
random_options = {
hue: "monochrome",
"format": "rgbArray",
"luminosity": "random",
count: 10
}
```
These options come from the [randomColor project by davidmerfield](https://github.com/davidmerfield/randomColor) which is used in the project for all random color generation.
Generating Image Colors
===
To generate your own colors there's a tiny script I'm using:
<callout class="warning">
<span>I need to find this code actually. Stay tuned.</span>
</callout>

@ -0,0 +1,13 @@
<script>
import Spinner from "$/client/components/Spinner.svelte";
</script>
<p>Fairly basic, just an SVG that's rotated using a simple CSS animation.</p>
<Spinner />
<h2>Using aspect_ratio</h2>
<p>You can give an aspect ratio and the spinner will create a <code>&lt;div&gt;</code> with the <code>--aspect-ratio:</code> set from the <code>static/gobal.css</code> CSS. This will make it work as a compliment to the <code>.stacked</code> and <code>.layer</code> CSS so you can have a simple loading spinner over another component.
</p>
<Spinner aspect_ratio="16/9" />

@ -0,0 +1,16 @@
The internet is slow, but Google says your website should be fast. Google can't meet their own
standards but that doesn't matter. All that matters is there's now a cost added to creating a
website because Google will punish _your_ content for loading slowly while _their_ content (and the
content of their buddies) can load slowly.
The Spinner helps tell users that a page is loading and it's typically used with `{#await}` and the
`client/helpers.js:defer()` function.
<callout class="warning">
<span>Find or create examples in the project for how to do this.
</span>
</callout>
Adding an `aspect_ratio` also helps you with page load speed because the page renders, it has less
layout shift, and there's a notification that it's done. Best of all the spinning is done in CSS
which doesn't count toward "execution" in Lighthouse.

@ -0,0 +1,39 @@
<script>
let first_top = true;
</script>
<style>
display {
width: 100%;
border: 1px solid var(--value0);
display: flex;
flex-direction: row;
}
first {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--red);
color: var(--value8);
}
second {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--yellow);
}
</style>
<display class="stacked" style="--aspect-ratio: 10/3;">
<first class="layer" class:top={ first_top }>
<h1>I Am On Top</h1>
</first>
<second class="layer" class:top={ !first_top }>
<h1>I Am On Bottom</h1>
</second>
</display>
<br/>
<button on:click={() => first_top = !first_top}>Swap Layers</button>

@ -0,0 +1,25 @@
Layers with different opacity are a staple of digital compositing systems but weirdly CSS is terrible at it. The stack/layer CSS in `static/global.csS` implements a simple method using `display: grid` to place elements into a single cell. The CSS grid system will then correctly layer each element in a stack. The elements also are set to `position:relative` so you can change their `z-index` to shuffle them around. Alternatively, you can simply set one of the children to `class="top"` and that one element will be on top.
If you don't set a `background-color` color then the layers will be transparent. You can also use `opacity` to hide or fade the layers in the stack.
The CSS that is making this work is below (but look in `static/global.css` to confirm it's still done this way.
```
.stacked &lbrace;
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-template-areas: "cover";
}
.stacked .layer &lbrace;
width: 100%;
position: relative;
grid-area: cover;
}
.stacked .top &lbrace;
z-index: 10;
}
```

@ -0,0 +1,48 @@
<style>
</style>
<script>
import Icon from "$/client/components/Icon.svelte";
</script>
<p>Basic demo that's just on/off.</p>
<input class="switch" type="checkbox" id="demo-1">
<label class="switch" for="demo-1"></label>
<p>Demo with Yes/No text.</p>
<input class="switch" type="checkbox" id="demo-2">
<label class="switch" for="demo-2">
<active>Yes</active>
<inactive>No</inactive>
</label>
<p>Demo showing you can change the size.</p>
<input class="switch" type="checkbox" id="demo-3">
<label class="switch medium" for="demo-3">
<active>Yes</active>
<inactive>No</inactive>
</label>
<input class="switch" type="checkbox" id="demo-4">
<label class="switch large" for="demo-4">
<active>Yes</active>
<inactive>No</inactive>
</label>
<p>Demo with Icons in rather than text.</p>
<input class="switch" type="checkbox" id="demo-5">
<label class="switch" for="demo-5">
<active><Icon name="eye" size="18px" /></active>
<inactive><Icon name="eye-off" size="18px" /></inactive>
</label>
<input class="switch" type="checkbox" id="demo-6">
<label class="switch medium" for="demo-6">
<active><Icon name="eye" size="24px" /></active>
<inactive><Icon name="eye-off" size="24px" /></inactive>
</label>
<input class="switch" type="checkbox" id="demo-7">
<label class="switch large" for="demo-7">
<active><Icon name="eye" size="36px" /></active>
<inactive><Icon name="eye-off" size="36px" /></inactive>
</label>

@ -0,0 +1,4 @@
I generally think you shouldn't use this `Switch` as it's probably not going to work with screen
readers and will mostly just confuse users. I have it here as a demonstration of _how_ to alter an
element by _hiding_ it, but using its state to change the CSS of another element. This is a deep
CSS trick, which I hope you won't really need that often.

@ -0,0 +1,22 @@
<script>
import Tabs from "$/client/components/Tabs.svelte";
import Calendar from "./Calendar.svelte";
import Cards from "./Cards.svelte";
import Login from "./Login.svelte";
import { log } from "$/client/logging.js";
export let panels = [
{title: "Calendar", active: true, icon: "calendar", component: Calendar},
{title: "Cards", active: false, icon: "credit-card", component: Cards},
{title: "Login", active: false, icon: "log-in", component: Login},
];
let selected = panels[0];
const tab_select = (event) => {
log.debug("SELECTED TAB", event.detail);
}
</script>
<Tabs panels={ panels } on:select={ tab_select } bind:selected />

@ -0,0 +1,5 @@
`Tabs` give you the common tabbed layout found in many applications for decades. The `Accordion`
demo just uses the `Tab` component with `vertical={true}`. It needs other Svelte components to
place into the tab panels, but it's implemented mostly in CSS. That means if you need to do a
different presentatino or can't use components for the panels then you can use the `<tab>` from
`static/global.css`.

@ -0,0 +1,32 @@
<script>
import Icon from '$/client/components/Icon.svelte';
</script>
<style>
content {
display: flex;
flex: flex-shrink;
flex-grow: 1;
justify-content: center;
}
</style>
<content>
<tile>
<left>
<Icon name="user" size="48" />
</left>
<middle>
<h4>
Tile Example
</h4>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
</middle>
<right>
<button><Icon name="check-circle" color="var(--color-bg)" size="24" /></button>
<button><Icon name="x-circle" color="var(--color-bg)" size="24"/></button>
</right>
</tile>
</content>

@ -0,0 +1 @@
This is a CSS only component that simply provides a classic "tile" layout. A tile seems to be organized with a left side that has an image/icon, a middle with content, and a right with buttons or other things. You don't need Svelte to make this work, and actually trying to use Svelte ends up making it more complex than just using CSS. To see how you use this just look in `admin/bando/demos/Tiles.svelte`.

@ -0,0 +1,35 @@
<script>
import Icon from "$/client/components/Icon.svelte";
import { fade } from "svelte/transition";
let bottom=true;
let top=false;
let right=true;
let left=false;
let active=true;
let reverse = false;
let toasts = ["Toast 1"];
const add_toast = () => {
toasts.push(`Toast ${toasts.length + 1}`);
toasts = toasts;
}
</script>
<span on:click={ add_toast}><Icon name="plus" size="48" /></span>
<span on:click={ () => left = !left }><Icon name="arrow-left" size="48" inactive={!left} /></span>
<span on:click={ () => right = !right }><Icon name="arrow-right" size="48" inactive={!right} /></span>
<span on:click={ () => bottom = !bottom }><Icon name="arrow-down" size="48" inactive={!bottom}/></span>
<span on:click={ () => top = !top }><Icon name="arrow-up" size="48" inactive={!top}/></span>
<span on:click={ () => reverse = !reverse }><Icon name={ reverse ? "chevrons-up" : "chevrons-down" } size="48" /></span>
<toast-list class:bottom class:top
class:left class:right class:active class:reverse>
{#each toasts as toast}
<toast transition:fade|local>
<span>{ toast }</span>
</toast>
{/each}
</toast-list>

@ -0,0 +1,4 @@
A toast is a little pop-up that slides or fades in from the edge of the screen. It's used to notify of events without getting in the way of the UI. Use the arrow buttons to change the location of the `toast-list` and the plus to add more toasts. Use the chevrons to change the direction toasts are filled in.
This demo starts with the toasts in the most common position on the bottom-right. You can use the
arrows to test out different combinations and then hit _+_ to add random toasts.

@ -0,0 +1,19 @@
<script>
import Icon from "$/client/components/Icon.svelte";
import Toasts from "$/client/components/Toasts.svelte";
let orientation="bottom right";
let toasts = [];
let send_toast;
let fade_after = 5000;
</script>
<span on:click={ () => send_toast(`New Toast ${ toasts.length }`) }><Icon name="plus" size="48" /></span>
<span on:click={ () => orientation = "bottom left" }><Icon name="arrow-down-left" size="48" /></span>
<span on:click={ () => orientation = "bottom right" }><Icon name="arrow-down-right" size="48" /></span>
<span on:click={ () => orientation = "top left" }><Icon name="arrow-up-left" size="48" /></span>
<span on:click={ () => orientation = "top right" }><Icon name="arrow-up-right" size="48" /></span>
<input name="fade" placeholder="Fade Time (ms)" bind:value={ fade_after }>
<Toasts bind:toasts bind:send_toast orientation={ orientation } fade_after={ fade_after } />

@ -0,0 +1,21 @@
This demonstrates how to use the simple `Toasts` component, but it *also* shows how to use a function provided by a component in Svelte. The trick is to realize that functions are variables, and that Svelte will let you _bind_ variables. You then simply have to do:
```
let send_toast;
```
In your script, and in your use of the component (`Toasts`) bind it like this:
```
<Toasts bind:send_toast />
```
Now when the Toasts component is loaded it will set _your_ `send_toast` variable to the function so you can call it. In your component then you simple do:
```
export const send_toast = () => {
// code here
}
```
Which will export it like a variable, but set it to `const` so Svelte doesn't complain that you aren't using it.

@ -0,0 +1,66 @@
<style>
boxes {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
grid-row-gap: 2rem;
grid-column-gap: 2rem;
}
box {
width: 100%;
background-color: var(--color-bg-inverted);
color: var(--color-text-inverted);
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 0.5rem;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
}
</style>
<p>Hover over each of these boxes to see the tooltip.</p>
<boxes>
<box>
<h1>top-right</h1>
<tooltip>This thing works?</tooltip>
</box>
<box>
<h1>top-left</h1>
<tooltip class="top-left">This thing works?</tooltip>
</box>
<box>
<h1>bottom-right</h1>
<tooltip class="bottom-right">This thing works?</tooltip>
</box>
<box>
<h1>bottom-left</h1>
<tooltip class="bottom-left">This thing works?</tooltip>
</box>
<box>
<h1>right</h1>
<tooltip class="right">This thing works?</tooltip>
</box>
<box>
<h1>left</h1>
<tooltip class="left">This thing works?</tooltip>
</box>
<box>
<h1>top</h1>
<tooltip class="top">This thing works?</tooltip>
</box>
<box>
<h1>bottom</h1>
<tooltip class="bottom">This thing works?</tooltip>
</box>
</boxes>

@ -0,0 +1,12 @@
`Tooltip` simply displays a little overlay message on a component. It's best to not use this too
much as it can clutter your UI significantly. Try to find a way to not need the tooltip.
You can put a tooltip on a word using:
```
<word>tooltip word test<tooltip>I hover</tooltip></word>
```
Which works like this:
<word>tooltip word test<tooltip>I hover</tooltip></word>

@ -0,0 +1,16 @@
<script>
import Video from "$/client/components/Video.svelte";
let source = "https://learnjsthehardway.com/media/sample_video.mp4";
let poster = "https://learnjsthehardway.com/media/sample_image.jpg";
let video_background = "rgb(0,0,0)";
</script>
<style>
container {
display: flex;
}
</style>
<container style="--aspect-ratio: 16/9;">
<Video poster={ poster } background_color={video_background } source={ source } />
</container>

@ -0,0 +1,5 @@
The `Video` component can handle both static video downloads and streaming using HLS. On platforms that support HLS video it will use the native `<video>` tag, but if the platform doesn't support it then it will fall back to [HLS.js](https://github.com/video-dev/hls.js/). Internally it uses the `client/fsm.js` code to keep everything straight while it juggles all the byzantine errors you have with videos.
Most of the options exported by `Video.svelte` make sense but the `aspect_ratio` setting is probably one you'll want to adjust if your videos aren't 16:9. This setting uses the CSS in `static/global.css` that uses `--aspect-ratio` to make sure the container holding the video stays at the correct aspect ratio. Without this setting the video's container will do weird things at different screen sizes.
This demo also shows how the countdown timer works with `starts_on` which is useful for live streaming situations where shows start at specific times.

@ -0,0 +1,23 @@
<script>
import WTVideo from "$/client/components/WTVideo.svelte";
let media = {
"src": "https://learnjsthehardway.com/media/sample_video.mp4",
"filename": "sample.mp4",
"poster": "https://learnjsthehardway.com/media/sample_image.jpg",
"preload": "none",
"torrent_url": "https://learnjsthehardway.com/media/sample_video.mp4.torrent"
}
</script>
<style>
container {
display: flex;
flex-direction: column;
margin-right: 0.5rem;
}
</style>
<container class="pattern-lines-sm">
<WTVideo media={ media } />
</container>

@ -0,0 +1,24 @@
The [WebTorrent](https://webtorrent.io) project provides a way for websites to
share the bandwidth load with the users viewing content. It works on almost any
content, but the first thing I I implemented is for Video.
This will most likely *only* work if you have your server on `127.0.0.1` since the torrent file is configured for that. Also, the default `npm run dev` will run the simple tracker implementation found in `services/tracker.js`. If you want to generate the torrents again then run:
```
sh scripts/mktorrents.sh sample.mp4
```
It uses the [mktorrents](https://github.com/pobrn/mktorrent) to generate the torrent since it is faster. Be careful of the WebTorrent specific options I use in the ``services/mktorrents.sh`` that are needed for WebTorrent to understand the torrent:
* `-p` -- Sets the private flag. This isn't needed for WebTorrent but is
needed if you want to add extra protections to people accessing your content
only after they log in.
* `-l 15` -- Sets the piece length to 15.
* `-w 127.0.0.1` -- Sets an initial web URL to use as a source. You
should remove this if you want them to only go through the tracker and other
users.
Download Link
===
If you want to let people download the video then add the `download={ true }` property. This demo sets that option so you can see how it works.

@ -0,0 +1,10 @@
import App from '$/admin/App.svelte';
const app = new App({
target: document.body,
props: {
}
});
export default app;

@ -0,0 +1,66 @@
<script>
import { push, link } from 'svelte-spa-router';
import { onMount } from "svelte";
import Icon from "$/client/components/Icon.svelte";
import Layout from "$/admin/Layout.svelte";
import Form from "$/client/components/Form.svelte";
import api from "$/client/api.js";
import { fade } from "svelte/transition";
import { defer } from "$/client/helpers.js";
export let params = {};
let data = {};
let error = "";
let schema = {};
const load_promise = defer();
onMount(async () => {
schema = await api.schema(params.table);
if(schema === undefined) {
error = "Failed to load schema.";
load_promise.reject();
} else {
load_promise.resolve();
}
});
const create_record = async () => {
let [status, row] = await api.put(`/api/admin/table?name=${params.table}`, data);
if(status == 200) {
error = "";
push(`/table/${params.table}/${row.id}/`);
} else if(status == 401) {
window.location = "/client/#/login";
} else {
error = "Failed saving record.";
data = row;
}
}
</script>
<Layout authenticated={ true } testid="admin-create-page">
{#await load_promise}
... loading ...
{:then}
<Form data={data} table={params.table} schema={ schema }>
<a data-testid="button-back" href="/table/{ params.table }" use:link>
<Icon name="arrow-left-circle" tooltip="Back to table." size="48" />
</a>
<span data-testid="button-create" on:click={ create_record }>
<Icon name="save" tooltip="Create." size="48" />
</span>
</Form>
{/await}
{#if error}
<toast-list class="bottom right active">
<toast transition:fade|local>
<error>{error}</error>
</toast>
</toast-list>
{/if}
</Layout>

@ -0,0 +1,286 @@
<script>
import { link } from 'svelte-spa-router';
import { onMount } from 'svelte';
import Layout from '$/client/Layout.svelte';
import Icon from '$/client/components/Icon.svelte';
import Code from "$/client/components/Code.svelte";
import HTML from "$/client/components/HTML.svelte";
import api from "$/client/api.js";
let index = {};
let docs_data;
let url = "/docs/";
export let params;
const caps_to_icon = {
"BUG": "bug",
"TODO": "clipboard-check",
"WARNING": "alert-triangle",
"FOOTGUN": "bomb",
"DEPRECATED": "axe"
}
const jump = (id) => {
let node = document.getElementById(id);
if(node) node.scrollIntoView();
}
const jump_top = () => jump("top-scroll");
const load_docs = async (to_load) => {
// only load if the URL changed
if(to_load !== url) {
const [status, data] = await api.get(`/docs/api/${to_load}.json`);
docs_data = status === 200 ? data : {};
url = to_load;
jump_top();
}
}
const load_index = async () => {
const [status, data] = await api.get("/docs/api/index.json");
index = status === 200 ? data : {};
}
const type_to_syntax = {
"callexpression": "()",
"objectexpression": "{}",
"function": "()",
"class": "{}",
"method": "()"
}
onMount(load_index);
$: if(params.wild) {
// load_docs will ignore any calls where params.wild doesn't change url
load_docs(params.wild)
} else {
url = "/docs/";
}
</script>
<style>
sidebar {
max-width: 300px;
min-height: 100vh;
height: 100vh;
max-height: 100vh;
margin: unset;
}
sidebar items {
overflow-y: auto;
}
right {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
max-height: 100vh;
overflow-y: auto;
width: calc(100vw - 300px);
min-width: calc(100vw - 300px);
max-width: calc(100vw - 300px);
}
right > div {
padding: 0.5rem;
}
export {
border-top: 1px solid var(--value5);
padding: 0.5rem;
}
export.member {
padding-left: 1.5rem;
}
export.class-def {
background-color: var(--value7);
}
export > heading {
display: flex;
flex-direction: column;
width: 100%;
margin-bottom: 1rem;
}
export > heading h4,
export > heading h2,
export > heading h1
{
margin-top: 0.5rem;
font-family: var(--font-family);
font-weight: 600;
}
export > heading meta-data {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto;
}
export > info {
display: block;
padding-top: 0.5rem;
}
export > info commment {
display: block;
}
/* FOOTGUN: https://github.com/sveltejs/svelte/issues/2967
When you @html in Svelte it can't apply any styles from here.
You have to scope the a tag style inside module-header, BUT,
set it to global so Svelte will apply it to the whole doc,
not its special class.
*/
module-header :global(a) {
color: var(--color-text-inverted);
}
module-header {
display: block;
padding: 0.5rem;
background-color: var(--color-bg-inverted);
color: var(--color-text-inverted);
}
module-header :global(h2),
module-header :global(h3),
module-header :global(h4) {
margin-top: 3rem;
}
toc {
display: grid;
grid-template-columns: repeat(5, 1fr);
background-color: var(--value3);
color: var(--value9);
}
toc span {
border: 1px solid black;
padding: 0.5rem;
text-align: center;
}
.no-doc {
background-color: var(--color-error);
}
</style>
<Layout footer={ false} header={ false } centered={ true } fullscreen={ true }>
<sidebar>
<top><h4><a href="/" use:link><Icon name="arrow-left-circle" /> API Docs</a> </h4></top>
<items>
<a class:active={ url === "/docs/" } href="/docs/" use:link>Introduction</a>
{#each Object.keys(index) as item}
<a class:active={ item === url } href="/docs/{item}" use:link>{item}</a>
{/each}
</items>
</sidebar>
<right>
{#if url === "/docs/"}
<div id="top-scroll">
<HTML url="/docs/api/index.html" />
</div>
{:else if docs_data}
<toc id="top-scroll">
{#each docs_data.exports as exp}
<span class:no-doc={ !exp.comment } on:click={ () => jump(exp.slug) }>{ exp.name }
{#each exp.caps as cap_word}<Icon name={ caps_to_icon[cap_word] } size="24" light={ true }/>{/each}
</span>
{#if exp.isa == "class"}
{#each exp.methods as member}
<span class:no-doc={ !member.comment } on:click={ () => jump(member.slug) }>.{ member.name }
{#each member.caps as cap_word}<Icon name={ caps_to_icon[cap_word] } size="24" light={ true } />{/each}
</span>
{/each}
{/if}
{/each}
</toc>
<module-header>
<h1>{ url }</h1>
{#if docs_data.comment}
{@html docs_data.comment}
{/if}
</module-header>
{#each docs_data.exports as exp}
{#if exp.isa === "class"}
<export class="class-def">
<heading>
<h2 on:click={ () => jump_top() } id={ exp.slug }>class { exp.name }</h2>
<Code content={ exp.code } language="javascript" />
</heading>
<comment>
{#if exp.comment}
{@html exp.comment}
{/if}
</comment>
</export>
{#each exp.methods as member}
<export class="member">
<heading>
<h4 id={ member.slug } on:click={ () => jump(exp.slug) }>.{member.name}{ type_to_syntax[member.isa] || "" }
</h4>
<meta-data>
{docs_data.source}:{ member.line_start }
<em>{ member.isa } of { exp.name }</em>
{#if member.static}<b>static</b>{/if}
{#if member.async}<b>async</b>{/if}
{#if member.generator}<b>generator</b>{/if}
</meta-data>
</heading>
<info>
{#if member.comment}
<comment>
{ @html member.comment }
</comment>
{/if}
<Code content={ member.code } language="javascript" />
</info>
</export>
{/each}
{:else}
<export>
<heading>
<h4 on:click={ () => jump_top() }
id={ exp.slug }>{exp.name}{ type_to_syntax[exp.isa] || "" }
</h4>
<meta-data>
{docs_data.source}:{ exp.line_start }
<em>{ exp.isa }</em>
{#if exp.static}<b>static</b>{/if}
{#if exp.async}<b>async</b>{/if}
{#if exp.generator}<b>generator</b>{/if}
</meta-data>
</heading>
<info>
{#if exp.comment}
<comment>
{@html exp.comment}
</comment>
{/if}
<Code content={ exp.code } language="javascript" />
</info>
</export>
{/if}
{/each}
{/if}
</right>
</Layout>

@ -0,0 +1,84 @@
<script>
import Sidebar from "$/client/components/Sidebar.svelte";
import { link } from "svelte-spa-router";
import Layout from '$/admin/Layout.svelte';
import Icon from "$/client/components/Icon.svelte";
import EmailDNS from "./EmailDNS.svelte";
import EmailSend from "./EmailSend.svelte";
export let params = {};
let panels = [
{title: "Send Test", active: false, icon: "send", component: EmailSend },
{title: "DNS Test", active: false, icon: "cast", component: EmailDNS },
]
const select_named = () => {
if(params.name) {
return panels.find(p => p.title === params.name) || panels[0];
} else {
return panels[0];
}
}
let selected = select_named();
selected.active = true;
const sidebar_select = (event) => {
const {index, item} = event.detail;
selected = item;
panels = panels.map((x, i) => {
x.active = i == index;
return x;
});
}
</script>
<style>
div[slot="top"] span {
display: none;
}
contents {
padding: 0.5rem;
width: 100%;
max-height: 100vh;
overflow-y: auto;
}
left {
display: flex;
height: 100vh;
max-height: 100vh;
min-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
max-width: min-content;
width: min-content;
min-width: min-content;
background-color: var(--color-bg-secondary);
}
</style>
<Layout fullscreen={ true } header={ true } footer={false } authenticated={ true } testid="page-email-config">
<left>
<Sidebar on:select={ sidebar_select } menu={ panels }>
<div slot="top">
<h3><a href="/" use:link><Icon name="home" /> Email Admin</h3>
<span><Icon name="home" size="36" /></span>
</div>
<div slot="bottom">
<p>Be careful where you send email.</p>
</div>
</Sidebar>
</left>
<contents>
<h1>{selected.title}</h1>
<svelte:component this={selected.component} />
</contents>
</Layout>

@ -0,0 +1,147 @@
<script>
import api from "$/client/api.js";
import Spinner from "$/client/components/Spinner.svelte";
import Icon from "$/client/components/Icon.svelte";
let dns;
let domain_name;
let error = "";
let tests;
let querying = false;
const activate = (id) => {
tests = tests.map(r => {
r.active = r.id == id;
return r;
});
}
const run_test = async () => {
if(domain_name) {
querying = true;
const [status, data] = await api.get('/api/admin/email', {domain_name});
querying = false;
if(status === 200) {
dns = data.dns;
tests = data.tests;
error = "";
} else {
error = data.message || data._errors.domain_name[0];
dns = undefined;
tests = undefined; // clear the test results
}
} else {
error = "You must enter a valid domain name.";
}
}
</script>
<style>
domain-input {
display: flex;
}
form {
display: flex;
flex-direction: row;
width: 100%;
}
tr.error {
color: var(--red);
}
td {
text-align: left;
max-width: 20%;
min-width: 20%;
width: 20%;
}
td.data {
width: 80%;
word-break: break-all;
word-wrap: normal;
white-space: normal;
}
table {
width: 100%;
}
</style>
<domain-input data-testid="domain-input">
<form on:submit|preventDefault={ run_test } action="/api/admin/email" method="GET">
<input name="domain_name" bind:value={ domain_name } id="domain_name" placeholder="Domain Name" />
{#if querying}<Spinner />{/if}
</form>
</domain-input>
<h2>DNS Results { domain_name || "" }</h2>
{#if dns }
<table data-testid="domain-result">
<thead>
<tr><th>Result</th><th>Value</th></tr>
</thead>
<tbody>
{#if dns.ip4.host }
<tr><td>IP4 Host</td><td class="data">{ dns.ip4.host[0] }</td></tr>
<tr><td>IP4 Reverse</td><td class="data">{ dns.ip4.reverse[0] }</td></tr>
{:else}
<tr data-testid="error-dns" class="error"><td>IP4 Error</td><td class="data">{ dns.ip4.error.message }</td></tr>
{/if}
{#if dns.ip6.host}
<tr><td>IP6 Host</td><td class="data">{ dns.ip6.host[0] }</td></tr>
<tr><td>IP6 Reverse</td><td class="data">{ dns.ip6.reverse[0] }</td></tr>
{:else}
<tr class="error"><td>IP6 Error</td><td class="data">{ dns.ip6.error.message }</td></tr>
{/if}
{#if dns.mx}
<tr><td>MX Exchange</td><td class="data">{ dns.mx[0].exchange }</td></tr>
<tr><td>MX Priority</td><td class="data">{ dns.mx[0].priority }</td></tr>
{:else}
<tr class="error"><td>MX Error</td><td class="data">{ dns.mx_error.message }</td></tr>
{/if}
{#if dns.spf}
<tr><td>SPF</td><td class="data">{ dns.spf[0] }</td></tr>
{:else}
<tr class="error"><td>SPF</td><td class="data">{ dns.spf_error.message }</td></tr>
{/if}
{#if dns.dmarc}
<tr><td>DMARC</td><td class="data">{ dns.dmarc[0] }</td></tr>
{:else}
<tr class="error"><td>DMARC</td><td class="data">{ dns.dmarc_error.message }</td></tr>
{/if}
</tbody>
</table>
{:else if error}
<callout class="error">{ error }</callout>
{:else}
<callout>Enter a domain name to see the results.</callout>
{/if}
{#if tests && tests.length > 0}
<h2>DNS Analysis</h2>
<p>The following are recommendations based on errors found in your DNS records for { domain_name }.</p>
<tabs class="vertical">
{#each tests as result}
<a class:active={ result.active } on:click={() => activate(result.id)}><Icon name="alert-triangle" /> { result.title }</a>
<component>
{#if result.active}
<p>{ result.text }</p>
{/if}
</component>
{/each}
</tabs>
{:else if tests && tests.length == 0}
<h2>DNS Is Good</h2>
<p>No common errors found so far in your DNS records. You should try sending yourself
an email to test if it will deliver. These tests are performed against Googles public DNS at 8.8.8.8 so they should be visible in Gmail now.
</p>
{/if}

@ -0,0 +1,61 @@
<script>
import api from "$/client/api.js";
import FormField from '$/client/components/FormField.svelte';
let form = {
to_address: "",
}
let error;
let message;
let config;
const send_test = async () => {
const [status, data] = await api.post(`/api/admin/email`, form);
if(status == 200) {
message = data.message;
config = data.config;
} else {
error = data.message;
form = Object.assign(form, data);
}
}
</script>
<form>
<card>
<middle>
<FormField form={ form } field="to_address" label="To Email">
<input type="text" id="to_address" bind:value={form.to_address } name="to_address">
</FormField>
{#if message}
<br/>
<callout class="info" data-testid="result-message">
<span>{ message }</span>
</callout>
{/if}
</middle>
<bottom>
<button-group>
<button data-testid="send-button" on:click|preventDefault={ () => send_test() }>Send</button>
</button-group>
</bottom>
</card>
</form>
{#if error}
<callout class="error">
<span>Fatal Error: { error }</span>
</callout>
{/if}
{#if config}
<h2>Mail Server Config</h2>
<pre>
<code>
{JSON.stringify(config, null, 2)}
</code>
</pre>
{/if}

@ -0,0 +1,103 @@
<script>
import { onMount } from 'svelte';
import Icon from '$/client/components/Icon.svelte';
import { link } from 'svelte-spa-router';
import Code from '$/client/components/Code.svelte';
import Spinner from '$/client/components/Spinner.svelte';
import { log } from "$/client/logging.js";
import api from '$/client/api.js';
import Layout from "$/admin/Layout.svelte";
import { defer } from "$/client/helpers.js";
let errors;
let error_selected;
const load_promise = defer();
const load_info = async () => {
let [status, data] = await api.get('/api/devtools/info');
if(status == 200) {
errors = data.errors;
if(errors.length > 0) {
error_selected = errors[0];
}
load_promise.resolve();
} else {
log.debug("failed to get info", status);
}
}
onMount(async () => {
await load_info();
});
</script>
<style>
/** Sidebar style can be found in static/global.css */
sidebar.fixed {
overflow-y: auto;
max-height: calc(100vh - var(--fixed-header-height));
min-height: calc(100vh - var(--fixed-header-height));
height: calc(100vh - var(--fixed-header-height));
}
display {
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
max-height: calc(100vh - var(--fixed-header-height));
min-height: calc(100vh - var(--fixed-header-height));
height: calc(100vh - var(--fixed-header-height));
overflow-y: auto;
}
</style>
<Layout fullwidth={ true } fixed={ true } footer={false} authenticated={ true } testid="page-admin-errors">
{#await load_promise}
<Spinner />
{:then}
<content>
<sidebar class="fixed">
<top>
<h3><a href="/" use:link><Icon name="arrow-left-circle" size="36"/></a> Errors</h3>
</top>
<items>
{#each errors as error}
<a on:click={ () => error_selected = error }>{ error.location.file}</a>
{:else}
<h3>No Errors</h3>
<span>You currently have no errors that are shown. Try refreshing.</span>
{/each}
</items>
</sidebar>
<display>
{#if error_selected}
<h1>{error_selected.location.file} <Icon name="alert-circle" size="36" /></h1>
<h3>{ error_selected.text }</h3>
<pre>
<code>
{#each error_selected.location.lineText.split('\n') as line, i}
<span class:error={ error_selected.location.line === i + 1}>{ `${line}\n` }</span>
{/each}
</code>
</pre>
<h1>Notes</h1>
{#each error_selected.notes as note}
<h3>{note.location.file}</h3>
<p>{note.text}</p>
<pre>
<code>
{ note.location.line}: {note.location.lineText}
</code>
</pre>
{/each}
{/if}
</display>
</content>
{/await}
</Layout>

@ -0,0 +1,104 @@
<script>
import { onMount } from 'svelte';
import Sidebar from "$/client/components/Sidebar.svelte";
import TableIndex from "$/admin/pages/TableIndex.svelte";
import EmailDNS from "$/admin/pages/EmailDNS.svelte";
import EmailSend from "$/admin/pages/EmailSend.svelte";
import Stats from "$/admin/pages/Stats.svelte";
import IconFinder from "$/admin/bando/IconFinder.svelte";
import Djenterator from "$/admin/bando/Djenterator.svelte";
import Layout from "$/admin/Layout.svelte";
import { replace } from "svelte-spa-router";
export let params={what: "tables"};
let panels = [
{url: "tables", title: "Tables", active: true, icon: "database", component: TableIndex},
{url: "errors", title: "Errors", active: false, icon: "alert-circle", href: "/admin/#/errors/"},
{url: "icons", title: "Icons", active: false, icon: "smile", component: IconFinder},
{url: "demos", title: "Components", active: false, icon: "component", href: "/admin/#/bando/components/"},
{url: "docs", title: "API Docs", active: false, icon: "book", href: "/admin/#/docs/"},
{url: "routes", title: "Routes", active: false, icon: "code-2", href: "/admin/#/routes/"},
{url: "djenterator", title: "Djenterator", active: false, icon: "layout-template", component: Djenterator},
{url: "email-dns", title: "Email DNS", active: false, icon: "mail-check", component: EmailDNS},
{url: "email-send", title: "Email Send", active: false, icon: "mails", component: EmailSend},
{url: "stats", title: "Stats", active: false, icon: "activity", component: Stats},
];
let selected = panels[0];
const sidebar_select = (event) => {
const { index, item } = event.detail;
selected = item;
replace(`/${ selected.url }/`);
panels = panels.map((x, i) => {
x.active = i == index;
return x;
});
}
const url_select = (url) => {
for(let panel of panels) {
if(url === panel.url) {
panel.active = true;
selected = panel;
} else {
panel.active = false;
}
}
}
$:if(selected && params.what && params.what != selected.url) url_select(params.what);
onMount(() => {
if(params.what) {
url_select(params.what);
}
});
</script>
<style>
right {
flex-direction: column;
padding: 0.5rem;
min-height: calc(100vh - var(--fixed-header-height));
max-height: calc(100vh - var(--fixed-header-height));
height: calc(100vh - var(--fixed-header-height));
min-width: calc(100% - 300px);
max-width: calc(100% - 300px);
width: calc(100% - 300px);
overflow-y: auto;
}
left {
display: flex;
max-width: min-content;
min-height: calc(100vh - var(--fixed-header-height));
max-height: calc(100vh - var(--fixed-header-height));
height: calc(100vh - var(--fixed-header-height));
}
content {
padding: 0px !important;
}
</style>
<Layout fullwidth={ true } fixed={ true } footer={false} authenticated={ true } testid="page-admin-{ params.what }">
<content>
<left>
<Sidebar on:select={ sidebar_select } menu={ panels }>
<div slot="top">
<h3>Admin Dashboard</h3>
</div>
</Sidebar>
</left>
<right>
<svelte:component this={selected.component} />
</right>
</content>
</Layout>

@ -0,0 +1,151 @@
<script>
import { push, link } from 'svelte-spa-router';
import { onMount } from 'svelte';
import Layout from "$/admin/Layout.svelte";
import Icon from "$/client/components/Icon.svelte";
import Modal from "$/client/components/Modal.svelte";
import Form from "$/client/components/Form.svelte";
import api from "$/client/api.js";
import { log } from "$/client/logging.js";
import { defer } from "$/client/helpers.js";
import Toasts from "$/client/components/Toasts.svelte";
export let params = {};
let send_toast;
let form_data = {_errors: {}, _valid: false};
let delete_confirm = false;
let load_promise = defer();
let schema = {};
const delete_record = async () => {
let [status, data] = await api.del(`/api/admin/table?name=${params.table}&row_id=${params.row_id}`);
if(status === 200) {
form_data = data;
form_data._errors = {}; // setup for errors later
push(`/table/${params.table}/`);
} else if(status == 401) {
window.location = "/client/#/login";
} else {
send_toast("Failed to delete.");
}
}
const update_record = async () => {
let [status, data] = await api.post(`/api/admin/table?name=${params.table}&row_id=${params.row_id}`, form_data);
log.debug("update record results", data);
if(status == 200) {
send_toast("Update successful.");
} else if(status == 401) {
window.location = "/client/#/login";
} else {
send_toast("Update failed.");
form_data = Object.assign(form_data, data);
}
}
const json_copy = () => {
const text = JSON.stringify(form_data, null, 4);
navigator.clipboard.writeText(text).then(() => {
send_toast("JSON data copied to clipboard");
}, () => {
send_toast("Failed copying to clipboard.");
});
}
onMount(async () => {
schema = await api.schema(params.table);
if(schema === undefined) {
send_toast("Failed to load schema.");
load_promise.reject();
} else {
let [status, data] = await api.get('/api/admin/table', {
name: params.table, row_id: params.row_id
});
if(status == 200) {
form_data = data;
load_promise.resolve();
} else if(status == 401) {
window.location = "/client/#/login";
} else {
sent_toast("Failed to load table data.");
load_promise.reject();
}
}
});
</script>
<style>
card {
background-color: var(--color-bg);
}
card top {
text-align: center;
}
card middle {
padding: 1rem;
}
</style>
<Layout authenticated={ true } testid="page-admin-readupdate">
{#await load_promise}
<!-- form already has a spinner -->
{:then}
<Form data={form_data} table={params.table} schema={ schema }>
<a href="/table/{ params.table }" data-testid="button-back" use:link>
<Icon name="arrow-left-circle" tooltip="Back to table." size="48" />
</a>
<span data-testid="button-copy" on:click={ json_copy }>
<Icon name="copy" tooltip="Copy JSON." size="48" />
</span>
<span data-testid="button-update" on:click={ update_record }>
<Icon name="save" tooltip="Update." size="48" />
</span>
<span data-testid="button-delete" on:click={ () => delete_confirm = true }>
<Icon name="trash" tooltip="Delete!" size="48" />
</span>
</Form>
{:catch}
<callout class="error">
<p>Return to the table:
<a href="/table/{ params.table }" data-testid="button-back" use:link>
<Icon name="arrow-left-circle" tooltip="Back to table." size="48" />
</a>
</p>
</callout>
{/await}
</Layout>
<Toasts bind:send_toast orientation="bottom right"/>
{#if delete_confirm}
<Modal on:close={() => delete_confirm = false }>
<card>
<top>
<h1>Really Delete?</h1>
</top>
<middle>
<p>This action is permanent to make sure it's what you want.</p>
</middle>
<bottom>
<button-group>
<button type="button" data-testid="button-delete-no" on:click={() => delete_confirm = false }>Cancel</button>
<button data-testid="button-delete-yes" on:click={ delete_record }>DELETE</button>
</button-group>
</bottom>
</card>
</Modal>
{/if}

@ -0,0 +1,117 @@
<script>
import { onMount } from 'svelte';
import Icon from '$/client/components/Icon.svelte';
import Code from '$/client/components/Code.svelte';
import Spinner from '$/client/components/Spinner.svelte';
import { log } from "$/client/logging.js";
import api from '$/client/api.js';
import Layout from "$/admin/Layout.svelte";
import { defer } from "$/client/helpers.js";
import { link } from 'svelte-spa-router';
let api_register;
let socket_register;
let api_selected;
let socket_selected;
const load_promise = defer();
const select_item = (maybe_api, maybe_socket) => {
api_selected = maybe_api;
socket_selected = maybe_socket;
}
const load_info = async () => {
let [status, data] = await api.get('/api/devtools/info');
if(status == 200) {
api_register = data.api;
socket_register = data.sockets;
load_promise.resolve();
} else {
log.debug("failed to get info", status);
}
}
onMount(async () => {
await load_info();
});
</script>
<style>
/** Sidebar style can be found in static/global.css */
sidebar.fixed {
overflow-y: auto;
max-height: calc(100vh - var(--fixed-header-height));
min-height: calc(100vh - var(--fixed-header-height));
height: calc(100vh - var(--fixed-header-height));
}
display {
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
max-height: calc(100vh - var(--fixed-header-height));
min-height: calc(100vh - var(--fixed-header-height));
height: calc(100vh - var(--fixed-header-height));
overflow-y: auto;
}
</style>
<Layout fullwidth={ true } fixed={ true } footer={false} authenticated={ true } testid="page-admin-routes">
{#await load_promise}
<Spinner />
{:then}
<content>
<sidebar class="fixed">
<top>
<h3><a href="/" use:link><Icon name="arrow-left-circle" /></a> Routes</h3>
</top>
<items>
<h3>/api</h3>
{#each Object.entries(api_register) as [route, info]}
<a on:click={ () => select_item(info, false, false) }>{route}</a>
{/each}
<h3>sockets</h3>
{#each Object.entries(socket_register) as [route, info]}
<a on:click={ () => select_item(false, info, false) }>{route}</a>
{/each}
</items>
</sidebar>
<display>
{#if api_selected}
<route>
<h1>{ api_selected.name } <Icon name="share-2" size="36" /></h1>
{#each api_selected.functions as func}
<h1>{ func.name }</h1>
<Code language="javascript" content={ func.code.trim() } />
{/each}
</route>
{:else if socket_selected}
<h1>{ socket_selected.target_name } <Icon name="radio" size="36" /></h1>
<table>
<thead>
<tr><th>route_path</th><th>target_name</th><th>file_name</th></tr>
</thead>
<tbody>
<tr>
<td>{ socket_selected.route_path }</td>
<td>{ socket_selected.target_name }</td>
<td>{ socket_selected.file_name }</td>
</tr>
</tbody>
</table>
<h1>{ socket_selected.route_path }</h1>
<Code language="javascript" content={ socket_selected.code } />
{:else}
<h1>Nothing Selected</h1>
<p>Either you have no routes setup yet, or you haven't selected one on the left. To create a new JSON HTTP API route add a file to <code>api</code> with the file named after the route name you want. If you want a socket handler then create a file in <code>socket</code> instead. You can generate starter code each of these in the Djenterator.</p>
{/if}
</display>
</content>
{/await}
</Layout>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save