Initial big commit that brings over all of the latest development from my sites. This will be refined and released soon, but right now I'm testing how it installs with ljsthw-bandolier's installer.

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

176
.gitignore vendored

@ -1,153 +1,23 @@
# ---> Vim /node_modules/
# Swap /public/
[._]*.s[a-v][a-z] /rendered/build/
!*.svg # comment out if you don't need vector files /rendered/public/
[._]*.sw[a-p]
[._]s[a-rt-v][a-z] .*.sw*
[._]ss[a-gi-z] .DS_Store
[._]sw[a-p] *.sqlite3*
debug/
# Session static/thumbs
Session.vim static/videos
Sessionx.vim secrets/*
coverage/
# Temporary .coverage
.netrwhist static/module
*~ client/config.js
# Auto-generated tag files emails/config.js
tags media
# Persistent undo tests/fixtures
[._]*.un~ rendered/wip
junk/
# ---> Node static/images/sample_video.mp4
# Logs static/js/webtorrent.debug.js
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

@ -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}
<Reloader />
<Bandolier shown={ false }/>
{/if}

@ -0,0 +1,27 @@
<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";
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>
{#if $user.admin }
<li><a href="/" use:link><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}
<a href="/client/#/">Login</a>
{/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="/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/#/bando/iconfinder/">
<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/#/bando/djenterator/">
<Icon name="edit" light={ true } size="64" />
<span>Templates</span>
</a>
<a href="/admin/#/bando/devinfo/routes/">
<Icon name="code" light={ true } size="64" />
<span>Routes</span>
</a>
<a href="/admin/#/bando/devinfo/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/#/bando/devinfo/errors/">
<Icon name="alert-circle" light={ true } size="64" />
<span>Errors</span>
</a>
<ul>
{#each errors as error, i}
<li>{ error.filename }</li>
{/each}
</ul>
</bando>
{/if}

@ -0,0 +1,221 @@
<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>client/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 } />
{:else if show == "DOCS"}
{#await load_docs(selected) then docs }
{#if docs}
<Markdown content={ docs } />
{/if}
{/await}
{:else}
<h1>{selected.title}</h1>
<svelte:component this={selected.component} />
{/if}
</component>
</contents>
</Layout>
<CodeFormatter />

@ -0,0 +1,257 @@
<script>
import template from "lodash/template";
import { fade } from "svelte/transition";
import Icon from "$/client/components/Icon.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 = "";
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) {
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 load_template = async (template_name) => {
let res = await fetch(`/djenterator/${template_name}`);
if(res.status == 200) {
source = await res.text();
last_good = source;
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 toggle_template = () => {
if(showing_rendered) {
render_template();
} else {
results = renderer.toString();
}
showing_rendered = !showing_rendered;
}
const copy_code = () => {
navigator.clipboard.writeText(results).then(() => {
notice = "Code copied to clipboard.";
}, () => {
notice = "Failed copying to clipboard.";
});
}
const canary = '3a025dba-1a62-4169-ae9f-f7cd4104c4f4';
log.debug("Canary is compiled into build as", canary);
$: 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>
content {
display: flex;
flex-direction: row;
width: 100%;
}
template-editor {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
}
pre {
position: relative;
height: 90vh;
max-height: 90vh;
overflow-y: auto;
}
right pre {
display: flex;
font-size: 1em;
flex-basis: 100%;
margin: 0;
padding: 0;
}
right pre code {
padding-top: 2rem;
display: flex;
flex-basis: 100%;
border-radius: 0px 4px 4px 0px;
line-height: unset;
margin: 0;
}
left {
display: flex;
flex-grow: 1;
flex-basis: 70ch;
}
left textarea {
border-radius: 4px 0px 0px 4px;
margin: 0;
background-color: var(--color-secondary);
color: var(--color-bg);
height: 90vh;
max-height: 90vh;
}
right {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 3;
flex-basis: 100ch;
align-items: stretch;
}
status {
position: absolute;
z-index: 100;
top: 0;
padding-right: 1rem;
padding-left: 1rem;
padding-top: 0.1rem;
width: 90%;
color: var(--color-bg);
display: flex;
box-sizing: border-box;
background-color: var(--color-secondary);
border-radius: 0px 4px 0px 0px;
justify-content: space-evenly;
}
status file {
flex-basis: 90%;
text-align: right;
}
status buttons {
display: flex;
justify-content: space-around;
flex-basis: 10%;
padding-top: 0.5rem;
}
pre notice {
position: absolute;
bottom: 0;
left: 0.3rem;
right: 0.3rem;
text-align: right;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-tertiary);
border-radius: 4px 4px 0px 0px;
}
</style>
<Layout fullscreen={ true } header={ false } testid="page-bando-djenterator">
<template-editor>
<select bind:value={ selected_template }>
{#each generators as template}
<option value={ template }>{ template }</option>
{/each}
</select>
<content>
<left>
<textarea class="editor" bind:value={ variable_json } rows="15"></textarea>
</left>
<right>
<status>
<buttons>
<span on:click={ copy_code }><Icon name="copy" size="24" color="var(--color-bg)" /></span>
<span on:click={ toggle_template }><Icon name="code" size="24" color="var(--color-bg)" /></span>
</buttons>
<file>static/djenterator/{ selected_template }</file>
</status>
<pre>
<code>
{results}
</code>
{#if notice}
<notice in:fade on:click={ () => notice = "" }>
<b>{ notice }</b>
</notice>
{/if}
</pre>
</right>
</content>
<template-editor>
</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,47 @@
<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>client/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,114 @@
<script>
import Icon from "$/client/components/Icon.svelte";
import { onMount } from "svelte";
import api from "$/client/api.js";
import { log } from "$/client/logging.js";
let all_icons = [];
let inactive = false;
let icons = [];
export let size=48;
let search = "";
export let labels=true;
export let tight=false;
let message = "";
const search_icons = (pattern) => {
icons = all_icons.filter(i => i.includes(pattern));
}
$: search_icons(search);
const gen_code = (name) => {
let results = `<Icon name="${name}" size="${size}" />`;
navigator.clipboard.writeText(results).then(() => {
message = `${name} copied to clipboard.`;
}, () => {
message = `${name} copy FAILED.`;
});
}
onMount(async () => {
const [status, data] = await api.get("/icons/index.json");
if(status === 200) {
icons = data;
all_icons = icons;
} else {
log.error("Invalid response", status, data);
}
});
</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;
}
search-bar input#size {
width: 6ch;
}
search-bar input#search {
min-width: 30ch;
max-wdith: 30ch;
width: 30ch;
}
search-bar span {
padding-right: 1rem;
}
@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>
<search-bar>
<span on:click={ () => inactive = !inactive }><Icon name={ inactive ? 'eye' : 'eye-off'} size="24" /></span>
<input bind:value={ search } id="search" > <Icon name="x" size="24" /> <input bind:value={ size } id="size" >
<span>{ message }</span>
</search-bar>
<icons class:tight={ tight }>
{#each icons as name}
<icon on:click={ () => gen_code(name) }>
<Icon name={ name } size={ size } inactive={inactive}/>
{#if labels}
<span>{ name }</span>
{/if}
</icon>
{:else}
<h1>No Icons</h1>
{/each}
</icons>

@ -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 `client/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 `client/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 `client/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 `client/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 `localhost:5001` 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 localhost:5001` -- 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,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,101 @@
<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: "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},
{url: "icons", title: "Icons", active: false, icon: "smile", component: IconFinder},
{url: "demos", title: "Components", active: false, icon: "component", href: "/admin/#/bando/components/"},
{url: "routes", title: "Routes", active: false, icon: "code-2", href: "/admin/#/routes/"},
{url: "errors", title: "Errors", active: false, icon: "alert-circle", href: "/admin/#/errors/"},
{url: "djenterator", title: "Djenterator", active: false, icon: "layout-template", component: Djenterator},
];
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;
}
}
}
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" size="36"/></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>

@ -0,0 +1,342 @@
<script>
import { onMount } from 'svelte';
import api from "$/client/api.js";
import Source from "$/client/components/Source.svelte";
import Spinner from "$/client/components/Spinner.svelte";
import Icon from "$/client/components/Icon.svelte";
import IsVisible from "$/client/components/IsVisible.svelte";
import { defer, random_numbers } from "$/client/helpers.js";
let chart_load_promise = defer();
let stats = {};
let by_chains = [];
let original_data = [];
let search_text = "";
let search_error = "";
let dates = [];
let combine_results = false;
let combined_chains = [];
const date_key = (date) => {
// construct a consistent date format
const d = new Date(date);
// month is zero based
return `${d.getMonth()+1}-${d.getDate()}`
}
const new_result = () => {
// the idea is to initialize every chain's map with dates set to zero
const init_keys = dates.map(d => [date_key(d), 0]);
// then when it's analyzed later we'll have a full list
return {
hits_by_date: Object.fromEntries(init_keys),
sum: 0,
id: random_numbers(1)[0]
};
}
const transform_by_chains = (data, min=1) => {
const result = {};
// sort keys for dates
dates = Object.keys(data).sort();
for(let date of dates) {
const chains = data[date];
for(let [chain, chain_stats] of Object.entries(chains)) {
const result_chain = result[chain] || new_result();
// convert the date to get the DOW?
result_chain.hits_by_date[date_key(date)] = chain_stats.count;
result_chain.sum += chain_stats.count;
result[chain] = result_chain;
}
}
// now remove any stats that are too thin to care about
const filtered = [];
for(let chain in result) {
// keep anything with register in it, but only if we aren't combining results
if(combine_results || chain.includes("register") || result[chain].sum > min) {
filtered.push([chain, result[chain]]);
}
}
return filtered.sort((a, b) => b[1].sum - a[1].sum);
}
const calc_stats = (data) => {
let sum = 0;
let sumsq = 0;
const count = data.length;
let min = 0;
let max = 0;
for(let sample of data) {
sum += sample;
sumsq += sample * sample;
min = sample < min ? sample : min;
max = sample > max ? sample : max;
}
// sqrt( (self.sumsq - ( self.sum * self.sum / self.count )) / (self.count - 1) )
// normally you include count - 1 for accurate stats, but that results in divide by 0 so screw it
let stddev = Math.sqrt((sumsq - (sum * sum / count)) / (count));
let mean = sum / count;
let upper = mean + (2 * stddev);
let lower = mean - (2 * stddev);
return {sum, sumsq, count, mean, upper, lower, min, max, stddev};
}
const config_chart = () => {
Chart.defaults.font.size = 16;
}
const create_chart = (title, data) => {
const { hits_by_date, id } = data;
const labels = Object.keys(hits_by_date);
const counts = Object.values(hits_by_date);
const ctx = document.getElementById(`chart-${id}`);
// sometimes it scrolls by too fast or is registered but not visible
if(!ctx) return;
// should do this dynamically on the fly instead
const info = calc_stats(counts);
const chart_max = Math.max(info.max, info.upper);
// the only problem with the data is it's sparse and missing dates
try {
const chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data: counts,
fill: false,
color: 'hsl(0, 0%, 10%)',
borderColor: 'hsl(0, 0%, 20%)',
backgroundColor: 'hsl(0, 0%, 30%)',
tension: 0.1,
pointRadius: counts.length > 40 ? 0 : 2,
borderWidth: 1,
}]
},
options: {
scales: {
y: {
beginAtZero: true,
max: chart_max * 1.1,
min: 0,
}
},
plugins: {
title: {
display: true,
text: `${Math.round(info.mean)} +/- ${Math.round(info.upper)}`,
align: 'end',
position: 'bottom',
},
legend: {
display: false
},
annotation: {
annotations: {
mean: {
type: 'line',
yMin: info.mean,
yMax: info.mean,
borderColor: 'hsl(100, 50%, 50%)',
borderWidth: 1,
},
top: {
type: 'line',
yMin: info.upper,
yMax: info.upper,
borderColor: 'hsl(0, 50%, 60%)',
borderWidth: 1,
},
bottom: {
type: 'line',
yMin: info.lower,
yMax: info.lower,
borderColor: 'hsl(0, 50%, 60%)',
borderWidth: 1,
},
mid_top: {
type: 'line',
yMin: info.mean + info.stddev,
yMax: info.mean + info.stddev,
borderColor: 'hsl(50, 50%, 60%)',
borderWidth: 1,
},
mid_bottom: {
type: 'line',
yMin: info.mean - info.stddev,
yMax: info.mean - info.stddev,
borderColor: 'hsl(50, 50%, 60%)',
borderWidth: 1,
},
}
}
},
}
});
} catch(error) {
// sometimes you get an error about a chart needing to be destroyed,
// most likely because the HTML node is hidden by Svelte
// but I'm not sure how to do that, and it doesn't seem to
// cause problems so just log it
console.error(error.message);
}
}
const calculate_combination = (result) => {
const all_chains = [];
const first = new_result();
for(let [urls, stats] of result) {
all_chains.push([stats.sum, urls]);
for(let key in first.hits_by_date) {
first.hits_by_date[key] += stats.hits_by_date[key];
}
}
combined_chains = all_chains;
return [["", first]];
}
const search = () => {
try {
chart_load_promise = defer();
let result = [];
if(search_text.trim() === "") {
result = original_data;
} else {
const pattern = new RegExp(search_text);
result = original_data.filter(data => {
return data[0].match(pattern);
});
}
if(combine_results) {
by_chains = calculate_combination(result);
} else {
// easy just assign it to get svelte going
by_chains = result;
}
// reset the error
search_error = "";
} catch(error) {
search_error = error.message;
} finally {
chart_load_promise.resolve();
}
}
const toggle_combined = () => {
combine_results = !combine_results;
search();
}
const reset_search = () => {
chart_load_promise = defer();
// reset the results
by_chains = original_data;
// clear the errors
search_error = "";
chart_load_promise.resolve();
}
$: if(search_text === "") {
reset_search();
} else {
search();
}
onMount(async () => {
const [status, data] = await api.get("/reports/chains.json");
if(status === 200) {
stats = data;
// to enable search, keep an original, and by_chains for results
original_data = transform_by_chains(stats.result);
by_chains = original_data;
} else {
console.error("Invalid response", status, data);
}
});
</script>
<style>
#chart-container {
min-height: 200px;
}
search-bar {
display: flex;
align-items: center;
}
search-bar combine-button {
filter: opacity(60%);
padding: 0.5rem;
}
search-bar combine-button.toggled {
filter: opacity(100%);
}
</style>
<Source src="/js/chart.min.js" on:load={ config_chart }/>
<Source src="/js/chartjs-plugin-annotation.min.js" on:load={ () => chart_load_promise.resolve() } />
{#if stats.domain}
<h1>{ stats.domain }</h1>
<search-bar>
<input type="text" placeholder="Search..." bind:value={ search_text } on:enter={ search } />
<combine-button on:click={ toggle_combined } class:toggled={ combine_results}>
<Icon name="shuffle" tooltip="Shuffle" />
</combine-button>
</search-bar>
<div>{ by_chains.length } results{#if combine_results}<b>, combined</b>{/if}</div>
{/if}
{#await chart_load_promise}
<Spinner />
{:then}
<hr />
{#if search_error}
<callout class="error">{ search_error }</callout>
{/if}
{#each by_chains as data, i}
<IsVisible on:visible={ () => setTimeout(() => create_chart(data[0], data[1]), 200) }>
<div id="chart-container" slot="visible">
{#if combine_results}
<canvas id="chart-{data[1].id}" width="400" height="100"></canvas>
<table>
<tr><th>sum</th><th>chain</th></tr>
{#each combined_chains as [sum, url]}
<tr><td>{ sum }</td><td style="text-align: left;">{url}</td></tr>
{/each}
</table>
{:else}
<div><b>{data[0]}</b></div>
<canvas id="chart-{data[1].id}" width="400" height="100"></canvas>
{/if}
</div>
<div id="chart-container" slot="hidden"><p>&nbsp;</p></div>
</IsVisible>
{/each}
{/await}

@ -0,0 +1,97 @@
<script>
import { link } from 'svelte-spa-router';
import Icon from "$/client/components/Icon.svelte";
import Spinner from "$/client/components/Spinner.svelte";
import Layout from "$/admin/Layout.svelte";
import api from "$/client/api.js";
import DataTable from "$/client/components/DataTable.svelte";
import { defer } from "$/client/helpers.js";
import { onMount } from "svelte";
let rows = [];
let column_names = [];
let pagination = { currentPage: 1 }
const load_promise = defer("datatable load promise");
export let params = {};
// BUG: this works around stupid in DataTable where
// paginating causes the link to id be invalid
const link_url = r => `/admin/#/table/${params.table}/${r.id}/`;
const update_data = async (url, options) => {
const { currentPage } = pagination;
options.currentPage = currentPage;
let [status, results] = await api.get(url, options);
if(status == 200) {
// generates the _url used by the DataTabe component
rows = results.data.map(r => {
r._url = link_url(r);
return r;
});
pagination = Object.assign(pagination, results.pagination);
if(rows.length > 0) {
column_names = Object.keys(rows[0]);
}
load_promise.resolve();
} else {
window.location = "/client/#/login";
}
}
const full_query = async () => {
await update_data('/api/admin/table', {name: params.table});
}
const search_query = async (event) => {
let search_text = event.detail;
await update_data('/api/admin/table', {name: params.table, search: search_text});
}
const clear_search = async () => {
await update_data('/api/admin/table', {name: params.table});
}
const cell_click = () => {
// I was going to the record when you click but that prevents people from
// copy-pasting the data. I think I'll leave it alone.
}
onMount(async () => {
await full_query();
});
</script>
<style>
content {
flex-direction: column;
padding-right: 0.5rem;
padding-left: 0.5rem;
}
</style>
<Layout fullwidth={ true } authenticated={ true } testid="page-admin-table">
<content>
<h1><a href="/" use:link><Icon name="arrow-left-circle" size="36"/></a> { params.table } Table</h1>
{#await load_promise}
<Spinner />
{:then}
<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 data-testid="link-create" href="/table/create/{params.table}/" use:link><Icon name="file-plus" size="36" tooltip="Create." /></a>
</DataTable>
{/await}
</content>
</Layout>

@ -0,0 +1,85 @@
<script>
import { onMount } from 'svelte';
import Icon from "$/client/components/Icon.svelte";
import Layout from "$/admin/Layout.svelte";
import api from "$/client/api.js";
let tables = [];
let show_all = false;
let ignore = ["knex_migrations", "sqlite_sequence", "knex_migrations_lock"];
onMount(async () => {
let [status, data] = await api.get('/api/admin/schema');
if(status === 200) {
tables = data;
} else {
window.location = "/client/#/login";
}
});
</script>
<style>
schemas {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
grid-column-gap: 1em;
}
schemas description table tr,
schemas description table th {
width: 100%;
}
schemas description h1 {
margin-bottom: 0px;
}
buttons {
display: flex;
justify-content: space-between;
}
content {
flex-direction: column;
padding-right: 0.5rem;
padding-left: 0.5rem;
padding-top: 0.5rem;
width: 100%;
}
</style>
<content>
<buttons>
<span on:click={ () => show_all = !show_all }><Icon name={ show_all ? "eye-off" : "eye" } tooltip="Toggle internal tables." /><span>
</buttons>
<schemas>
{#each tables as table, i}
{#if show_all || !ignore.includes(table.name)}
<description>
<h1><a data-testid="table-name-{table.name}" href="/admin/#/table/{ table.name }/">{ table.name }</a></h1>
<table>
<thead>
<tr><th>name</th><th>type</th><th>max</th><th>null</th><th>default</th>
</thead>
<tbody>
{#each Object.keys(table._columns) as colname, i}
<tr>
<td>{colname}</td>
<td>{table._columns[colname].type}</td>
<td>{table._columns[colname].maxLength}</td>
<td>{table._columns[colname].nullable}</td>
<td>{table._columns[colname].defaultValue}</td>
</tr>
{/each}
<tbody>
</table>
</description>
{/if}
{/each}
</schemas>
</content>

@ -0,0 +1,63 @@
<script>
import { onMount } from 'svelte';
import Layout from '$/admin/Layout.svelte';
import api from "$/client/api.js";
import assert from "$/client/assert.js";
import { random_numbers, defer } from "$/client/helpers.js";
import { log } from "$/client/logging.js";
// just get some random numbers to run that function
const r = random_numbers(2);
assert(r.length == 2, "Whoops, random_numbers failed.");
// this is a mock on purpose to test the mock code
api.mock({
"/fake/test": {
"get": [500, {"message": "Failure."}],
}
});
const assert_promise = defer("assert promise");
const bad_promise = defer("bad promise");
onMount(async () => {
const [status, data] = await api.get("/fake/test");
bad_promise.reject(new Error("Bad worked."));
try {
assert(status === 200, "Fake test call failed (it should).");
} catch(error) {
log.error(error, "Assert worked!");
assert_promise.resolve("resolve-message");
}
});
</script>
<Layout testid="page-admin-test" centered={ true }>
<h1>Additional Tests</h1>
<callout class="info">
<span>
<b>NOTE:</b> This page basically does nothing except provide access to
things for the unit test in tests/ui/admin_tests.js. It has a mock
on purpose to test that they keep working, and does asserts as well.
You can delete this if you also delete the test.
</span>
</callout>
{#await assert_promise}
<p data-testid="no-assert">Waiting for assert.</p>
{:then msg}
<p data-testid="assert-works">Assert is working. Good job! Message: { msg }</p>
{/await}
{#await bad_promise}
<p data-testid="bad-await">This should error.</p>
{:then}
<p data-testid="bad-failed">OOPS! This shouldn't run.</p>
{:catch error}
<p data-testid="bad-worked">Defer failure worked: { error }</p>
{/await}
</Layout>

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

Loading…
Cancel
Save