Commit after Episode 2 of the show. First cuts of the landing page, problems list page, and starting off on the problem show.
commit
595ef589dd
@ -0,0 +1,277 @@ |
|||||||
|
{ |
||||||
|
"env": { |
||||||
|
"browser": true, |
||||||
|
"es6": true, |
||||||
|
"node": true |
||||||
|
}, |
||||||
|
"plugins": ["svelte3"], |
||||||
|
"overrides": [ |
||||||
|
{"files": "*.svelte", "processor": "svelte3/svelte3"} |
||||||
|
], |
||||||
|
"extends": "eslint:recommended", |
||||||
|
"parserOptions": { |
||||||
|
"ecmaVersion": 2021, |
||||||
|
"sourceType": "module" |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"accessor-pairs": "error", |
||||||
|
"array-bracket-newline": "off", |
||||||
|
"array-bracket-spacing": [ "warn", "never" ], |
||||||
|
"array-callback-return": "error", |
||||||
|
"array-element-newline": "off", |
||||||
|
"arrow-body-style": "off", |
||||||
|
"arrow-parens": "off", |
||||||
|
"arrow-spacing": [ |
||||||
|
"error", |
||||||
|
{ |
||||||
|
"after": true, |
||||||
|
"before": true |
||||||
|
} |
||||||
|
], |
||||||
|
"block-scoped-var": "error", |
||||||
|
"block-spacing": "error", |
||||||
|
"brace-style": [ |
||||||
|
"error", |
||||||
|
"1tbs" |
||||||
|
], |
||||||
|
"callback-return": "off", |
||||||
|
"camelcase": "off", |
||||||
|
"capitalized-comments": "off", |
||||||
|
"class-methods-use-this": "off", |
||||||
|
"comma-dangle": "off", |
||||||
|
"comma-spacing": [ |
||||||
|
"warn", |
||||||
|
{ |
||||||
|
"after": true, |
||||||
|
"before": false |
||||||
|
} |
||||||
|
], |
||||||
|
"comma-style": [ |
||||||
|
"error", |
||||||
|
"last" |
||||||
|
], |
||||||
|
"complexity": "error", |
||||||
|
"computed-property-spacing": [ |
||||||
|
"error", |
||||||
|
"never" |
||||||
|
], |
||||||
|
"consistent-return": "error", |
||||||
|
"consistent-this": "error", |
||||||
|
"curly": "off", |
||||||
|
"default-case": "error", |
||||||
|
"dot-location": "off", |
||||||
|
"dot-notation": "off", |
||||||
|
"eol-last": "error", |
||||||
|
"eqeqeq": "off", |
||||||
|
"func-call-spacing": "error", |
||||||
|
"func-name-matching": "error", |
||||||
|
"func-names": "error", |
||||||
|
"func-style": [ |
||||||
|
"error", |
||||||
|
"expression" |
||||||
|
], |
||||||
|
"function-paren-newline": "off", |
||||||
|
"generator-star-spacing": "error", |
||||||
|
"global-require": "off", |
||||||
|
"guard-for-in": "warn", |
||||||
|
"handle-callback-err": "error", |
||||||
|
"id-blacklist": "error", |
||||||
|
"id-length": "off", |
||||||
|
"id-match": "error", |
||||||
|
"implicit-arrow-linebreak": [ |
||||||
|
"error", |
||||||
|
"beside" |
||||||
|
], |
||||||
|
"indent": "off", |
||||||
|
"indent-legacy": "off", |
||||||
|
"init-declarations": "warn", |
||||||
|
"jsx-quotes": "error", |
||||||
|
"key-spacing": "warn", |
||||||
|
"keyword-spacing": "off", |
||||||
|
"line-comment-position": "off", |
||||||
|
"linebreak-style": [ |
||||||
|
"error", |
||||||
|
"unix" |
||||||
|
], |
||||||
|
"lines-around-comment": "off", |
||||||
|
"lines-around-directive": "error", |
||||||
|
"lines-between-class-members": "error", |
||||||
|
"max-classes-per-file": "off", |
||||||
|
"max-depth": "error", |
||||||
|
"max-len": "off", |
||||||
|
"max-lines": "off", |
||||||
|
"max-lines-per-function": "off", |
||||||
|
"max-nested-callbacks": "error", |
||||||
|
"max-params": "off", |
||||||
|
"max-statements": "off", |
||||||
|
"max-statements-per-line": "error", |
||||||
|
"multiline-comment-style": "off", |
||||||
|
"new-cap": ["off"], |
||||||
|
"new-parens": "error", |
||||||
|
"newline-after-var": "off", |
||||||
|
"newline-before-return": "off", |
||||||
|
"newline-per-chained-call": "off", |
||||||
|
"no-alert": "error", |
||||||
|
"no-array-constructor": "error", |
||||||
|
"no-async-promise-executor": "error", |
||||||
|
"no-await-in-loop": "off", |
||||||
|
"no-bitwise": "error", |
||||||
|
"no-buffer-constructor": "error", |
||||||
|
"no-caller": "error", |
||||||
|
"no-catch-shadow": "error", |
||||||
|
"no-confusing-arrow": "error", |
||||||
|
"no-console": "warn", |
||||||
|
"no-continue": "error", |
||||||
|
"no-div-regex": "error", |
||||||
|
"no-duplicate-imports": "error", |
||||||
|
"no-else-return": "off", |
||||||
|
"no-empty-function": "off", |
||||||
|
"no-eq-null": "off", |
||||||
|
"no-eval": "error", |
||||||
|
"no-extend-native": "error", |
||||||
|
"no-extra-bind": "error", |
||||||
|
"no-extra-label": "error", |
||||||
|
"no-extra-parens": "off", |
||||||
|
"no-floating-decimal": "error", |
||||||
|
"no-implicit-coercion": "error", |
||||||
|
"no-implicit-globals": "error", |
||||||
|
"no-implied-eval": "error", |
||||||
|
"no-inline-comments": "off", |
||||||
|
"no-invalid-this": "error", |
||||||
|
"no-iterator": "error", |
||||||
|
"no-label-var": "error", |
||||||
|
"no-labels": "error", |
||||||
|
"no-lone-blocks": "error", |
||||||
|
"no-lonely-if": "off", |
||||||
|
"no-loop-func": "error", |
||||||
|
"no-magic-numbers": "off", |
||||||
|
"no-misleading-character-class": "error", |
||||||
|
"no-mixed-operators": "error", |
||||||
|
"no-mixed-requires": "error", |
||||||
|
"no-multi-assign": "error", |
||||||
|
"no-multi-spaces": "off", |
||||||
|
"no-multi-str": "error", |
||||||
|
"no-multiple-empty-lines": "warn", |
||||||
|
"no-native-reassign": "error", |
||||||
|
"no-negated-condition": "off", |
||||||
|
"no-negated-in-lhs": "error", |
||||||
|
"no-nested-ternary": "error", |
||||||
|
"no-new": "error", |
||||||
|
"no-new-func": "error", |
||||||
|
"no-new-object": "error", |
||||||
|
"no-new-require": "error", |
||||||
|
"no-new-wrappers": "error", |
||||||
|
"no-octal-escape": "error", |
||||||
|
"no-param-reassign": "error", |
||||||
|
"no-path-concat": "error", |
||||||
|
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], |
||||||
|
"no-process-env": "off", |
||||||
|
"no-process-exit": "off", |
||||||
|
"no-proto": "error", |
||||||
|
"no-prototype-builtins": "error", |
||||||
|
"no-restricted-globals": "error", |
||||||
|
"no-restricted-imports": "error", |
||||||
|
"no-restricted-modules": "error", |
||||||
|
"no-restricted-properties": "error", |
||||||
|
"no-restricted-syntax": "error", |
||||||
|
"no-return-assign": "warn", |
||||||
|
"no-return-await": "off", |
||||||
|
"no-script-url": "error", |
||||||
|
"no-self-compare": "error", |
||||||
|
"no-sequences": "error", |
||||||
|
"no-shadow": "off", |
||||||
|
"no-shadow-restricted-names": "error", |
||||||
|
"no-spaced-func": "error", |
||||||
|
"no-sync": "warn", |
||||||
|
"no-tabs": "off", |
||||||
|
"no-template-curly-in-string": "error", |
||||||
|
"no-ternary": "off", |
||||||
|
"no-throw-literal": "error", |
||||||
|
"no-trailing-spaces": "off", |
||||||
|
"no-undef-init": "off", |
||||||
|
"no-undefined": "off", |
||||||
|
"no-underscore-dangle": "off", |
||||||
|
"no-unmodified-loop-condition": "error", |
||||||
|
"no-unneeded-ternary": "error", |
||||||
|
"no-unused-expressions": "error", |
||||||
|
"no-use-before-define": ["error", {"functions": true, "classes": false}], |
||||||
|
"no-useless-call": "error", |
||||||
|
"no-useless-computed-key": "error", |
||||||
|
"no-useless-concat": "error", |
||||||
|
"no-useless-constructor": "error", |
||||||
|
"no-useless-rename": "error", |
||||||
|
"no-useless-return": "error", |
||||||
|
"no-var": "error", |
||||||
|
"no-void": "error", |
||||||
|
"no-warning-comments": "off", |
||||||
|
"no-whitespace-before-property": "error", |
||||||
|
"no-with": "error", |
||||||
|
"nonblock-statement-body-position": "error", |
||||||
|
"object-curly-newline": "off", |
||||||
|
"object-curly-spacing": "off", |
||||||
|
"object-shorthand": "warn", |
||||||
|
"one-var": "off", |
||||||
|
"one-var-declaration-per-line": "error", |
||||||
|
"operator-assignment": "error", |
||||||
|
"operator-linebreak": "error", |
||||||
|
"padded-blocks": "off", |
||||||
|
"padding-line-between-statements": "error", |
||||||
|
"prefer-arrow-callback": "error", |
||||||
|
"prefer-const": "off", |
||||||
|
"prefer-destructuring": "off", |
||||||
|
"prefer-numeric-literals": "error", |
||||||
|
"prefer-object-spread": "error", |
||||||
|
"prefer-promise-reject-errors": "error", |
||||||
|
"prefer-reflect": "off", |
||||||
|
"prefer-rest-params": "error", |
||||||
|
"prefer-spread": "error", |
||||||
|
"prefer-template": "error", |
||||||
|
"quote-props": "off", |
||||||
|
"quotes": "off", |
||||||
|
"radix": "error", |
||||||
|
"require-atomic-updates": "warn", |
||||||
|
"require-await": "off", |
||||||
|
"require-jsdoc": "off", |
||||||
|
"require-unicode-regexp": "off", |
||||||
|
"rest-spread-spacing": "error", |
||||||
|
"semi": "off", |
||||||
|
"semi-spacing": "error", |
||||||
|
"semi-style": [ |
||||||
|
"error", |
||||||
|
"last" |
||||||
|
], |
||||||
|
"sort-imports": "off", |
||||||
|
"sort-keys": "off", |
||||||
|
"sort-vars": "error", |
||||||
|
"space-before-blocks": "error", |
||||||
|
"space-before-function-paren": "off", |
||||||
|
"space-in-parens": [ |
||||||
|
"error", |
||||||
|
"never" |
||||||
|
], |
||||||
|
"space-infix-ops": "off", |
||||||
|
"space-unary-ops": "error", |
||||||
|
"spaced-comment": [ |
||||||
|
"error", |
||||||
|
"always" |
||||||
|
], |
||||||
|
"strict": "error", |
||||||
|
"switch-colon-spacing": "error", |
||||||
|
"symbol-description": "error", |
||||||
|
"template-curly-spacing": [ "off", "never" ], |
||||||
|
"template-tag-spacing": "error", |
||||||
|
"unicode-bom": [ |
||||||
|
"error", |
||||||
|
"never" |
||||||
|
], |
||||||
|
"valid-jsdoc": "error", |
||||||
|
"vars-on-top": "error", |
||||||
|
"wrap-iife": "error", |
||||||
|
"wrap-regex": "error", |
||||||
|
"yield-star-spacing": "error", |
||||||
|
"yoda": [ |
||||||
|
"error", |
||||||
|
"never" |
||||||
|
] |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
/node_modules/ |
||||||
|
/public/ |
||||||
|
/rendered/build/ |
||||||
|
/rendered/public/ |
||||||
|
|
||||||
|
.*.sw* |
||||||
|
.DS_Store |
||||||
|
*.sqlite3* |
||||||
|
debug/ |
||||||
|
static/thumbs |
||||||
|
static/videos |
||||||
|
secrets/* |
||||||
|
coverage/ |
||||||
|
.coverage |
||||||
|
static/module |
||||||
|
client/config.js |
||||||
|
emails/config.js |
||||||
|
media |
||||||
|
tests/fixtures |
||||||
|
rendered/wip |
||||||
|
junk/ |
||||||
|
static/images/sample_video.mp4 |
||||||
|
static/js/webtorrent.debug.js |
@ -0,0 +1 @@ |
|||||||
|
It's Copyright. All Rights Reserved. You have no license at all other than permission to view it. |
@ -0,0 +1,31 @@ |
|||||||
|
# Introduction |
||||||
|
|
||||||
|
> Are you a better artist than a programmer? |
||||||
|
|
||||||
|
This is a parody of leetcode I am creating that provides designers with the same bullshit "tests" |
||||||
|
that programmers have to go through to get a job. I'm building this website live on Twitch at |
||||||
|
zedashaw so if you want to join in the fun, throw out ideas for ridiculous "leetcode style" tests |
||||||
|
for designers, or heckle me, then come watch! |
||||||
|
|
||||||
|
# You're no Designer! |
||||||
|
|
||||||
|
You're right, I'm a programmer. But if you're annoyed that people with absolutely no qualifications |
||||||
|
in testing your skills are somehow allowed to write tests that determine if you get a job then this |
||||||
|
website has proven its point. |
||||||
|
|
||||||
|
# The Rules |
||||||
|
|
||||||
|
The rules for these tests are as follows: |
||||||
|
|
||||||
|
1. Every test I write for a designer has to be something I could do. If you don't know, I'm actually a |
||||||
|
fairly capable painter that can work in many different mediums and styles. If a lowly "programmer" |
||||||
|
can pull off these challenges but you can't, then no job for you. |
||||||
|
2. Tests other people write have to be in the same bullshit style as leetcode tests. They should be |
||||||
|
written by people who know next to nothing about testing designers, be fairly ambiguous, and have |
||||||
|
next to no validity in actually confirming someone can make a design. |
||||||
|
3. The site has to feature Comic Sans somewhere, because that'll piss off all the right people. |
||||||
|
|
||||||
|
# Tech Stack |
||||||
|
|
||||||
|
All of the code will use my Bandolier Educational Web Framework so anybody--even a designer--can |
||||||
|
follow along and probably understand what's going on. |
@ -0,0 +1,13 @@ |
|||||||
|
<script> |
||||||
|
import Router from 'svelte-spa-router'; |
||||||
|
import routes from '$/admin/routes.js'; |
||||||
|
import Bandolier from '$/admin/bando/Bandolier.svelte'; |
||||||
|
import Reloader from "$/client/components/Reloader.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<Router {routes}/> |
||||||
|
|
||||||
|
{#if process.env.DANGER_ADMIN === "1"} |
||||||
|
<Reloader /> |
||||||
|
<Bandolier shown={ false }/> |
||||||
|
{/if} |
@ -0,0 +1,52 @@ |
|||||||
|
<script> |
||||||
|
import { logout_user } from '$/client/api.js'; |
||||||
|
import Icon from '$/client/components/Icon.svelte'; |
||||||
|
import Darkmode from '$/client/components/Darkmode.svelte'; |
||||||
|
import {link} from 'svelte-spa-router'; |
||||||
|
import { user } from "$/client/stores.js"; |
||||||
|
import { register_enabled } from "$/client/config.js"; |
||||||
|
|
||||||
|
export let fixed = false; |
||||||
|
</script> |
||||||
|
|
||||||
|
<header class:fixed> |
||||||
|
<nav> |
||||||
|
{#if $user.authenticated} |
||||||
|
<a href="/client/#/"><Icon name="home" tooltip="Back to app." size="36" /></a> |
||||||
|
<ul> |
||||||
|
<li><a href="/" on:click|preventDefault={ logout_user } data-testid="logout-link"><Icon name="log-out" tooltip="Log out."/></a></li> |
||||||
|
<li><a data-testid="user-profile-button" href="/profile/" use:link><Icon name="settings" tooltip="Settings."/></a></li> |
||||||
|
<li><a href="/feed.rss"><Icon name="rss" tooltip="RSS Feed." tip_position="bottom-left" /></a></li> |
||||||
|
<li><a href="https://learnjsthehardway.com/blog/"><Icon name="book" tooltip="Blog posts." /></a></li> |
||||||
|
{#if $user.admin } |
||||||
|
<li><a href="/admin/#/"><Icon name="keyboard" tooltip="Admin Dashboard."/></a></li> |
||||||
|
{/if} |
||||||
|
<li><a href="/" on:click|preventDefault={ logout_user } data-testid="logout-link"><Icon name="log-out" tooltip="Log out |
||||||
|
."/></a></li> |
||||||
|
<li><Darkmode /></li> |
||||||
|
</ul> |
||||||
|
{:else} |
||||||
|
<!-- don't use:link because we want them to go to the landing page if they aren't logged in. --> |
||||||
|
<a href="/"><Icon name="home" size="36" /></a> |
||||||
|
<ul> |
||||||
|
{#if register_enabled} |
||||||
|
<li> |
||||||
|
<a id="register-button" href="/client/#/register/"> |
||||||
|
<span class="mobile"> |
||||||
|
<Icon name="dollar-sign" tooltip="Register."/> |
||||||
|
</span> |
||||||
|
|
||||||
|
<span class="mobile-hide"> |
||||||
|
<button type="button">Register</button> |
||||||
|
</span> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
{/if} |
||||||
|
<li><a href="/login/" use:link><Icon name="log-in" tooltip="Log in." /></a></li> |
||||||
|
<li><a href="https://learnjsthehardway.com/blog/"><Icon name="book" tooltip="Blog posts." /></a></li> |
||||||
|
<li><a href="/live/" use:link><Icon name="video" tooltip="Livestreams." /></a></li> |
||||||
|
<li><Darkmode /></li> |
||||||
|
</ul> |
||||||
|
{/if} |
||||||
|
</nav> |
||||||
|
</header> |
@ -0,0 +1,48 @@ |
|||||||
|
<script> |
||||||
|
import LoggedIn from '$/client/components/LoggedIn.svelte'; |
||||||
|
import Spinner from '$/client/components/Spinner.svelte'; |
||||||
|
import Footer from '$/client/Footer.svelte'; |
||||||
|
import Header from '$/admin/Header.svelte'; |
||||||
|
|
||||||
|
export let fixed = false; |
||||||
|
export let footer = true; |
||||||
|
export let header = true; |
||||||
|
export let authenticated = false; |
||||||
|
export let testid = "page"; |
||||||
|
export let centered = false; |
||||||
|
export let fullscreen = false; |
||||||
|
export let fullwidth = false; |
||||||
|
export let auth_optional = false; |
||||||
|
export let horizontal = false; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if authenticated || auth_optional} |
||||||
|
{#if header} |
||||||
|
<Header fixed={fixed}/> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<LoggedIn optional={ auth_optional } redirect="/client/#/login" show_required_page={ false }> |
||||||
|
<main class:horizontal class:fullwidth class:fullscreen class:centered slot="yes" data-testid={ testid }> |
||||||
|
<slot></slot> |
||||||
|
</main> |
||||||
|
<main class:horizontal class:fullwidth class:fullscreen class:centered slot="no" data-testid={ testid }> |
||||||
|
<Spinner color="var(--value8)" /> |
||||||
|
</main> |
||||||
|
</LoggedIn> |
||||||
|
|
||||||
|
{#if footer} |
||||||
|
<Footer /> |
||||||
|
{/if} |
||||||
|
{:else} |
||||||
|
{#if header} |
||||||
|
<Header fixed={fixed}/> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<main class:horizontal class:fullwidth class:fullscreen class:centered data-testid={ testid }> |
||||||
|
<slot></slot> |
||||||
|
</main> |
||||||
|
|
||||||
|
{#if footer} |
||||||
|
<Footer /> |
||||||
|
{/if} |
||||||
|
{/if} |
@ -0,0 +1,116 @@ |
|||||||
|
<script> |
||||||
|
import Icon from '$/client/components/Icon.svelte'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { fade } from "svelte/transition"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
import api from '$/client/api.js'; |
||||||
|
|
||||||
|
export let shown = true; |
||||||
|
let errors = []; |
||||||
|
|
||||||
|
$: has_errors = errors.length > 0; |
||||||
|
|
||||||
|
const rephresh = async () => { |
||||||
|
let [status, data] = await api.get('/api/devtools/info'); |
||||||
|
|
||||||
|
if(status === 200) { |
||||||
|
errors = data.errors; |
||||||
|
log.debug("errors", errors); |
||||||
|
} else { |
||||||
|
log.error("Failed getting /api/devtools/devinfo", status, data); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handle_keypress = (event) => { |
||||||
|
if(event.ctrlKey && event.altKey) { |
||||||
|
if(event.key == "b" || event.keyCode == 66) { |
||||||
|
rephresh(); |
||||||
|
shown = !shown; |
||||||
|
} |
||||||
|
} else if(event.key === "Escape") { |
||||||
|
shown = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => rephresh()); |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:window on:keydown={ handle_keypress } /> |
||||||
|
|
||||||
|
<style> |
||||||
|
bando { |
||||||
|
display: grid; |
||||||
|
grid-template-rows: 1fr; |
||||||
|
grid-template-columns: repeat(5, 1fr); |
||||||
|
grid-gap: 0.5rem; |
||||||
|
width: 450px; |
||||||
|
position: fixed; |
||||||
|
bottom: 0; |
||||||
|
right: 1rem; |
||||||
|
background-color: var(--red); |
||||||
|
opacity: 1; |
||||||
|
z-index: 10000; |
||||||
|
padding: 1rem; |
||||||
|
color: var(--value9); |
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0; |
||||||
|
} |
||||||
|
|
||||||
|
bando#errors { |
||||||
|
display: flex; |
||||||
|
align-items: start; |
||||||
|
justify-content: start; |
||||||
|
} |
||||||
|
|
||||||
|
bando#errors ul { |
||||||
|
margin: 0px; |
||||||
|
padding: 0.3em; |
||||||
|
list-style-type: none; |
||||||
|
} |
||||||
|
|
||||||
|
bando a { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
color: var(--value9); |
||||||
|
width: min-content; |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
|
||||||
|
{#if shown} |
||||||
|
<bando transition:fade|local> |
||||||
|
<a href="/admin/#/icons/"> |
||||||
|
<Icon name="feather" light={ true } size="64" /> |
||||||
|
<span>Icons</span> |
||||||
|
</a> |
||||||
|
<a href="/admin/#/bando/components/"> |
||||||
|
<Icon name="layout" light={ true } size="64" /> |
||||||
|
<span>Library</span> |
||||||
|
</a> |
||||||
|
<a href="/admin/#/djenterator/"> |
||||||
|
<Icon name="edit" light={ true } size="64" /> |
||||||
|
<span>Templates</span> |
||||||
|
</a> |
||||||
|
<a href="/admin/#/routes/"> |
||||||
|
<Icon name="code" light={ true } size="64" /> |
||||||
|
<span>Routes</span> |
||||||
|
</a> |
||||||
|
<a href="/admin/#/errors/"> |
||||||
|
<Icon name="alert-circle" light={ true } size="64" /> |
||||||
|
<span>Errors</span> |
||||||
|
</a> |
||||||
|
</bando> |
||||||
|
{:else if has_errors} |
||||||
|
<bando id="errors" transition:fade|local> |
||||||
|
<a href="/admin/#/errors/"> |
||||||
|
<Icon name="alert-circle" light={ true } size="64" /> |
||||||
|
<span>Errors</span> |
||||||
|
</a> |
||||||
|
<ul> |
||||||
|
{#each errors as error, i} |
||||||
|
<li>{ error.location.file}</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</bando> |
||||||
|
{/if} |
@ -0,0 +1,224 @@ |
|||||||
|
<script> |
||||||
|
import Accordion from "./demos/Accordion.svelte"; |
||||||
|
import AspectRatio from "./demos/AspectRatio.svelte"; |
||||||
|
import Badge from "./demos/Badge.svelte"; |
||||||
|
import ButtonGroup from "./demos/ButtonGroup.svelte"; |
||||||
|
import Calendar from "./demos/Calendar.svelte"; |
||||||
|
import Callout from "./demos/Callout.svelte"; |
||||||
|
import Cards from "./demos/Cards.svelte"; |
||||||
|
import Carousel from "./demos/Carousel.svelte"; |
||||||
|
import Chat from "./demos/Chat.svelte"; |
||||||
|
import Code from "$/client/components/Code.svelte"; |
||||||
|
import CodeDemo from "./demos/Code.svelte"; |
||||||
|
import CodeFormatter from "$/client/components/CodeFormatter.svelte"; |
||||||
|
import Countdown from "./demos/Countdown.svelte"; |
||||||
|
import Darkmode from "./demos/Darkmode.svelte"; |
||||||
|
import DataTable from "./demos/DataTable.svelte"; |
||||||
|
import FairPay from "./demos/FairPay.svelte"; |
||||||
|
import Flipper from "./demos/Flipper.svelte"; |
||||||
|
import Form from "./demos/Form.svelte"; |
||||||
|
import HLSVideo from "./demos/HLSVideo.svelte"; |
||||||
|
import Icon from "$client/components/Icon.svelte"; |
||||||
|
import IconDemo from "./demos/Icon.svelte"; |
||||||
|
import IconImage from "./demos/IconImage.svelte"; |
||||||
|
import LiveStream from "./demos/LiveStream.svelte"; |
||||||
|
import LoggedIn from "./demos/LoggedIn.svelte"; |
||||||
|
import Login from "./demos/Login.svelte"; |
||||||
|
import Markdown from "$/client/components/Markdown.svelte"; |
||||||
|
import MarkdownDemo from "./demos/Markdown.svelte"; |
||||||
|
import Modal from "./demos/Modal.svelte"; |
||||||
|
import OGPreview from "./demos/OGPreview.svelte"; |
||||||
|
import Pagination from "./demos/Pagination.svelte"; |
||||||
|
import PlaceHolder from "./demos/PlaceHolder.svelte"; |
||||||
|
import Progress from "./demos/Progress.svelte"; |
||||||
|
import Sidebar from "$/client/components/Sidebar.svelte"; |
||||||
|
import SidebarCSS from "./demos/SidebarCSS.svelte"; |
||||||
|
import SidebarDemo from "./demos/Sidebar.svelte"; |
||||||
|
import SnapImage from "./demos/SnapImage.svelte"; |
||||||
|
import Spinner from "./demos/Spinner.svelte"; |
||||||
|
import StackLayer from "./demos/StackLayer.svelte"; |
||||||
|
import Switch from "./demos/Switch.svelte"; |
||||||
|
import Tabs from "./demos/Tabs.svelte"; |
||||||
|
import Tiles from "./demos/Tiles.svelte"; |
||||||
|
import Toast from "./demos/Toast.svelte"; |
||||||
|
import Toastier from "./demos/Toastier.svelte"; |
||||||
|
import Tooltip from "./demos/Tooltip.svelte"; |
||||||
|
import Video from "./demos/Video.svelte"; |
||||||
|
import WTVideo from "./demos/WTVideo.svelte"; |
||||||
|
|
||||||
|
import Layout from "../Layout.svelte"; |
||||||
|
import { link, replace } from "svelte-spa-router"; |
||||||
|
|
||||||
|
export let params = {}; |
||||||
|
|
||||||
|
|
||||||
|
/* WARNING: If you put any component that uses /api/login into the first slot |
||||||
|
* or set active: true on them it will require a login. |
||||||
|
*/ |
||||||
|
let panels = [ |
||||||
|
{title: "Accordion", active: false, icon: "align-justify", component: Accordion}, |
||||||
|
{title: "AspectRatio", active: false, icon: "copy", component: AspectRatio}, |
||||||
|
{title: "Badge", active: false, icon: "award", component: Badge}, |
||||||
|
{title: "ButtonGroup", active: false, icon: "server", component: ButtonGroup}, |
||||||
|
{title: "Calendar", active: false, icon: "calendar", component: Calendar}, |
||||||
|
{title: "Callout", active: false, icon: "file-plus", component: Callout}, |
||||||
|
{title: "Cards", active: false, icon: "credit-card", component: Cards}, |
||||||
|
{title: "Carousel", active: false, icon: "repeat", component: Carousel}, |
||||||
|
{title: "Chat", active: false, icon: "message-circle", component: Chat}, |
||||||
|
{title: "Code", active: false, icon: "code", component: CodeDemo}, |
||||||
|
{title: "Countdown", active: false, icon: "clock", component: Countdown}, |
||||||
|
{title: "Darkmode", active: false, icon: "sunrise", component: Darkmode}, |
||||||
|
{title: "DataTable", active: false, icon: "grid", component: DataTable}, |
||||||
|
{title: "FairPay", active: false, icon: "dollar-sign", component: FairPay}, |
||||||
|
{title: "Flipper", active: false, icon: "layers", component: Flipper}, |
||||||
|
{title: "Form", active: false, icon: "database", component: Form}, |
||||||
|
{title: "Icon", active: false, icon: "feather", component: IconDemo}, |
||||||
|
{title: "IconImage", active: false, icon: "image", component: IconImage}, |
||||||
|
{title: "LiveStream", active: false, icon: "cast", component: LiveStream}, |
||||||
|
{title: "LoggedIn", active: false, icon: "log-out", component: LoggedIn}, |
||||||
|
{title: "Login", active: false, icon: "log-in", component: Login}, |
||||||
|
{title: "Markdown", active: false, icon: "file", component: MarkdownDemo}, |
||||||
|
{title: "Modal", active: false, icon: "maximize", component: Modal}, |
||||||
|
{title: "OGPreview", active: false, icon: "external-link", component: OGPreview}, |
||||||
|
{title: "Pagination", active: false, icon: "skip-forward", component: Pagination}, |
||||||
|
{title: "PlaceHolder", active: false, icon: "image", component: PlaceHolder}, |
||||||
|
{title: "Progress", active: false, icon: "thermometer", component: Progress}, |
||||||
|
{title: "Sidebar", active: false, icon: "sidebar", component: SidebarDemo}, |
||||||
|
{title: "SidebarCSS", active: false, icon: "sidebar", component: SidebarCSS}, |
||||||
|
{title: "SnapImage", active: false, icon: "camera", component: SnapImage}, |
||||||
|
{title: "Spinner", active: false, icon: "rotate-cw", component: Spinner}, |
||||||
|
{title: "StackLayer", active: false, icon: "layers", component: StackLayer}, |
||||||
|
{title: "Switch", active: false, icon: "check-square", component: Switch}, |
||||||
|
{title: "Tabs", active: false, icon: "folder", component: Tabs}, |
||||||
|
{title: "Tiles", active: false, icon: "camera", component: Tiles}, |
||||||
|
{title: "Toast", active: false, icon: "message-square", component: Toast}, |
||||||
|
{title: "Toastier", active: false, icon: "message-square", component: Toastier}, |
||||||
|
{title: "Tooltip", active: false, icon: "help-circle", component: Tooltip}, |
||||||
|
{title: "HLSVideo", active: false, icon: "video", component: HLSVideo}, |
||||||
|
{title: "Video", active: false, icon: "video", component: Video}, |
||||||
|
{title: "WTVideo", active: false, icon: "video", component: WTVideo}, |
||||||
|
]; |
||||||
|
|
||||||
|
panels.forEach(p => (p.code = p.code || `/bando/demos/${p.title}.svelte`)); |
||||||
|
|
||||||
|
const select_named = () => { |
||||||
|
if(params.name) { |
||||||
|
return panels.find(p => p.title === params.name) || panels[0]; |
||||||
|
} else { |
||||||
|
return panels[0]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let selected = select_named(); |
||||||
|
selected.active = true; |
||||||
|
let show = "DEMO"; |
||||||
|
|
||||||
|
const load_docs = async (from) => { |
||||||
|
const res = await fetch(`${from.code}.md`); |
||||||
|
return res.status == 200 ? res.text() : undefined; |
||||||
|
} |
||||||
|
|
||||||
|
const sidebar_select = (event) => { |
||||||
|
const {index, item} = event.detail; |
||||||
|
show = "DEMO"; |
||||||
|
|
||||||
|
selected = item; |
||||||
|
|
||||||
|
panels = panels.map((x, i) => { |
||||||
|
x.active = i == index; |
||||||
|
return x; |
||||||
|
}); |
||||||
|
|
||||||
|
replace(`/bando/components/${selected.title}/`); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
div[slot="top"] span { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
contents { |
||||||
|
padding: 0.5rem; |
||||||
|
width: 100%; |
||||||
|
max-height: 100vh; |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
tabs { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
@media only screen and (max-width: 900px) { |
||||||
|
div[slot="top"] h3 { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
div[slot="top"] span { |
||||||
|
display: inline-block; |
||||||
|
padding-top: 0.3rem; |
||||||
|
} |
||||||
|
|
||||||
|
div[slot="bottom"] { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
left { |
||||||
|
max-height: 100vh; |
||||||
|
overflow-y: auto; |
||||||
|
overflow-x: hidden; |
||||||
|
max-width: min-content; |
||||||
|
width: min-content; |
||||||
|
min-width: min-content; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout fullscreen={ true } header={false} footer={ false } testid="page-bando-demos"> |
||||||
|
<left> |
||||||
|
<Sidebar on:select={ sidebar_select } menu={ panels }> |
||||||
|
<div slot="top"> |
||||||
|
<h3><a href="/" use:link><Icon name="arrow-left-circle" /> Components</h3> |
||||||
|
<span><Icon name="home" size="36" /></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div slot="bottom"> |
||||||
|
<p>Code is in <b>admin/bando/demos</b></p> |
||||||
|
</div> |
||||||
|
</Sidebar> |
||||||
|
</left> |
||||||
|
|
||||||
|
<contents> |
||||||
|
<tabs> |
||||||
|
<a data-testid="tab-demo" class:active={ show == "DEMO" } on:click={ () => show = "DEMO" }> |
||||||
|
<Icon name="eye" size="36px"/> Demo |
||||||
|
</a> |
||||||
|
|
||||||
|
<a data-testid="tab-docs" class:active={ show == "DOCS" } on:click={ () => show = "DOCS" }> |
||||||
|
<Icon name="book-open" size="36px"/> Docs |
||||||
|
</a> |
||||||
|
|
||||||
|
<a data-testid="tab-code" class:active={ show == "CODE" } on:click={ () => show = "CODE" }> |
||||||
|
<Icon name="code" size="36px" /> Code |
||||||
|
</a> |
||||||
|
</tabs> |
||||||
|
<component data-testid="demo-{ selected.title }"> |
||||||
|
{#if show == "CODE"} |
||||||
|
<Code src={ selected.code } language="javascript" /> |
||||||
|
{:else if show == "DOCS"} |
||||||
|
{#await load_docs(selected) then docs } |
||||||
|
{#if docs} |
||||||
|
<Markdown content={ docs } /> |
||||||
|
{:else} |
||||||
|
<h1>No Docs</h1> |
||||||
|
<p>This component is not documented yet.</p> |
||||||
|
{/if} |
||||||
|
{/await} |
||||||
|
{:else} |
||||||
|
<h1>{selected.title}</h1> |
||||||
|
<svelte:component this={selected.component} /> |
||||||
|
{/if} |
||||||
|
</component> |
||||||
|
</contents> |
||||||
|
</Layout> |
||||||
|
<CodeFormatter /> |
@ -0,0 +1,152 @@ |
|||||||
|
<script> |
||||||
|
import template from "lodash/template"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import Toasts from "$/client/components/Toasts.svelte"; |
||||||
|
import Code from "$/client/components/Code.svelte"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
import Layout from "../Layout.svelte"; |
||||||
|
import api from '$/client/api.js'; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
|
||||||
|
export let selected_template; |
||||||
|
|
||||||
|
let showing_rendered = false; |
||||||
|
let results = ""; |
||||||
|
let source = ""; |
||||||
|
let variable_json = "{ }"; |
||||||
|
let generators = []; |
||||||
|
|
||||||
|
let variables = {}; |
||||||
|
let renderer = () => source; |
||||||
|
let notice = ""; |
||||||
|
let last_good = ""; |
||||||
|
let send_toast; |
||||||
|
|
||||||
|
let language = "javascript"; |
||||||
|
const language_list = { |
||||||
|
"html": "html", |
||||||
|
"svelte": "javascript", |
||||||
|
"sh": "shell", |
||||||
|
"js": "javascript" |
||||||
|
} |
||||||
|
|
||||||
|
const list_generators = async () => { |
||||||
|
let [status, data] = await api.get('/api/devtools/djenterator'); |
||||||
|
|
||||||
|
if(status == 200) { |
||||||
|
generators = data; |
||||||
|
selected_template = generators[0]; |
||||||
|
} else { |
||||||
|
log.debug("failed to load generators", status); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const render_template = () => { |
||||||
|
try { |
||||||
|
// avoid rendering when the current template doesn't match the renderer |
||||||
|
if(selected_template == renderer._template) { |
||||||
|
variables = variable_json ? JSON.parse(variable_json): {}; |
||||||
|
results = renderer(variables); |
||||||
|
notice = ""; |
||||||
|
last_good = results; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} catch(err) { |
||||||
|
log.error(err); |
||||||
|
notice = err.message; |
||||||
|
results = last_good; |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const load_variables = async (template_name) => { |
||||||
|
let res = await fetch(`/djenterator/${template_name}.vars`); |
||||||
|
|
||||||
|
if(res.status == 200) { |
||||||
|
variable_json = await res.text(); |
||||||
|
} else { |
||||||
|
variable_json = '{}'; |
||||||
|
notice = `No ${template_name}.vars file found. ${res.status}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const detect_language = (name) => { |
||||||
|
const ext = name.split(".")[1]; |
||||||
|
language = language_list[ext]; |
||||||
|
} |
||||||
|
|
||||||
|
const load_template = async (template_name) => { |
||||||
|
let res = await fetch(`/djenterator/${template_name}`); |
||||||
|
|
||||||
|
if(res.status == 200) { |
||||||
|
source = await res.text(); |
||||||
|
last_good = source; |
||||||
|
detect_language(template_name); |
||||||
|
|
||||||
|
try { |
||||||
|
renderer = template(source); |
||||||
|
// tag this template renderer so that we don't try to render it against the wrong one |
||||||
|
renderer._template = template_name; |
||||||
|
render_template(); |
||||||
|
} catch(error) { |
||||||
|
log.error(error); |
||||||
|
notice = `${error.message}`; |
||||||
|
results = source; |
||||||
|
} |
||||||
|
} else { |
||||||
|
notice = `Error loading ${template_name}: ${res.status}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const copy_code = () => { |
||||||
|
navigator.clipboard.writeText(results).then(() => { |
||||||
|
notice = "Code copied to clipboard."; |
||||||
|
}, () => { |
||||||
|
notice = "Failed copying to clipboard."; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
$: if(variable_json) render_template(); |
||||||
|
|
||||||
|
// this reload the templates when you click on a new one |
||||||
|
const re_render = async (what) => { |
||||||
|
await load_variables(what); |
||||||
|
await load_template(what); |
||||||
|
} |
||||||
|
|
||||||
|
$: if(selected_template) re_render(selected_template); |
||||||
|
|
||||||
|
onMount(async () => await list_generators()); |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
textarea.editor { |
||||||
|
height: 15em; |
||||||
|
background-color: var(--value0); |
||||||
|
color: var(--value9); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout fullscreen={ true } header={ false } footer={ false } testid="page-bando-djenterator"> |
||||||
|
<blockstart style="width: 100%"> |
||||||
|
<block style="--w: 100%"> |
||||||
|
<select bind:value={ selected_template }> |
||||||
|
{#each generators as template} |
||||||
|
<option value={ template }>{ template }</option> |
||||||
|
{/each} |
||||||
|
</select> |
||||||
|
|
||||||
|
<textarea class="editor" bind:value={ variable_json }></textarea> |
||||||
|
{#if notice} |
||||||
|
<callout class="error">{notice}</callout> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if results} |
||||||
|
<Code on:copy={ send_toast("Copied to clipboard!") } content={ results } language={ language } /> |
||||||
|
{/if} |
||||||
|
|
||||||
|
</block> |
||||||
|
</blockstart> |
||||||
|
<Toasts bind:send_toast fade_after={ 10000 } orientation="bottom right" /> |
||||||
|
</Layout> |
@ -0,0 +1,194 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
import api from "$/client/api.js"; |
||||||
|
import { defer } from "$/client/helpers.js"; |
||||||
|
import Toast from "$/client/components/Toasts.svelte"; |
||||||
|
|
||||||
|
let all_icons = []; |
||||||
|
let icons_by_letters = {"a": []}; |
||||||
|
let selected_letter = "a"; |
||||||
|
let inactive = false; |
||||||
|
let icons = []; |
||||||
|
export let size=48; |
||||||
|
let search = ""; |
||||||
|
export let labels=true; |
||||||
|
export let tight=false; |
||||||
|
let load_promise = defer(); |
||||||
|
let send_toast; |
||||||
|
|
||||||
|
const order_pages = (in_icons) => { |
||||||
|
const letters = {}; |
||||||
|
for(let icon_name of in_icons) { |
||||||
|
const first = icon_name[0]; |
||||||
|
letters[first] = letters[first] || []; |
||||||
|
letters[first].push(icon_name); |
||||||
|
} |
||||||
|
|
||||||
|
return letters; |
||||||
|
} |
||||||
|
|
||||||
|
const search_icons = async (pattern) => { |
||||||
|
await load_promise; // lord I hate Svelte's lifecycle |
||||||
|
|
||||||
|
if(pattern.trim() === "") { |
||||||
|
icons_by_letters = order_pages(all_icons); |
||||||
|
icons = all_icons; |
||||||
|
selected_letter = Object.keys(icons_by_letters)[0]; |
||||||
|
} else { |
||||||
|
icons = all_icons.filter(i => i.includes(pattern)); |
||||||
|
|
||||||
|
if(icons.length > 0) { |
||||||
|
icons_by_letters = order_pages(icons); |
||||||
|
selected_letter = Object.keys(icons_by_letters)[0]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
$: search_icons(search); |
||||||
|
|
||||||
|
const gen_code = (name) => { |
||||||
|
let results = `<Icon name="${name}" size="${size}" />`; |
||||||
|
|
||||||
|
navigator.clipboard.writeText(results).then(() => { |
||||||
|
send_toast(`${name} copied to clipboard.`); |
||||||
|
}, () => { |
||||||
|
send_toast(`${name} copy FAILED.`); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
const [status, data] = await api.get("/icons/index.json"); |
||||||
|
|
||||||
|
if(status === 200) { |
||||||
|
all_icons = data; |
||||||
|
icons_by_letters = order_pages(all_icons); |
||||||
|
load_promise.resolve(); |
||||||
|
} else { |
||||||
|
log.error("Invalid response", status, data); |
||||||
|
load_promise.reject(); |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
icons { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(6, 1fr); |
||||||
|
grid-template-rows: auto; |
||||||
|
row-gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
icons.tight { |
||||||
|
grid-template-columns: repeat(10, 1fr); |
||||||
|
} |
||||||
|
|
||||||
|
icons icon { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
search-bar { |
||||||
|
display: flex; |
||||||
|
justify-content: space-evenly; |
||||||
|
flex-wrap: nowrap; |
||||||
|
height: 5ch; |
||||||
|
min-height: 5ch; |
||||||
|
} |
||||||
|
|
||||||
|
search-bar input#size { |
||||||
|
width: 6ch; |
||||||
|
} |
||||||
|
|
||||||
|
search-bar input#search { |
||||||
|
min-width: 30ch; |
||||||
|
max-width: 30ch; |
||||||
|
width: 30ch; |
||||||
|
} |
||||||
|
|
||||||
|
search-bar span { |
||||||
|
padding-right: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
padding-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
display { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: stretch; |
||||||
|
} |
||||||
|
|
||||||
|
display letters { |
||||||
|
display: grid; |
||||||
|
grid-template-rows: 1fr 1fr; |
||||||
|
grid-template-columns: repeat(13, 1fr); |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
display letters letter { |
||||||
|
font-size: 1.5em; |
||||||
|
font-weight: 600; |
||||||
|
text-align: center; |
||||||
|
padding: 1rem; |
||||||
|
border: 1px solid var(--value5); |
||||||
|
} |
||||||
|
|
||||||
|
display letters letter.selected { |
||||||
|
background-color: var(--color-bg-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
@media only screen and (max-width: 900px) { |
||||||
|
icons { |
||||||
|
grid-template-columns: repeat(4, 1fr); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) { |
||||||
|
icons { |
||||||
|
grid-template-columns: repeat(3, 1fr); |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<content> |
||||||
|
<search-bar> |
||||||
|
<span on:click={ () => inactive = !inactive }> |
||||||
|
<Icon tooltip="Toggle inactive look." name={ inactive ? 'eye' : 'eye-off'} size="24" /> |
||||||
|
</span> |
||||||
|
<input placeholder="Search names..." bind:value={ search } id="search" > |
||||||
|
<div>Pixel Size:</div> |
||||||
|
<input bind:value={ size } id="size" > |
||||||
|
</search-bar> |
||||||
|
|
||||||
|
{#if icons.length > 0} |
||||||
|
<display> |
||||||
|
<letters> |
||||||
|
{#each Object.keys(icons_by_letters) as letter} |
||||||
|
<letter class:selected={ letter === selected_letter } on:click={ () => selected_letter = letter }>{ letter }</letter> |
||||||
|
{/each} |
||||||
|
</letters> |
||||||
|
|
||||||
|
<icons class:tight={ tight }> |
||||||
|
{#each icons_by_letters[selected_letter] as name} |
||||||
|
<icon on:click={ () => gen_code(name) }> |
||||||
|
<Icon name={ name } size={ size } inactive={inactive}/> |
||||||
|
{#if labels} |
||||||
|
<span>{ name }</span> |
||||||
|
{/if} |
||||||
|
</icon> |
||||||
|
{/each} |
||||||
|
</icons> |
||||||
|
</display> |
||||||
|
{:else} |
||||||
|
<h1>No Icons match "{ search }"</h1> |
||||||
|
{/if} |
||||||
|
<Toast bind:send_toast orientation="bottom right" /> |
||||||
|
</content> |
@ -0,0 +1,21 @@ |
|||||||
|
<script> |
||||||
|
import Tabs from "$/client/components/Tabs.svelte"; |
||||||
|
import Calendar from "./Calendar.svelte"; |
||||||
|
import Cards from "./Cards.svelte"; |
||||||
|
import Login from "./Login.svelte"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
|
||||||
|
export let panels = [ |
||||||
|
{title: "Calendar", active: true, icon: "calendar", component: Calendar}, |
||||||
|
{title: "Cards", active: false, icon: "credit-card", component: Cards}, |
||||||
|
{title: "Login", active: false, icon: "log-in", component: Login}, |
||||||
|
]; |
||||||
|
|
||||||
|
let selected = panels[0]; |
||||||
|
|
||||||
|
const tab_select = (event) => { |
||||||
|
log.debug("SELECTED TAB", event.detail); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Tabs panels={ panels } on:select={ tab_select } bind:selected vertical={true} /> |
@ -0,0 +1,7 @@ |
|||||||
|
An accordion is just the `Tabs` component with `vertical` set to true: |
||||||
|
|
||||||
|
``` |
||||||
|
<Tabs panels={ panels } on:select={ tab_select } bind:selected vertical={true} /> |
||||||
|
``` |
||||||
|
|
||||||
|
See the documentation for `Tabs` for how to use it. |
@ -0,0 +1,30 @@ |
|||||||
|
<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; |
||||||
|
height: 100%; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<aspect-test> |
||||||
|
<div class="top-box" style="--aspect-ratio: 16/9;"> |
||||||
|
<p>16/9 Aspect Ratio. This is a test to see if the new CSS aspect-ratio property actually works. |
||||||
|
Let me know if this is not consistently 16/9. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div style="--aspect-ratio: 10/2; background-color: var(--value7); font-size: 1.5vw;"> |
||||||
|
<p>10/2 Aspect Ratio. This is a test to see if the new CSS aspect-ratio property actually works. |
||||||
|
Let me know if this is not consistently 10/2. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</aspect-test> |
@ -0,0 +1,12 @@ |
|||||||
|
Because CSS doesn't really support aspect ratios (despite a standard that says it does) you'll have a difficult time positioning and sizing many elements on the page. The `--aspect-ratio` hack I've put into `static/global.css` does a decent job of creating a fake aspect ratio system without too many changes to your HTML. You only need to add: |
||||||
|
|
||||||
|
``` |
||||||
|
style="--aspect-ratio: 16/9" |
||||||
|
``` |
||||||
|
|
||||||
|
And the magic CSS should do what you want. It's a trick I've taken from [an article on CSS tricks](https://css-tricks.com/aspect-ratio-boxes/#using-custom-properties) that gives an excellent solution that doesn't require crazy changed to your CSS or HTML. You can also use `16/9` or decimal notation `1.4`. |
||||||
|
|
||||||
|
Limitations |
||||||
|
=== |
||||||
|
|
||||||
|
As you change the size of this demo you'll see the limitation of this method. As the boxes shrink the text will explode out of the "box" that CSS has, or the boxes will cover other boxes. It's best to have only 1 or 2 elements that use `--aspect-ration` and test it at various sizes. |
@ -0,0 +1,51 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
box { |
||||||
|
width: 200px; |
||||||
|
height: 100px; |
||||||
|
background-color: var(--color-bg-inverted); |
||||||
|
color: var(--color-text-inverted); |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
position: relative; |
||||||
|
padding: 0.5rem; |
||||||
|
border-radius: var(--border-radius); |
||||||
|
box-shadow: var(--box-shadow) var(--color-shadow); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<p>A simple badge with a number:</p> |
||||||
|
<badge>1</badge> |
||||||
|
|
||||||
|
<p>A badge with a tiny icon in it:</p> |
||||||
|
<badge><Icon name="inbox" size="18px" width="1px" /></badge> |
||||||
|
|
||||||
|
|
||||||
|
<p>Top left position with <b>class="top-left"</b>:</p> |
||||||
|
|
||||||
|
<box> |
||||||
|
<b>Top Left</b> |
||||||
|
<badge class="top-left"><Icon name="inbox" size="14px" width="1px" /></badge> |
||||||
|
</box> |
||||||
|
|
||||||
|
<p>Bottom left position with <b>class="bottom-left"</b>:</p> |
||||||
|
<box> |
||||||
|
<b>Bottom Left</b> |
||||||
|
<badge class="bottom-left"><Icon name="inbox" size="14px" width="1px" /></badge> |
||||||
|
</box> |
||||||
|
|
||||||
|
<p>Top right position with <b>class="top-right"</b>:</p> |
||||||
|
<box> |
||||||
|
<b>Top Right</b> |
||||||
|
<badge class="top-right"><Icon name="inbox" size="14px" width="1px" /></badge> |
||||||
|
</box> |
||||||
|
|
||||||
|
<p>Bottom right position with <b>class="bottom-right"</b>:</p> |
||||||
|
<box> |
||||||
|
<b>Bottom Right</b> |
||||||
|
<badge class="bottom-right"><Icon name="inbox" size="14px" width="1px" /></badge> |
||||||
|
</box> |
@ -0,0 +1,3 @@ |
|||||||
|
Badges are simple little notification icons placed on elements. The ones implemented here are _very_ minimalist, with just enough space to work with the [feather icons](https://feathericons.com) included with the project. |
||||||
|
|
||||||
|
You can place most single character elements inside a badge but test that your font will scale appropriately on different screen sizes. |
@ -0,0 +1,25 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
button-group.vertical { |
||||||
|
width: min-content; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<p>This is also shown at the bottom the Card demo. It's just a compact strip for buttons.</p> |
||||||
|
|
||||||
|
<button-group> |
||||||
|
<button id="ok"><Icon name="check-circle" color="var(--color-accent)" size="48" /></button> |
||||||
|
<button><Icon name="x-circle" color="var(--color-bg)" size="48"/></button> |
||||||
|
<button><Icon name="alert-circle" color="var(--color-bg)" size="48"/></button> |
||||||
|
</button-group> |
||||||
|
|
||||||
|
<h2>Vertical</h2> |
||||||
|
|
||||||
|
<button-group class="vertical"> |
||||||
|
<button id="ok"><Icon name="check-circle" color="var(--color-accent)" size="48" /></button> |
||||||
|
<button><Icon name="x-circle" color="var(--color-bg)" size="48"/></button> |
||||||
|
<button><Icon name="alert-circle" color="var(--color-bg)" size="48"/></button> |
||||||
|
</button-group> |
@ -0,0 +1 @@ |
|||||||
|
ButtonGroup will bundle a strip of buttons together with one or more marked "active". It will correctly curve the corners of the first and last button, and can be run horizontally or vertically. |
@ -0,0 +1,27 @@ |
|||||||
|
<script> |
||||||
|
import Calendar from "$/client/components/Calendar.svelte"; |
||||||
|
|
||||||
|
let message = ""; |
||||||
|
|
||||||
|
|
||||||
|
const format_date = (d) => d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); |
||||||
|
|
||||||
|
const date_selected = (event) => { |
||||||
|
message = `Date is: ${format_date(event.detail)}`; |
||||||
|
} |
||||||
|
|
||||||
|
const next_month = (event) => { |
||||||
|
message = `Next! First of Month: ${format_date(event.detail.fom)}`; |
||||||
|
} |
||||||
|
|
||||||
|
const prev_month = (event) => { |
||||||
|
message = `Previous! First of Month: ${format_date(event.detail.fom)}`; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<Calendar on:select={ date_selected } on:next={ next_month } on:previous={ prev_month } /> |
||||||
|
|
||||||
|
{#if message} |
||||||
|
<h4>{ message }</h4> |
||||||
|
{/if} |
@ -0,0 +1 @@ |
|||||||
|
This is a _very_ basic implementation of a calendar that you might use in a fancy date picker. It does _not_ take into account the myriad of different date systems in the world, but it wouldn't be hard to change it to meet your needs. Consider it a starting point for your own Calendar if you need one. |
@ -0,0 +1,37 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
callout { |
||||||
|
margin: 1rem; |
||||||
|
width: 50%; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<callout> |
||||||
|
<span>This is a normal callout example.</span> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<callout class="success"> |
||||||
|
<span>This is a success callout example.</span> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<callout class="warning"> |
||||||
|
<span>This is a warning callout example.</span> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<callout class="alert"> |
||||||
|
<span>This is an alert callout example.</span> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<callout class="info"> |
||||||
|
<span>This is an info callout example.</span> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<h2>Callout with Badges</h2> |
||||||
|
|
||||||
|
<callout class="alert"> |
||||||
|
<span>This is an alert callout example with a badge.</span> |
||||||
|
<badge class="top-right"><Icon name="alert-triangle" width="2px" size="18px" /></badge> |
||||||
|
</callout> |
@ -0,0 +1,8 @@ |
|||||||
|
The Callout is simply a way to focus the user on a piece of text they should see. There's different levels of callout you can use: |
||||||
|
|
||||||
|
* `class="alert"` -- An alert style you can use to tell of impending doom. Change the variable `--red` or `--color-error` in the CSS to change it's color from the default monochrome. |
||||||
|
* `class="success"` -- For success messages. Change `--color-good` or `--green` to change it in the CSS. |
||||||
|
* `class="warning"` -- For warning messages. Change `--color-warning` or `--orange`. |
||||||
|
* `class="info"` -- For info messages. Change `--color-info` or `--yellow`. |
||||||
|
|
||||||
|
When I say "change color" I mean go into the file `static/monochrome.css` or `static/color.css` to change how it looks. |
@ -0,0 +1,46 @@ |
|||||||
|
<script> |
||||||
|
import Icon from '$/client/components/Icon.svelte'; |
||||||
|
import PlaceHolder from "$/client/components/PlaceHolder.svelte"; |
||||||
|
</script> |
||||||
|
<style> |
||||||
|
content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
flex: flex-shrink; |
||||||
|
flex-grow: 1; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
button#ok { |
||||||
|
background-color: var(--color-bg-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
card { |
||||||
|
width: 600px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<content> |
||||||
|
<card> |
||||||
|
<top> |
||||||
|
<PlaceHolder width={ 16 * 60 } height={ 9 * 60 } /> |
||||||
|
</top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<h4> |
||||||
|
Card Example |
||||||
|
</h4> |
||||||
|
<p>Lorem ipsum dolor sit amet, consectetur |
||||||
|
adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button id="ok"><Icon name="check-circle" color="var(--color-accent)" size="48" /></button> |
||||||
|
<button><Icon name="x-circle" color="var(--color-bg)" size="48"/></button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
|
||||||
|
<p>This is another example of simply using CSS to implement basic components. Rather than implement Svelte compoentns with slots it is sometimes <b>much</b> easier to CSS and plain HTML. To see how to use cards look at <b>admin/bando/demos/Cards.svelte</b>. I also show you how to restyle the buttons if you want them to be larger and square like you find in many panel examples.</p> |
||||||
|
</content> |
@ -0,0 +1 @@ |
|||||||
|
Cards are used everywhere on the web, many times without you even knowing it. They allow you to show an image or similar media, some text, then some buttons. A variant of `Cards` is `Tiles` that are a horizontal layout of the same information. I generally use `Card` when I want a large display of the image or video, and `Tile` when I want a small display with a thumbnail or icon. |
@ -0,0 +1,25 @@ |
|||||||
|
<script> |
||||||
|
import Carousel from "$/client/components/Carousel.svelte"; |
||||||
|
import IconImage from "$/client/components/IconImage.svelte"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
|
||||||
|
export let panels = [ |
||||||
|
{caption: "Anchors Away", active: true, component: IconImage, |
||||||
|
props: { name: "anchor", hue: "red", background_hue: "green" } }, |
||||||
|
{caption: "Money Money Money", active: false, component: IconImage, |
||||||
|
props: { name: "dollar-sign", hue: "blue", background_hue: "purple", pattern: "triangles-sm" } }, |
||||||
|
{caption: "We Should Talk", active: false, component: IconImage, |
||||||
|
props: { name: "message-circle", hue: "green", background_hue: "orange", pattern: "lines-sm" } }, |
||||||
|
]; |
||||||
|
|
||||||
|
let selected = panels[0]; |
||||||
|
|
||||||
|
const tab_select = (event) => { |
||||||
|
// this is where you can grab selection information |
||||||
|
log.debug("SELECTED TAB", event.detail); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Carousel panels={ panels } on:select={ tab_select } bind:selected /> |
||||||
|
|
||||||
|
<h3>You're looking at the {selected.caption} panel which has a {selected.props.name} icon.</h3> |
@ -0,0 +1 @@ |
|||||||
|
The `Carousel` is found mostly on landing pages and even then it's considered not very useful. The problem with this UI component is it makes it difficult to navigate the images, and if it's automated then users will have a hard time accessing something you show them when it passes by. If you're using it I suggest using it only on the landing page, and then if it auto-rotates have it stop when users touch it for the first time. |
@ -0,0 +1,14 @@ |
|||||||
|
<script> |
||||||
|
import Chat from "$/client/components/Chat.svelte"; |
||||||
|
import Markdown from "$/client/components/Markdown.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
div { |
||||||
|
max-width: 600px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<div> |
||||||
|
<Chat /> |
||||||
|
</div> |
@ -0,0 +1 @@ |
|||||||
|
The `Chat` is a small mini chat you might find on live streaming websites. It's not full featured but the client and backend are simple enough that you can change them to do what you want. To make it work you may need to look at `socket/chat.js` and make sure that's running correctly. If you don't use `Chat` on your site then consider removing `socket/chat.js` and edit `client/stores.js` to remove all [socket-io](https://socket.io) references. This will save you about 50k in the downloaded size of your app. |
@ -0,0 +1,53 @@ |
|||||||
|
<script> |
||||||
|
import Code from "$/client/components/Code.svelte"; |
||||||
|
import Spinner from "$/client/components/Spinner.svelte"; |
||||||
|
import CodeFormatter from "$/client/components/CodeFormatter.svelte"; |
||||||
|
|
||||||
|
let code = ""; |
||||||
|
let test_code = "/test.js"; |
||||||
|
let message = ""; |
||||||
|
let timer; |
||||||
|
|
||||||
|
const load_code = async () => { |
||||||
|
let res = await fetch(test_code); |
||||||
|
code = res.status == 200 ? await res.text() : `Error getting /test.js: ${res.status}`; |
||||||
|
} |
||||||
|
|
||||||
|
const code_copied = () => { |
||||||
|
if(timer) clearTimeout(timer); |
||||||
|
message = "You copied it!"; |
||||||
|
timer = setTimeout(() => message = "", 2000); |
||||||
|
} |
||||||
|
|
||||||
|
let code_promise = load_code(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#await code_promise} |
||||||
|
<Spinner /> |
||||||
|
{:then} |
||||||
|
<p>You can display code with color, line numbers, and clipboard copying with <code>Code</code>, and you can click on the code to copy it to your clipboard:</p> |
||||||
|
<Code content={ code } on:copy={ code_copied } language="javascript" /> |
||||||
|
|
||||||
|
<hr> |
||||||
|
|
||||||
|
<p>It's designed similar to the <code>Markdown</code> component in that you can put the code in <code>content=""</code>, the inner slot, or as a <code>src=""</code> url to load.</p> |
||||||
|
|
||||||
|
<Code on:copy={ code_copied } language="javascript"> |
||||||
|
{ code } |
||||||
|
</Code> |
||||||
|
|
||||||
|
<hr/> |
||||||
|
|
||||||
|
<p>Here is the same code in <code>test.js</code> being loaded via a <code>src=""</code>.<p> |
||||||
|
</p> |
||||||
|
|
||||||
|
<Code src={ test_code } on:copy={ code_copied } language="javascript"/> |
||||||
|
|
||||||
|
{#if message} |
||||||
|
<toast class="bottom-right"> |
||||||
|
{ message } |
||||||
|
</toast> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<CodeFormatter /> |
||||||
|
{/await} |
@ -0,0 +1,25 @@ |
|||||||
|
The `Code` component simplifies displaying code with line numbers and letting people copy the code to their clipboard. It will get the code from a URL, wrap it in a `<pre><code>..</code></pre>` block, and then convert each line to an inner `<span>` so it will receive a line number using CSS. |
||||||
|
|
||||||
|
The CSS that makes this magic happen is: |
||||||
|
|
||||||
|
<pre> |
||||||
|
<code data-language="css"> |
||||||
|
pre code span::before { |
||||||
|
counter-increment: line; |
||||||
|
content: counter(line); |
||||||
|
display: inline-block; |
||||||
|
padding: 0 0.3rem; |
||||||
|
margin-right: 0.5rem; |
||||||
|
border-right: 1px solid var(--color-inactive); |
||||||
|
min-width: 3ch; |
||||||
|
text-align: right; |
||||||
|
color: var(--color-inactive); |
||||||
|
-webkit-user-select: none; |
||||||
|
-moz-user-select: none; |
||||||
|
-ms-user-select: none; |
||||||
|
user-select: none; |
||||||
|
}</code></pre> |
||||||
|
|
||||||
|
This uses the `::before`` selector to add a line number counter to the front of each span, and then create a thin line. This is why you can use your mouse to select the lines of code but the _line numbers_ won't be selected (even if some browsers show you they are being selected). |
||||||
|
|
||||||
|
To make it easier on people it will also just copy the code when you click on it and then do a `Toast` alert. |
@ -0,0 +1,16 @@ |
|||||||
|
<script> |
||||||
|
import Countdown from "$/client/components/Countdown.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
background { |
||||||
|
background-color: var(--color-bg-inverted); |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<background> |
||||||
|
<Countdown starts_on={ new Date(Date.now() + 20000) } /> |
||||||
|
</background> |
@ -0,0 +1 @@ |
|||||||
|
In my `LiveStream` usage I found I needed to tell people when the stream was starting with a countdown timer. This will implement a simple panel that you can layer over the video using the `Stacked/Layer` CSS. |
@ -0,0 +1,6 @@ |
|||||||
|
<script> |
||||||
|
import Darkmode from "$/client/components/Darkmode.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<Darkmode /> |
@ -0,0 +1,21 @@ |
|||||||
|
This simple little component will create a dark or light switch that changes the CSS and remembers the users setting. The technique uses this line from `static/colors.css` or `static/monochrome.css`: |
||||||
|
|
||||||
|
``` |
||||||
|
[data-theme="dark"] { |
||||||
|
``` |
||||||
|
|
||||||
|
This changes the CSS using the variables in that block to alter their colors to darker versions. Then in the JavaScript the theme is stored in the browser's local storage to remember it: |
||||||
|
|
||||||
|
``` |
||||||
|
const set_theme = () => { |
||||||
|
document.documentElement.setAttribute('data-theme', theme); |
||||||
|
localStorage.setItem('theme', theme); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
When the user comes back their theme is loaded with: |
||||||
|
|
||||||
|
``` |
||||||
|
theme = localStorage.getItem('theme') ? localStorage.getItem('theme') : 'light'; |
||||||
|
set_theme() |
||||||
|
``` |
@ -0,0 +1,71 @@ |
|||||||
|
<script> |
||||||
|
import DataTable from "$/client/components/DataTable.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import { link } from 'svelte-spa-router'; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
|
||||||
|
let rows = [ |
||||||
|
{_url: "/", id: 1, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 2, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 3, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 4, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 5, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 7, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 7, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 8, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 9, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 10, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 11, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 12, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 13, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 14, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 15, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 16, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 17, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 18, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 19, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 20, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
{_url: "/", id: 21, name: "Zed", age: 100, eyes: "Blue", hair: "Bald"}, |
||||||
|
]; |
||||||
|
|
||||||
|
let column_names = Object.keys(rows[0]); |
||||||
|
|
||||||
|
let pagination = { currentPage: 1, lastPage: 1 } |
||||||
|
|
||||||
|
const full_query = async (event) => { |
||||||
|
log.debug("QUERY", event.detail); |
||||||
|
} |
||||||
|
|
||||||
|
const search_query = async (event) => { |
||||||
|
let search_text = event.detail; |
||||||
|
|
||||||
|
log.debug("SEARCH", search_text); |
||||||
|
} |
||||||
|
|
||||||
|
const clear_search = async (event) => { |
||||||
|
log.debug("CLEAR", event.detail); |
||||||
|
} |
||||||
|
|
||||||
|
const cell_click = (event) => { |
||||||
|
log.debug("CELL", event.detail); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await full_query(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<DataTable rows={ rows } |
||||||
|
columns={ column_names } |
||||||
|
bind:pagination |
||||||
|
on:full_query={ full_query } |
||||||
|
on:search_query={ search_query } |
||||||
|
on:clear_search={ clear_search } |
||||||
|
on:cell_click={ cell_click }> |
||||||
|
|
||||||
|
<a href="/admin/table/" use:link><Icon name="arrow-left" size="36" /></a> |
||||||
|
<a href="/admin/table/create/table/" use:link><Icon name="file-plus" size="36" /></a> |
||||||
|
|
||||||
|
</DataTable> |
@ -0,0 +1,3 @@ |
|||||||
|
The `DataTable` simply takes an [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) of objects and displays them using a typical table with rows and columns. It also provides pagination and search features by default. |
||||||
|
|
||||||
|
It's intended to be a starting point for your own table display if you need one, and it's used in the `admin/` management system. |
@ -0,0 +1,252 @@ |
|||||||
|
<script> |
||||||
|
import { link } from 'svelte-spa-router'; |
||||||
|
import { fade } from 'svelte/transition'; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import api from "$/client/api.js"; |
||||||
|
import FormField from "$/client/components/FormField.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import IconImage from "$/client/components/IconImage.svelte"; |
||||||
|
import Paypal from "$/client/components/Paypal.svelte"; |
||||||
|
import BTCPay from "$/client/components/BTCPay.svelte"; |
||||||
|
import Layout from "$/client/Layout.svelte"; |
||||||
|
import { fake_payments } from "$/client/config.js"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
|
||||||
|
const quips = { |
||||||
|
"0": "Awww, really? Alright then.", |
||||||
|
"10": "That's a good start.", |
||||||
|
"20": "Even more fair.", |
||||||
|
"30": "Sweet!", |
||||||
|
"40": "Yes! Love it!", |
||||||
|
"50": "Booyah!", |
||||||
|
"60": "Really?! Thank you!", |
||||||
|
"70": "Whoa! No way!", |
||||||
|
"80": "Fantastic! THANK YOU!", |
||||||
|
"90": "NO WAY! YES!", |
||||||
|
"100": "I love you!" |
||||||
|
} |
||||||
|
|
||||||
|
const form = { |
||||||
|
amount: 10, |
||||||
|
_errors: {}, |
||||||
|
_valid: true |
||||||
|
} |
||||||
|
|
||||||
|
// BUG: we have to duplicate this logic here because of how paypal works |
||||||
|
$: form._valid = form.amount >= 0 && form.amount <= 100; |
||||||
|
|
||||||
|
const amount_by = 10; |
||||||
|
|
||||||
|
let paid_in_full = false; |
||||||
|
let payment_failed = false; |
||||||
|
|
||||||
|
const change_amount = () => { |
||||||
|
form.amount = form.amount < 100 ? form.amount + amount_by : 0; |
||||||
|
} |
||||||
|
|
||||||
|
const payment_finished = (event) => { |
||||||
|
const { system, status } = event.detail; |
||||||
|
log.assert(status === "complete", "Payment status should be complete with finished event", system, event.detail); |
||||||
|
paid_in_full = true; |
||||||
|
} |
||||||
|
|
||||||
|
const payment_canceled = (event) => { |
||||||
|
log.debug("CANCELED", event); |
||||||
|
} |
||||||
|
|
||||||
|
const payment_error = () => { |
||||||
|
payment_failed = true; |
||||||
|
} |
||||||
|
|
||||||
|
const payment_loading = (event) => { |
||||||
|
log.debug("LOADING", event); |
||||||
|
} |
||||||
|
|
||||||
|
const pay_nothing = async () => { |
||||||
|
// posting to /api/user/payments is how you do a free purchase |
||||||
|
let [status, data] = await api.post("/api/user/payments", { amount: form.amount }, api.logout_user); |
||||||
|
|
||||||
|
if(status === 200) { |
||||||
|
paid_in_full = true; |
||||||
|
} else { |
||||||
|
log.error("status", status, "data", data); |
||||||
|
payment_failed = true; |
||||||
|
form._errors.main = data.message || data.error || "Payment Error."; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// this is a demo of how to check a user's purchase quickly |
||||||
|
const already_paid = async () => { |
||||||
|
let [status, data] = await api.get("/api/user/payments"); |
||||||
|
|
||||||
|
if(status === 200 && data.paid === true) { |
||||||
|
paid_in_full = true; |
||||||
|
} else { |
||||||
|
log.debug("GET to /api/user/payments returned", status, data); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await already_paid(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
|
||||||
|
form#purchase { |
||||||
|
width: min-content; |
||||||
|
} |
||||||
|
|
||||||
|
label.slider { |
||||||
|
cursor: pointer; |
||||||
|
display: flex; |
||||||
|
height: 4rem; |
||||||
|
width: 100%; |
||||||
|
background-color: var(--color-bg-secondary); |
||||||
|
user-select: none; |
||||||
|
-webkit-user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
label.slider::after { |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
width: calc(1% * var(--amount)); |
||||||
|
min-width: 2ch; |
||||||
|
background-color: var(--color-bg-tertiary); |
||||||
|
height: 4rem; |
||||||
|
counter-reset: amount var(--amount); |
||||||
|
content: '$' counter(amount); |
||||||
|
transition: 0.5s; |
||||||
|
} |
||||||
|
|
||||||
|
input#amount { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
card { |
||||||
|
width: 400px; |
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0px 0px; |
||||||
|
} |
||||||
|
|
||||||
|
card#failed top { |
||||||
|
background-color: var(--color-bg-secondary); |
||||||
|
text-align: center; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
payments { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
} |
||||||
|
|
||||||
|
payments.disabled { |
||||||
|
filter: blur(5px); |
||||||
|
} |
||||||
|
|
||||||
|
form card bottom { |
||||||
|
padding: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
callout { |
||||||
|
border-radius: 0px 0px var(--border-radius) var(--border-radius); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout centered={ true } authenticated={ true }> |
||||||
|
{#if paid_in_full} |
||||||
|
<card in:fade|local id="paid"> |
||||||
|
<top> |
||||||
|
<IconImage name="dollar-sign" /> |
||||||
|
</top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<h1>Welcome!</h1> |
||||||
|
<p>Thank you for your purchase. You can now enjoy the entire |
||||||
|
site. |
||||||
|
</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button data-testid="payment-done-button"><a href="/" use:link>Start Browsing</a></button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
{:else} |
||||||
|
{#if payment_failed} |
||||||
|
<card in:fade|local id="failed"> |
||||||
|
<top><h1 data-testid="payment-error">Payment Error!</h1></top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<p style="font-size: 1.5em;">There was an error processing your payment. Please try again later.</p> |
||||||
|
<p>You can email <a href="mailto:help@xor.academy">help@xor.academy</a> to get help with this purchase.</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button type="button"><a href="/" use:link><Icon name="arrow-left-circle" size="36" /> Cancel</a></button> |
||||||
|
<button data-testid="payment-tryagain" on:click={ () => payment_failed = false }><Icon name="credit-card" size="36" light={ true } /> Try Again</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
{:else} |
||||||
|
<form method="POST" id="purchase"> |
||||||
|
<card> |
||||||
|
<top> |
||||||
|
{#if payment_failed } |
||||||
|
<h1>Payment Error!</h1> |
||||||
|
{:else} |
||||||
|
<h1>What's a Fair Price?</h1> |
||||||
|
{/if} |
||||||
|
</top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<FormField form={ form } field="amount" label="{ quips[form.amount] || `Bad Value! ${form.amount}` }"> |
||||||
|
<label data-testid="payment-amount" class="slider" |
||||||
|
for="amount" style="--amount: { form.amount };" |
||||||
|
on:click={ () => change_amount() }> |
||||||
|
</label> |
||||||
|
<input type="numeric" id="amount" bind:value={ form.amount } /> |
||||||
|
</FormField> |
||||||
|
|
||||||
|
{#if form._valid} |
||||||
|
<p>Click on the slider to change <b>how much you think this course is worth</b>, then |
||||||
|
<b>select your payment method</b>.</p> |
||||||
|
{:else} |
||||||
|
<p><b>You've caused an error in this form. Please email help@xor.academy and tell them how you did this.</b></p> |
||||||
|
{/if} |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<payments class:disabled={ !form._valid }> |
||||||
|
{#if form.amount > 0} |
||||||
|
<Paypal credit_card={ true } amount={ form.amount } |
||||||
|
on:error={ payment_error} |
||||||
|
on:finished={ payment_finished } |
||||||
|
on:canceled={ payment_canceled } |
||||||
|
on:loading={ payment_loading } |
||||||
|
disabled = { !form._valid }/> |
||||||
|
|
||||||
|
<BTCPay amount={ form.amount } |
||||||
|
on:error={ payment_error} |
||||||
|
on:finished={ payment_finished } |
||||||
|
on:canceled={ payment_canceled } |
||||||
|
on:loading={ payment_loading } |
||||||
|
disabled = { !form._valid } /> |
||||||
|
{:else} |
||||||
|
<button type="button" data-testid="button-paynothing" on:click|preventDefault={ pay_nothing }>Pay Nothing</button> |
||||||
|
{/if} |
||||||
|
</payments> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
{#if fake_payments} |
||||||
|
<callout class="warning"> |
||||||
|
Payments are fake right now. Use a fake CC from Paypal, |
||||||
|
and use testnet coins for BTC/LTC. |
||||||
|
</callout> |
||||||
|
{/if} |
||||||
|
</form> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</Layout> |
@ -0,0 +1,16 @@ |
|||||||
|
The FairPay component is more of a demo than an actual component. It shows how to use the |
||||||
|
`Paypal.svelte` and `BTCPay.svelte` components. The `FairPay.svelte` implements a "pay what you |
||||||
|
want" style of payments. |
||||||
|
|
||||||
|
Why the $10 increments? There's a form of credit card crime called "carding" where people with |
||||||
|
stolen numbers test their cards on any purchase that is $1. They'll get large numbers of possibly |
||||||
|
valid credit cards and they need to test them. The best way to test them is to attempt a very small |
||||||
|
purchase, and if the purchase works then they mark the card valid. |
||||||
|
|
||||||
|
If you allow people to pay any amount as a donation then carders will raid your purchase form and |
||||||
|
rack up $1 fraudulent charges. That may not seem to bad, but your payment processor will then |
||||||
|
charge _you_ $15 or more to handle the charge back. Even if you offer to refund the purchase and |
||||||
|
they do no work. This means if you receive $100 of fraudulent charges you would owe $1500 in fees. |
||||||
|
|
||||||
|
The easiest way to prevent this is to simply only allow increments of $10. $10 is too high for most |
||||||
|
carder testers, so this will deter them (at least until everyone does this). |
@ -0,0 +1,52 @@ |
|||||||
|
<style> |
||||||
|
card { |
||||||
|
width: 400px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<script> |
||||||
|
import IconImage from "$/client/components/IconImage.svelte"; |
||||||
|
let flipped = false; |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<flipper class:flipped={ flipped }> |
||||||
|
<inner> |
||||||
|
<card class="front"> |
||||||
|
<top> |
||||||
|
<IconImage name="arrow-left" /> |
||||||
|
</top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<h4> |
||||||
|
Front Card |
||||||
|
</h4> |
||||||
|
<p>This is the front facing card. Click the button to flip it.</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button on:click={ () => flipped = !flipped }>Flip It!</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
<card class="back"> |
||||||
|
<top> |
||||||
|
<IconImage name="arrow-right" /> |
||||||
|
</top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<h4> |
||||||
|
Back Card |
||||||
|
</h4> |
||||||
|
<p>This is the back facing card. Click the button to flip it back.</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button on:click={ () => flipped = !flipped }>Flip it Back!</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
</inner> |
||||||
|
</flipper> |
@ -0,0 +1,13 @@ |
|||||||
|
The `Flipper` shows two panels on the front and back of and lets you "flip" them around. This is a |
||||||
|
decent way to restrict the user interface between two options, but it will most likely have problems |
||||||
|
with accessibility without some additional `aria` attributes. |
||||||
|
|
||||||
|
The technique uses the `transform-style: preserve-3d` to make sure that the browser treats it like a |
||||||
|
3D space. Then it uses `transform: rotateY(180deg)` to do the flipping action. |
||||||
|
|
||||||
|
You finally need these two lines to maintain the back and front: |
||||||
|
|
||||||
|
``` |
||||||
|
-webkit-backface-visibility: hidden; /* Safari */ |
||||||
|
backface-visibility: hidden; |
||||||
|
``` |
@ -0,0 +1,78 @@ |
|||||||
|
<script> |
||||||
|
import FormField from '$/client/components/FormField.svelte'; |
||||||
|
import api from "$/client/api.js"; |
||||||
|
|
||||||
|
let form = { |
||||||
|
full_name: "", |
||||||
|
password: "", |
||||||
|
subscribe: '0', |
||||||
|
email: "", |
||||||
|
notes: "", |
||||||
|
_valid: false, |
||||||
|
_errors: {main: ""}, |
||||||
|
} |
||||||
|
|
||||||
|
// normally the rules are maintained on the /api/ and then added to the |
||||||
|
// form when there's an error. See client/pages/Register.svelte for a good example |
||||||
|
form._rules = { |
||||||
|
full_name: "required", |
||||||
|
password: "required", |
||||||
|
subscribe: "required", |
||||||
|
email: "required|email", |
||||||
|
notes: "", |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<style> |
||||||
|
main { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
padding-top: 3rem; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<main> |
||||||
|
<form action="/api/user/profile" method="POST"> |
||||||
|
<card> |
||||||
|
<top> |
||||||
|
<h1>Register</h1> |
||||||
|
<error>{ form._errors.main }</error> |
||||||
|
</top> |
||||||
|
|
||||||
|
|
||||||
|
<middle> |
||||||
|
<FormField form={ form } field="full_name" label="Full Name"> |
||||||
|
<input type="text" id="full_name" bind:value={ form.full_name } name="full_name"> |
||||||
|
</FormField> |
||||||
|
|
||||||
|
|
||||||
|
<FormField form={ form } field="password" label="Password"> |
||||||
|
<input type="password" id="password" bind:value={ form.password } name="password"> |
||||||
|
</FormField> |
||||||
|
|
||||||
|
|
||||||
|
<FormField form={ form } field="email" label="Email"> |
||||||
|
<input type="email" id="email" bind:value={ form.email } name="email"> |
||||||
|
</FormField> |
||||||
|
|
||||||
|
|
||||||
|
<FormField form={ form } field="notes" label="Notes"> |
||||||
|
<textarea bind:value={ form.notes } name="notes" id="notes"></textarea> |
||||||
|
</FormField> |
||||||
|
|
||||||
|
<FormField form={ form } field="subscribe" label="Want Notifications?"> |
||||||
|
<input type="checkbox" id="subscribe" name="subscribe" bind:checked={ form.subscribe }> |
||||||
|
</FormField> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button data-testid="register-button" |
||||||
|
on:click|preventDefault={ () => form = api.validate(form) }>Register</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
</form> |
||||||
|
</main> |
@ -0,0 +1,10 @@ |
|||||||
|
This demonstrates how to use the `FormField` component to construct forms with nicer error messages. There are a lot of things you need to do for nice error messages on forms, and `FormField` handles most of them. You need to set variables for it, then place your input inside: |
||||||
|
|
||||||
|
``` |
||||||
|
<FormField form={ form } field="subscribe" label="Want Notifications?"> |
||||||
|
<input type="checkbox" id="subscribe" name="subscribe" bind:checked={ form.subscribe }> |
||||||
|
</FormField> |
||||||
|
``` |
||||||
|
|
||||||
|
All of the input fields work mostly the same but with Svelte you need to use `bind:checked={ |
||||||
|
variable }` instead of `bind:value`. |
@ -0,0 +1,22 @@ |
|||||||
|
<script> |
||||||
|
import HLSVideo from "$/client/components/HLSVideo.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
let source = "/images/sample_video.mp4"; |
||||||
|
let poster = "/images/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> |
||||||
|
|
||||||
|
<h1>Instructions</h1> |
||||||
|
|
||||||
|
<p>To get this demo to work you should get the |
||||||
|
<a href="/client/#/bando/components/WTVideo/">WTVideo</a> demo working. |
||||||
|
This demo will have you create a sample image and sample video that |
||||||
|
the <code>HLSVideo</code> uses as well. If you don't have access to |
||||||
|
the <code>mktorrent</code> command mentioned in the |
||||||
|
<a href="/client/#/bando/components/WTVideo/">WTVideo</a> demo then |
||||||
|
just skip that part and as long as you have the samples in place |
||||||
|
the <code>HLSVideo</code> demo will work. </p> |
@ -0,0 +1,63 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import Code from "$/client/components/Code.svelte"; |
||||||
|
let size = 64; |
||||||
|
let fill = "none"; |
||||||
|
let color = "var(--color)"; |
||||||
|
let width = "2px"; |
||||||
|
let shadow = ""; |
||||||
|
let tooltip = "This is a tip!"; |
||||||
|
let tip_position = "bottom"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
icons { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
} |
||||||
|
|
||||||
|
icons div { |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
font-size: 2em; |
||||||
|
} |
||||||
|
|
||||||
|
icons div input, |
||||||
|
icons div select { |
||||||
|
font-size: 1em; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<icons> |
||||||
|
<div>size: <input name="size" bind:value={ size } /></div> |
||||||
|
<div>fill: <input name="fill" bind:value={ fill } /></div> |
||||||
|
<div>width: <input name="width" bind:value={ width } /></div> |
||||||
|
<div>color: <input name="color" bind:value={ color } /></div> |
||||||
|
<div>shadow: <input name="shadow" type="checkbox" bind:checked={ shadow } /></div> |
||||||
|
<div>tooltip: <input name="tooltip" bind:value={ tooltip } /></div> |
||||||
|
<div>tip_position: |
||||||
|
<select name="tip_position" bind:value={ tip_position }> |
||||||
|
<option>bottom</option> |
||||||
|
<option>left</option> |
||||||
|
<option>right</option> |
||||||
|
<option>top</option> |
||||||
|
<option>bottom-left</option> |
||||||
|
<option>bottom-right</option> |
||||||
|
<option>top-left</option> |
||||||
|
<option>top-right</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
<Icon name="keyboard" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} /> |
||||||
|
<Icon name="alarm-clock" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} /> |
||||||
|
<Icon name="activity" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} /> |
||||||
|
<Icon name="apple" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} /> |
||||||
|
<Icon name="angry" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} /> |
||||||
|
<Icon name="anchor" color={color} size={size} fill={fill} width={width} shadow={shadow} tooltip={tooltip} tip_position={tip_position} /> |
||||||
|
</icons> |
||||||
|
|
||||||
|
<hr> |
||||||
|
<h4>Code Sample using <code>anchor</code></h4> |
||||||
|
|
||||||
|
<Code language="html" content='<Icon name="anchor" color="{color}" size="{size}" fill="{fill}" width="{width}" shadow="{shadow}" tooltip="{tooltip}" tip_position="{tip_position}" />' /> |
||||||
|
|
||||||
|
<a href="/admin/#/icons"><button type="button">See all icons and search them.</button></a> |
@ -0,0 +1,5 @@ |
|||||||
|
# Icons by <a href="https://lucide.dev">lucide</a> |
||||||
|
|
||||||
|
The Bandolier includes the great icon collection from <a href="https://lucide.dev">lucide</a> to get you started. Probably every icon you could possibly need is in this collection, and there's a nice Icon Finder tool to find what you need at <a href="/admin/#/icons/">/admin/#/icons/</a>. |
||||||
|
|
||||||
|
Using `Icon` is very simple. You find the name, and can set a size, whether it's inactive or not, and a few other options to control the appearance. In the Icon Finder you can click on an icon to copy the code for it. |
@ -0,0 +1,56 @@ |
|||||||
|
<script> |
||||||
|
import IconImage from "$/client/components/IconImage.svelte"; |
||||||
|
|
||||||
|
let index = 0; |
||||||
|
|
||||||
|
let grayscale=0; |
||||||
|
|
||||||
|
const icon_patterns = [ |
||||||
|
{name: "archive", pattern: "dots-sm", hue: "green", background_hue: "orange"}, |
||||||
|
{name: "dollar-sign", pattern: "grid-sm", hue: "orange", background_hue: "blue"}, |
||||||
|
{name: "arrow-up-circle", pattern: "vertical-lines-md", hue: "purple", background_hue: "black"}, |
||||||
|
{name: "camera", pattern: "triangles-lg", hue: "pink", background_hue: "green"}, |
||||||
|
]; |
||||||
|
|
||||||
|
let props = icon_patterns[index]; |
||||||
|
|
||||||
|
const change = () => { |
||||||
|
index += 1; |
||||||
|
props = icon_patterns[index % icon_patterns.length]; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
card { |
||||||
|
max-width: 600px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<card> |
||||||
|
<top> |
||||||
|
<!-- this is how you assign a set of properties to a component using the {...} syntax --> |
||||||
|
<IconImage {...props } grayscale={ grayscale } /> |
||||||
|
</top> |
||||||
|
<middle> |
||||||
|
<h4>IconImage Component</h4> |
||||||
|
<p>The IconImage is kind of a fancy <b>PlaceHolder</b> that embeds an <b>Icon</b> in a pattern field with color. It's good enough to use for quite a while until you can design a better asset. The patterns come from <a href="https://bansal.io/pattern-css" target="_">pattern.css</a>. |
||||||
|
</p> |
||||||
|
|
||||||
|
<code><IconImage name="{ props.name }" pattern="{ props.pattern }" hue="{ props.hue }" background_hue="{ props.background_hue }" grayscale="{ grayscale }" /></code> |
||||||
|
|
||||||
|
<br> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button on:click={ change }>Change It!</button> |
||||||
|
<button on:click={ () => grayscale = grayscale == 0 ? 1 : 0 }> |
||||||
|
{#if grayscale} |
||||||
|
Color It! |
||||||
|
{:else} |
||||||
|
Gray It! |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
@ -0,0 +1,27 @@ |
|||||||
|
<script> |
||||||
|
import Video from "$/client/components/Video.svelte"; |
||||||
|
import Markdown from "$/client/components/Markdown.svelte"; |
||||||
|
|
||||||
|
let video_background = "rgba(0,0,0,0)"; |
||||||
|
|
||||||
|
let stream = { |
||||||
|
"video_source": "HLSJS_SOURCE", |
||||||
|
"video_poster": "/images/header.svg", |
||||||
|
"starts_on": "Jun 11 2021 12:20:22 GMT-0400" |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
container { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
width: 100%; |
||||||
|
min-width: 770px; |
||||||
|
margin-right: 0.5rem; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<container class="pattern-dots-sm"> |
||||||
|
<Video poster={ stream.poster } background_color={video_background } source={ stream.source } /> |
||||||
|
</container> |
||||||
|
|
@ -0,0 +1,5 @@ |
|||||||
|
You can use the `Video` component to stream any HLS stream. On Apple's Safari this works natively and doesn't require any additional code. On other platforms you have to use the [HLS.js library](https://github.com/video-dev/hls.js/) in `static/js/hls.js` to decode and play the HLS protocol. |
||||||
|
|
||||||
|
The `Video` component automatically negotiates the complex dance of figuring out when to load HLS.js. |
||||||
|
|
||||||
|
This demo won't work without you modifying it with an HLS stream (or other stream supported by the `<video>` tag. Change the stream variable in `admin/bando/demos/LiveStream.svelte` to enable one. |
@ -0,0 +1,17 @@ |
|||||||
|
<script> |
||||||
|
import { user } from "$/client/stores.js"; |
||||||
|
import LoggedIn from "$/client/components/LoggedIn.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<LoggedIn> |
||||||
|
<callout slot="yes"> |
||||||
|
<h1>You are logged in.</h1> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<callout class="error" slot="no"> |
||||||
|
<h1>You are NOT logged in</h1> |
||||||
|
</callout> |
||||||
|
</LoggedIn> |
||||||
|
|
||||||
|
<button on:click={ () => $user.authenticated = !$user.authenticated }>Change Login</button> |
||||||
|
|
@ -0,0 +1,16 @@ |
|||||||
|
The LoggedIn component checks if the user is logged in by doing a GET to "/api/login" and if that API endpoint returns 401 or 403 it will perform redirects for you. It updates the `$user.authenticated` Svelte store (from `client/stores.js`) and then as your app changes that setting it will adapt and display different content. |
||||||
|
|
||||||
|
Use this component to: |
||||||
|
|
||||||
|
1. Block a page's content from unauthenticated users. |
||||||
|
2. Redirect users to your a new location if they are not authenticated. |
||||||
|
3. Show a standard redirect page when it's redirecting. |
||||||
|
|
||||||
|
In this test you are simply changing the $user.authenticated to see how the LoggedIn component handles it dynamically. You should still add a check for `$user.authenticated` in your own `onMount` since svelte will run them no matter what. |
||||||
|
|
||||||
|
|
||||||
|
<callout class="warning"> |
||||||
|
<span> |
||||||
|
Currently you should only use LoggedIn as a major page blocking component rather than fine grained individual elements. It makes a network request to `/api/login` to confirm the user's logged in status, and if you have more than one each instance will do this check. If you see repeated calls to `/api/login` then check how your using this component on your pages. |
||||||
|
</span> |
||||||
|
</callout> |
@ -0,0 +1,13 @@ |
|||||||
|
<script> |
||||||
|
import Login from "$/client/components/Login.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
shrink { |
||||||
|
display: flex; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<shrink> |
||||||
|
<Login /> |
||||||
|
</shrink> |
@ -0,0 +1,5 @@ |
|||||||
|
The `Login` is both in `client/pages/Login.svelte` and also in `client/components/Login.svelte`. The |
||||||
|
one in `pages` is more like this demo and simply uses the `component`. The reason for the split is |
||||||
|
so you can prompt for a login with a `Modal` in situations where tearing the user away from what |
||||||
|
they're viewing might be wrong. A good example of this is if people are watching a video but need |
||||||
|
to log in to comment or chat. |
@ -0,0 +1,51 @@ |
|||||||
|
<script> |
||||||
|
import Markdown from "$/client/components/Markdown.svelte"; |
||||||
|
import Spinner from "$/client/components/Spinner.svelte"; |
||||||
|
|
||||||
|
let md = ""; |
||||||
|
let test_md = "/test.md"; |
||||||
|
|
||||||
|
const load_md = async () => { |
||||||
|
let res = await fetch(test_md); |
||||||
|
md = res.status == 200 ? await res.text() : `Error getting /test.md: ${res.status}`; |
||||||
|
} |
||||||
|
|
||||||
|
let md_promise = load_md(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#await md_promise} |
||||||
|
<Spinner /> |
||||||
|
{:then} |
||||||
|
<callout> |
||||||
|
<Markdown content={ md } /> |
||||||
|
</callout> |
||||||
|
|
||||||
|
<hr> |
||||||
|
|
||||||
|
<p> |
||||||
|
Because Svelte does a fairly correct JavaScript parse of the <code>content={}</code> |
||||||
|
you can use the ` (back-tick) syntax to write your markdown directly. |
||||||
|
</p> |
||||||
|
|
||||||
|
<callout> |
||||||
|
<Markdown content={` |
||||||
|
Write Me Right |
||||||
|
=== |
||||||
|
|
||||||
|
You can write the markdown directly in the source too using the JavaScript |
||||||
|
template syntax. |
||||||
|
`}/> |
||||||
|
</callout> |
||||||
|
{/await} |
||||||
|
|
||||||
|
<p>Originally I had this much more complicated using the Svelte slots, but it turns out |
||||||
|
to be very complicated and also doesn't work with <code>rendered</code> pages at all. |
||||||
|
This style works with rendered pages, which is probably the more common way you would |
||||||
|
use Markdown. |
||||||
|
</p> |
||||||
|
|
||||||
|
<h2>Security Warning</h2> |
||||||
|
|
||||||
|
<p>There is <b>NO</b> HTML sanitization on this output, so do <b>NOT</b> render |
||||||
|
user inupt without adding some form of sanitization like with <a href="https://github.com/cure53/DOMPurify">dompurify</a>. Consult the <a href="https://github.com/developit/snarkdown">snardown</a> documentation for more information. |
||||||
|
</p> |
@ -0,0 +1,32 @@ |
|||||||
|
<script> |
||||||
|
import Modal from "$/client/components/Modal.svelte"; |
||||||
|
let modal_open = false; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
test-panel { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
padding: 1rem; |
||||||
|
background: var(--color-bg); |
||||||
|
border-radius: var(--border-radius); |
||||||
|
opacity: 100%; |
||||||
|
width: 400px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
{#if modal_open} |
||||||
|
<Modal on:close={ () => modal_open = false }> |
||||||
|
<test-panel> |
||||||
|
<h1>Modal Open</h1> |
||||||
|
<p> |
||||||
|
You can put anything inside the modal, and it also handles ESC for closing the |
||||||
|
modal by sending an <b>on:close</b> event. |
||||||
|
</p> |
||||||
|
<button on:click={ () => modal_open = false }>CLOSE ME (ESC)</button> |
||||||
|
</test-panel> |
||||||
|
</Modal> |
||||||
|
{:else} |
||||||
|
<button on:click={ () => modal_open = true }>OPEN MODAL</button> |
||||||
|
{/if} |
||||||
|
|
@ -0,0 +1,8 @@ |
|||||||
|
The `Modal` component is useful for short notices or UI elements when you don't want to remove the |
||||||
|
user from a view they need to continue seeing. A good example usage is logging in while they watch |
||||||
|
a video. Transitioning to an entirely new page just to ask for username/password ruins their video |
||||||
|
viewing experience. |
||||||
|
|
||||||
|
Try changing this `admin/bando/demos/Model.svelte` file to not use the `{#if}` when the model |
||||||
|
opens. You'll see that the button to open the modal stays around until you hover off. Just a quirk |
||||||
|
of how the modal is layered and probably fixed with `z-index` if you run into it. |
@ -0,0 +1,42 @@ |
|||||||
|
<script> |
||||||
|
import OGPreview from "$/client/components/OGPreview.svelte"; |
||||||
|
import { base_host } from "$/client/config.js"; |
||||||
|
|
||||||
|
let og = { |
||||||
|
"title": "LJSTHW OG Card Test", // title of the article |
||||||
|
"description": "A simple test of the OG style of sharing cards.", // description for inside preview |
||||||
|
"image": `${ base_host }/images/zed.png`, // image to display, 5mb/1200/627 max |
||||||
|
"url": `${ base_host }/client/#/bando/demos/OGPreview`, // URL to article |
||||||
|
"type": "website", // not mentioned on linked in but needed |
||||||
|
} |
||||||
|
|
||||||
|
let twitter = { |
||||||
|
"card": "summary", // must be summary, summary_large_image, app, player |
||||||
|
"creator": "@lzsthw", // @username of content creator |
||||||
|
"description": og.description, // max 200 chars |
||||||
|
"image": og.image, |
||||||
|
"image:alt": "A drawing of Zed by Zed.", // max 420 chars image alt |
||||||
|
"site": "@lzsthw", // @username of site |
||||||
|
"title": og.title, |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<OGPreview og={ og } twitter={ twitter} /> |
||||||
|
|
||||||
|
<style> |
||||||
|
card { |
||||||
|
max-width: 600px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<card> |
||||||
|
<top> |
||||||
|
<!-- this is how you assign a set of properties to a component using the {...} syntax --> |
||||||
|
<img src="/images/zed.png" /> |
||||||
|
</top> |
||||||
|
<middle> |
||||||
|
<h4>OG Twitter Test</h4> |
||||||
|
<p>If you visit <a href="https://cards-dev.twitter.com/validator">the Twitter validator tool</a> and enter an internet accessible link to this demo it will show the above image and a description. |
||||||
|
</p> |
||||||
|
</middle> |
||||||
|
</card> |
@ -0,0 +1,32 @@ |
|||||||
|
<callout class="warning"> |
||||||
|
<span> |
||||||
|
This is mostly only a demo because Twitter doesn't actually render JavaScript client |
||||||
|
side so it won't see these meta tags. If you want this to work, put it on a page in |
||||||
|
<code>rendered/pages/</code> and don't Hydrate it. |
||||||
|
</span> |
||||||
|
</callout> |
||||||
|
|
||||||
|
You can create a special page/link for Twitter, Facebook, and LinkedIn that will render a summary+image for the page. Uses its own preview-card feature [docs](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started) while Facebook and LinkedIn use the [OpenGraph](https://ogp.me/) specification. OpenGraph is much simpler, really only supporting things like links, images, etc. Twitter's is more capable since it allows you to link to images, embed videos, live streams, and link to apps in Apple and Google stores. |
||||||
|
|
||||||
|
The component starts with a base set of attributes for OG and Twitter cards, which you can augment by simply updating the object used. All the component does is walk through the `twiter` or `og` keys and create the necessary `<meta>` tag for the head. |
||||||
|
|
||||||
|
Svelte:Head Sort-of-Bug |
||||||
|
=== |
||||||
|
|
||||||
|
One thing to keep in mind is if you place these `<meta>` tags directly in a page then they'll get duplicated on each page visit. This is because Svelte is updating the DOM for the page, but seems to not consider the `<svelte:head>` as idempotent as other elements in the DOM. The fix is to move all uses of `<svelte:head>` into a component so Svelte properly updates them on page changes. |
||||||
|
|
||||||
|
The code for `OGPreview.svelte` does this, which means you *do not* put it into a `<svelte:head>`. Instead you do this: |
||||||
|
|
||||||
|
<pre><code data-language="javascript"> |
||||||
|
<OGPreview og={ og } twitter={ twitter} /> |
||||||
|
</code></pre> |
||||||
|
|
||||||
|
Then `OGPreview` does everything for you. If you run into this bug in your own development use the `client/components/HeaderOnce.svelte` component like this: |
||||||
|
|
||||||
|
``` |
||||||
|
<HeaderOnce> |
||||||
|
<meta-tags-here> |
||||||
|
</HeaderOnce> |
||||||
|
``` |
||||||
|
|
||||||
|
Those tags will then technically be in a "component" so they'll update as you change pages rather than get duplicated for each page. |
@ -0,0 +1,33 @@ |
|||||||
|
<script> |
||||||
|
import Pagination from "$/client/components/Pagination.svelte"; |
||||||
|
|
||||||
|
let log = []; |
||||||
|
|
||||||
|
let pagination = { |
||||||
|
currentPage: 1, |
||||||
|
total: 236, |
||||||
|
lastPage: 12, |
||||||
|
perPage: 20, |
||||||
|
from: 20, |
||||||
|
to: 80, |
||||||
|
} |
||||||
|
|
||||||
|
const page_changed = () => { |
||||||
|
log.push(`New page ${pagination.currentPage} of ${pagination.lastPage}`); |
||||||
|
log = log; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<p>Total Pages: <input name="page_count" bind:value={ pagination.lastPage } type="number" ></p> |
||||||
|
|
||||||
|
<Pagination bind:pagination on:change={ page_changed }/> |
||||||
|
|
||||||
|
<h3>Change Log</h3> |
||||||
|
|
||||||
|
<p>A simple log of page changes only as a demo of handling the <b>changed</b> callback.</p> |
||||||
|
|
||||||
|
<ul> |
||||||
|
{#each log as line} |
||||||
|
<li>{ line }</li> |
||||||
|
{/each} |
||||||
|
</ul> |
@ -0,0 +1 @@ |
|||||||
|
A reasonably complete `Pagination` system that will work with the [knex-paginate by felixmosh](https://github.com/felixmosh/knex-paginate) module. It tries to replicate the behavior of pagination system found on many websites that abreviates the pages when you reach the middle but still allow for jumping to the first or last. |
@ -0,0 +1,6 @@ |
|||||||
|
<script> |
||||||
|
import Panels from "$/client/components/Panels.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<Panels /> |
@ -0,0 +1,21 @@ |
|||||||
|
<script> |
||||||
|
import PlaceHolder from "$/client/components/PlaceHolder.svelte"; |
||||||
|
let width=640; |
||||||
|
let height=480; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
span { |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
justify-content: space-evenly; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<span> |
||||||
|
<input bind:value={ width } placeholder="Width"> X |
||||||
|
<input bind:value={ height } placeholder="Height"> |
||||||
|
</span> |
||||||
|
|
||||||
|
<PlaceHolder width={ width } height={ height }/> |
||||||
|
|
@ -0,0 +1,3 @@ |
|||||||
|
The `PlaceHolder` is a simple JavaScript generated placeholder image for when you're starting your |
||||||
|
design. I prefer the `ImageIcon` instead of this one since it gives a better start and feedback on |
||||||
|
the design. |
@ -0,0 +1,33 @@ |
|||||||
|
<script> |
||||||
|
import ProgressMeter from "$/client/components/ProgressMeter.svelte"; |
||||||
|
let progress = 50; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
progress { |
||||||
|
width: 50%; |
||||||
|
} |
||||||
|
|
||||||
|
meter { |
||||||
|
width: 50%; |
||||||
|
} |
||||||
|
|
||||||
|
input { |
||||||
|
width: 5ch; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<p>HTML has a default <b>progress</b> tag you can use and it looks like this:</p> |
||||||
|
<progress value={ progress } max="100"></progress> |
||||||
|
|
||||||
|
<p>There's also <b>meter</b> tags that look a little different.</p> |
||||||
|
|
||||||
|
<meter value={ progress } max="100"> |
||||||
|
</meter> |
||||||
|
|
||||||
|
<p>Since those tags are already taken, ours will be called...<b>progress-meter</b>!</p> |
||||||
|
|
||||||
|
<ProgressMeter percent={ progress } /> |
||||||
|
<br/> |
||||||
|
<input placeholder="Percent Progress" type="numeric" bind:value={ progress } /> |
||||||
|
|
@ -0,0 +1,13 @@ |
|||||||
|
The `Progress` demo uses either the standard tags found in browsers, or the |
||||||
|
`client/components/Progress.svelte` component if you want a simple custom progress meter. |
||||||
|
|
||||||
|
Your OS (or browser) should have native controls for progress, but there are two HTML tags |
||||||
|
available: |
||||||
|
|
||||||
|
1. `<progress>` -- It shows a ... progress. |
||||||
|
2. `<mete>` -- It shows a ... meter which is apparently...totally different? |
||||||
|
|
||||||
|
Go with whatever works for you, and if you want to altern the look then use the `<Progress/>` tag. |
||||||
|
|
||||||
|
In the `static/global.css` file this is listed as a `<progress-meter>` tag to void conflicts with |
||||||
|
`<progress>` or `<meter>`. |
@ -0,0 +1,26 @@ |
|||||||
|
<script> |
||||||
|
import Sidebar from "$/client/components/Sidebar.svelte"; |
||||||
|
|
||||||
|
|
||||||
|
export let menu = [ |
||||||
|
{title: "Calendar", active: false, icon: "calendar"}, |
||||||
|
{title: "Cards", active: false, icon: "credit-card"}, |
||||||
|
{title: "Countdown", active: false, icon: "clock"}, |
||||||
|
{title: "Darkmode", active: false, icon: "sunrise"}, |
||||||
|
{title: "Form", active: false, icon: "database"}, |
||||||
|
{title: "Video", active: false, icon: "video"}, |
||||||
|
{title: "Sidebar", active: false, icon: "sidebar"}, |
||||||
|
]; |
||||||
|
</script> |
||||||
|
|
||||||
|
<main> |
||||||
|
<Sidebar menu={ menu }> |
||||||
|
<div slot="top"> |
||||||
|
<h1>Header</h1> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div slot="bottom"> |
||||||
|
<h3>Footer</h3> |
||||||
|
</div> |
||||||
|
</Sidebar> |
||||||
|
</main> |
@ -0,0 +1,6 @@ |
|||||||
|
This `Sidebar` is a very simple version of the ones you find online. It doesn't support nested |
||||||
|
accordion style structures and only a simple list of Icon+Title elements. |
||||||
|
|
||||||
|
Look in `admin/bando/Components.svelte` to see a real use of the sidebar to load components |
||||||
|
dynamically. It uses `<svelte:component>` to render the chosen component, which comes up quite |
||||||
|
often in applications. |
@ -0,0 +1,35 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
|
||||||
|
export let menu = [ |
||||||
|
{title: "Calendar", active: false, icon: "calendar"}, |
||||||
|
{title: "Cards", active: false, icon: "credit-card"}, |
||||||
|
{title: "Countdown", active: false, icon: "clock"}, |
||||||
|
{title: "Darkmode", active: false, icon: "sunrise"}, |
||||||
|
{title: "Form", active: false, icon: "database"}, |
||||||
|
{title: "Video", active: false, icon: "video"}, |
||||||
|
{title: "Sidebar", active: false, icon: "sidebar"}, |
||||||
|
]; |
||||||
|
</script> |
||||||
|
|
||||||
|
<main> |
||||||
|
<sidebar> |
||||||
|
<top><h1>CSS Only</h1></top> |
||||||
|
|
||||||
|
<items> |
||||||
|
{#each menu as item} |
||||||
|
<a data-testid="sidebar-link-{ item.title }" |
||||||
|
class:with-icon={ item.icon !== undefined } |
||||||
|
class:without-icon={ !item.icon} |
||||||
|
class:active={ item.active } |
||||||
|
href="/client/#/bando/components/SidebarCSS/"> |
||||||
|
{#if item.icon} |
||||||
|
<Icon size="24" name={ item.icon } light={ item.active } /> <span class="with-icon">{ item.title }</span> |
||||||
|
{:else} |
||||||
|
<span class="without-icon">{ item.title }</span> |
||||||
|
{/if} |
||||||
|
</a> |
||||||
|
{/each} |
||||||
|
</items> |
||||||
|
</sidebar> |
||||||
|
</main> |
@ -0,0 +1,3 @@ |
|||||||
|
This is an example of using the CSS directly for a Sidebar. You need this in any situation where you can't create a set of components to put inside the `Sidebar` component. It replicates the menu in the [Svelte version](/client/#/bando/components/Sidebar/) using the css from the `static/global.css` file. |
||||||
|
|
||||||
|
It also shows how to enable the collapsing down to "icon only mode" using the `with-icon` or `without-icon` classes. Try changing some of the `icon` properties int he `menu` variable to see how that works. Also try shrinking the window down so it's less than 600px wide to see how the icons replace the text. |
@ -0,0 +1,15 @@ |
|||||||
|
<script> |
||||||
|
import SnapImage from "$/client/components/SnapImage.svelte"; |
||||||
|
|
||||||
|
const image = "https://learnjsthehardway.com/media/sample_image.jpg"; |
||||||
|
let source = ""; |
||||||
|
const colors = [[44, 77, 26], [127, 187, 60], [189, 160, 115], [88, 153, 44], [25, 31, 14], [124, 151, 61], [89, 115, 43], [65, 122, 34], [108, 149, 173], [89, 119, 109]]; |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<SnapImage width="1600" height="900" colors={ colors } src={ source } /> |
||||||
|
|
||||||
|
<br /> |
||||||
|
|
||||||
|
<button on:click={ () => source = image }>Click to Load</button> |
@ -0,0 +1,29 @@ |
|||||||
|
`SnapImage` creates a temporary image using SVG that looks _kind of_ like the incoming image, and |
||||||
|
when that image finally loads fades it into view. This helps with _perceived_ render speed in the |
||||||
|
following ways: |
||||||
|
|
||||||
|
1. The aspect ratio of the image is placed before the page loads, reducing or eliminating render shift. |
||||||
|
2. A block of responsive SVG loads instantly which tricks the browser into fully rendering the whole page making it seem faster. |
||||||
|
3. The rendered colors look enough like the image for the brief time they're present that people percieve the image as loading faster even when it's not. |
||||||
|
|
||||||
|
By default `SnapImage` will use a set of random monochrome colors if none are given. You can change the options used to generate the random colors by setting `random_options` like this: |
||||||
|
|
||||||
|
``` |
||||||
|
random_options = { |
||||||
|
hue: "monochrome", |
||||||
|
"format": "rgbArray", |
||||||
|
"luminosity": "random", |
||||||
|
count: 10 |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
These options come from the [randomColor project by davidmerfield](https://github.com/davidmerfield/randomColor) which is used in the project for all random color generation. |
||||||
|
|
||||||
|
Generating Image Colors |
||||||
|
=== |
||||||
|
|
||||||
|
To generate your own colors there's a tiny script I'm using: |
||||||
|
|
||||||
|
<callout class="warning"> |
||||||
|
<span>I need to find this code actually. Stay tuned.</span> |
||||||
|
</callout> |
@ -0,0 +1,13 @@ |
|||||||
|
<script> |
||||||
|
import Spinner from "$/client/components/Spinner.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<p>Fairly basic, just an SVG that's rotated using a simple CSS animation.</p> |
||||||
|
|
||||||
|
<Spinner /> |
||||||
|
|
||||||
|
<h2>Using aspect_ratio</h2> |
||||||
|
<p>You can give an aspect ratio and the spinner will create a <code><div></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 { |
||||||
|
display: grid; |
||||||
|
grid-template-rows: 1fr; |
||||||
|
grid-template-columns: 1fr; |
||||||
|
grid-template-areas: "cover"; |
||||||
|
} |
||||||
|
.stacked .layer { |
||||||
|
width: 100%; |
||||||
|
position: relative; |
||||||
|
grid-area: cover; |
||||||
|
} |
||||||
|
.stacked .top { |
||||||
|
z-index: 10; |
||||||
|
} |
||||||
|
``` |
@ -0,0 +1,48 @@ |
|||||||
|
<style> |
||||||
|
</style> |
||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<p>Basic demo that's just on/off.</p> |
||||||
|
<input class="switch" type="checkbox" id="demo-1"> |
||||||
|
<label class="switch" for="demo-1"></label> |
||||||
|
|
||||||
|
<p>Demo with Yes/No text.</p> |
||||||
|
<input class="switch" type="checkbox" id="demo-2"> |
||||||
|
<label class="switch" for="demo-2"> |
||||||
|
<active>Yes</active> |
||||||
|
<inactive>No</inactive> |
||||||
|
</label> |
||||||
|
|
||||||
|
<p>Demo showing you can change the size.</p> |
||||||
|
<input class="switch" type="checkbox" id="demo-3"> |
||||||
|
<label class="switch medium" for="demo-3"> |
||||||
|
<active>Yes</active> |
||||||
|
<inactive>No</inactive> |
||||||
|
</label> |
||||||
|
|
||||||
|
<input class="switch" type="checkbox" id="demo-4"> |
||||||
|
<label class="switch large" for="demo-4"> |
||||||
|
<active>Yes</active> |
||||||
|
<inactive>No</inactive> |
||||||
|
</label> |
||||||
|
|
||||||
|
<p>Demo with Icons in rather than text.</p> |
||||||
|
<input class="switch" type="checkbox" id="demo-5"> |
||||||
|
<label class="switch" for="demo-5"> |
||||||
|
<active><Icon name="eye" size="18px" /></active> |
||||||
|
<inactive><Icon name="eye-off" size="18px" /></inactive> |
||||||
|
</label> |
||||||
|
|
||||||
|
<input class="switch" type="checkbox" id="demo-6"> |
||||||
|
<label class="switch medium" for="demo-6"> |
||||||
|
<active><Icon name="eye" size="24px" /></active> |
||||||
|
<inactive><Icon name="eye-off" size="24px" /></inactive> |
||||||
|
</label> |
||||||
|
|
||||||
|
<input class="switch" type="checkbox" id="demo-7"> |
||||||
|
<label class="switch large" for="demo-7"> |
||||||
|
<active><Icon name="eye" size="36px" /></active> |
||||||
|
<inactive><Icon name="eye-off" size="36px" /></inactive> |
||||||
|
</label> |
@ -0,0 +1,4 @@ |
|||||||
|
I generally think you shouldn't use this `Switch` as it's probably not going to work with screen |
||||||
|
readers and will mostly just confuse users. I have it here as a demonstration of _how_ to alter an |
||||||
|
element by _hiding_ it, but using its state to change the CSS of another element. This is a deep |
||||||
|
CSS trick, which I hope you won't really need that often. |
@ -0,0 +1,22 @@ |
|||||||
|
<script> |
||||||
|
import Tabs from "$/client/components/Tabs.svelte"; |
||||||
|
import Calendar from "./Calendar.svelte"; |
||||||
|
import Cards from "./Cards.svelte"; |
||||||
|
import Login from "./Login.svelte"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
|
||||||
|
export let panels = [ |
||||||
|
{title: "Calendar", active: true, icon: "calendar", component: Calendar}, |
||||||
|
{title: "Cards", active: false, icon: "credit-card", component: Cards}, |
||||||
|
{title: "Login", active: false, icon: "log-in", component: Login}, |
||||||
|
]; |
||||||
|
|
||||||
|
let selected = panels[0]; |
||||||
|
|
||||||
|
const tab_select = (event) => { |
||||||
|
log.debug("SELECTED TAB", event.detail); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<Tabs panels={ panels } on:select={ tab_select } bind:selected /> |
@ -0,0 +1,5 @@ |
|||||||
|
`Tabs` give you the common tabbed layout found in many applications for decades. The `Accordion` |
||||||
|
demo just uses the `Tab` component with `vertical={true}`. It needs other Svelte components to |
||||||
|
place into the tab panels, but it's implemented mostly in CSS. That means if you need to do a |
||||||
|
different presentatino or can't use components for the panels then you can use the `<tab>` from |
||||||
|
`static/global.css`. |
@ -0,0 +1,32 @@ |
|||||||
|
<script> |
||||||
|
import Icon from '$/client/components/Icon.svelte'; |
||||||
|
</script> |
||||||
|
<style> |
||||||
|
content { |
||||||
|
display: flex; |
||||||
|
flex: flex-shrink; |
||||||
|
flex-grow: 1; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<content> |
||||||
|
<tile> |
||||||
|
<left> |
||||||
|
<Icon name="user" size="48" /> |
||||||
|
</left> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<h4> |
||||||
|
Tile Example |
||||||
|
</h4> |
||||||
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<right> |
||||||
|
<button><Icon name="check-circle" color="var(--color-bg)" size="24" /></button> |
||||||
|
<button><Icon name="x-circle" color="var(--color-bg)" size="24"/></button> |
||||||
|
</right> |
||||||
|
</tile> |
||||||
|
</content> |
||||||
|
|
@ -0,0 +1 @@ |
|||||||
|
This is a CSS only component that simply provides a classic "tile" layout. A tile seems to be organized with a left side that has an image/icon, a middle with content, and a right with buttons or other things. You don't need Svelte to make this work, and actually trying to use Svelte ends up making it more complex than just using CSS. To see how you use this just look in `admin/bando/demos/Tiles.svelte`. |
@ -0,0 +1,35 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import { fade } from "svelte/transition"; |
||||||
|
let bottom=true; |
||||||
|
let top=false; |
||||||
|
let right=true; |
||||||
|
let left=false; |
||||||
|
let active=true; |
||||||
|
let reverse = false; |
||||||
|
|
||||||
|
let toasts = ["Toast 1"]; |
||||||
|
|
||||||
|
const add_toast = () => { |
||||||
|
toasts.push(`Toast ${toasts.length + 1}`); |
||||||
|
toasts = toasts; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<span on:click={ add_toast}><Icon name="plus" size="48" /></span> |
||||||
|
<span on:click={ () => left = !left }><Icon name="arrow-left" size="48" inactive={!left} /></span> |
||||||
|
<span on:click={ () => right = !right }><Icon name="arrow-right" size="48" inactive={!right} /></span> |
||||||
|
<span on:click={ () => bottom = !bottom }><Icon name="arrow-down" size="48" inactive={!bottom}/></span> |
||||||
|
<span on:click={ () => top = !top }><Icon name="arrow-up" size="48" inactive={!top}/></span> |
||||||
|
|
||||||
|
<span on:click={ () => reverse = !reverse }><Icon name={ reverse ? "chevrons-up" : "chevrons-down" } size="48" /></span> |
||||||
|
|
||||||
|
|
||||||
|
<toast-list class:bottom class:top |
||||||
|
class:left class:right class:active class:reverse> |
||||||
|
{#each toasts as toast} |
||||||
|
<toast transition:fade|local> |
||||||
|
<span>{ toast }</span> |
||||||
|
</toast> |
||||||
|
{/each} |
||||||
|
</toast-list> |
@ -0,0 +1,4 @@ |
|||||||
|
A toast is a little pop-up that slides or fades in from the edge of the screen. It's used to notify of events without getting in the way of the UI. Use the arrow buttons to change the location of the `toast-list` and the plus to add more toasts. Use the chevrons to change the direction toasts are filled in. |
||||||
|
|
||||||
|
This demo starts with the toasts in the most common position on the bottom-right. You can use the |
||||||
|
arrows to test out different combinations and then hit _+_ to add random toasts. |
@ -0,0 +1,19 @@ |
|||||||
|
<script> |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import Toasts from "$/client/components/Toasts.svelte"; |
||||||
|
|
||||||
|
let orientation="bottom right"; |
||||||
|
let toasts = []; |
||||||
|
let send_toast; |
||||||
|
let fade_after = 5000; |
||||||
|
</script> |
||||||
|
|
||||||
|
<span on:click={ () => send_toast(`New Toast ${ toasts.length }`) }><Icon name="plus" size="48" /></span> |
||||||
|
<span on:click={ () => orientation = "bottom left" }><Icon name="arrow-down-left" size="48" /></span> |
||||||
|
<span on:click={ () => orientation = "bottom right" }><Icon name="arrow-down-right" size="48" /></span> |
||||||
|
<span on:click={ () => orientation = "top left" }><Icon name="arrow-up-left" size="48" /></span> |
||||||
|
<span on:click={ () => orientation = "top right" }><Icon name="arrow-up-right" size="48" /></span> |
||||||
|
|
||||||
|
<input name="fade" placeholder="Fade Time (ms)" bind:value={ fade_after }> |
||||||
|
|
||||||
|
<Toasts bind:toasts bind:send_toast orientation={ orientation } fade_after={ fade_after } /> |
@ -0,0 +1,21 @@ |
|||||||
|
This demonstrates how to use the simple `Toasts` component, but it *also* shows how to use a function provided by a component in Svelte. The trick is to realize that functions are variables, and that Svelte will let you _bind_ variables. You then simply have to do: |
||||||
|
|
||||||
|
``` |
||||||
|
let send_toast; |
||||||
|
``` |
||||||
|
|
||||||
|
In your script, and in your use of the component (`Toasts`) bind it like this: |
||||||
|
|
||||||
|
``` |
||||||
|
<Toasts bind:send_toast /> |
||||||
|
``` |
||||||
|
|
||||||
|
Now when the Toasts component is loaded it will set _your_ `send_toast` variable to the function so you can call it. In your component then you simple do: |
||||||
|
|
||||||
|
``` |
||||||
|
export const send_toast = () => { |
||||||
|
// code here |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Which will export it like a variable, but set it to `const` so Svelte doesn't complain that you aren't using it. |
@ -0,0 +1,66 @@ |
|||||||
|
<style> |
||||||
|
boxes { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 1fr 1fr; |
||||||
|
grid-template-rows: auto; |
||||||
|
grid-row-gap: 2rem; |
||||||
|
grid-column-gap: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
box { |
||||||
|
width: 100%; |
||||||
|
background-color: var(--color-bg-inverted); |
||||||
|
color: var(--color-text-inverted); |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
position: relative; |
||||||
|
padding: 0.5rem; |
||||||
|
border-radius: var(--border-radius); |
||||||
|
box-shadow: var(--box-shadow) var(--color-shadow); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<p>Hover over each of these boxes to see the tooltip.</p> |
||||||
|
|
||||||
|
<boxes> |
||||||
|
<box> |
||||||
|
<h1>top-right</h1> |
||||||
|
<tooltip>This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>top-left</h1> |
||||||
|
<tooltip class="top-left">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>bottom-right</h1> |
||||||
|
<tooltip class="bottom-right">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>bottom-left</h1> |
||||||
|
<tooltip class="bottom-left">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>right</h1> |
||||||
|
<tooltip class="right">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>left</h1> |
||||||
|
<tooltip class="left">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>top</h1> |
||||||
|
<tooltip class="top">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
|
||||||
|
<box> |
||||||
|
<h1>bottom</h1> |
||||||
|
<tooltip class="bottom">This thing works?</tooltip> |
||||||
|
</box> |
||||||
|
</boxes> |
@ -0,0 +1,12 @@ |
|||||||
|
`Tooltip` simply displays a little overlay message on a component. It's best to not use this too |
||||||
|
much as it can clutter your UI significantly. Try to find a way to not need the tooltip. |
||||||
|
|
||||||
|
You can put a tooltip on a word using: |
||||||
|
|
||||||
|
``` |
||||||
|
<word>tooltip word test<tooltip>I hover</tooltip></word> |
||||||
|
``` |
||||||
|
|
||||||
|
Which works like this: |
||||||
|
|
||||||
|
<word>tooltip word test<tooltip>I hover</tooltip></word> |
@ -0,0 +1,24 @@ |
|||||||
|
<script> |
||||||
|
import Video from "$/client/components/Video.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
let source = "/images/sample_video.mp4"; |
||||||
|
let poster = "/images/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> |
||||||
|
|
||||||
|
<h1>Instructions</h1> |
||||||
|
|
||||||
|
<p>Just like with the <code>HLSVideo</code> component, |
||||||
|
you should get the <a href="/client/#/bando/components/WTVideo/">WTVideo</a> |
||||||
|
demo working to get the sample files setup |
||||||
|
correctly. After that this demo will work too.</p> |
@ -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,35 @@ |
|||||||
|
<script> |
||||||
|
import WTVideo from "$/client/components/WTVideo.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
|
||||||
|
let media = { |
||||||
|
"src": "/images/sample_video.mp4", |
||||||
|
"filename": "sample_video.mp4", |
||||||
|
"poster": "/images/sample_image.jpg", |
||||||
|
"preload": "none", |
||||||
|
"torrent_url": "/images/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> |
||||||
|
|
||||||
|
<h1>Instructions</h1> |
||||||
|
|
||||||
|
<p>Read the <Icon name="book-open" /> Docs panel for instructions |
||||||
|
on how to get this demo to work on your local computer.</p> |
||||||
|
|
||||||
|
<h1>Bugs</h1> |
||||||
|
|
||||||
|
<p>The video downloads, but WebTorrent seems to lose track of the last |
||||||
|
chunk. Not sure why but if you know then message me. |
||||||
|
</p> |
@ -0,0 +1,44 @@ |
|||||||
|
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. |
||||||
|
|
||||||
|
# Getting Media |
||||||
|
|
||||||
|
This demo is found in `admin/bando/demos/WTVideos.svelte` and it is coded to expect a sample image, video and torrent file at: |
||||||
|
|
||||||
|
* `/images/sample_video.mp4` |
||||||
|
* `/images/sample_image.jpg` |
||||||
|
* `/images/sample_video.mp4.torrent` |
||||||
|
|
||||||
|
If you want it to work locally, grab a video and an image and place them there, then use `mktorrent` as described next. |
||||||
|
|
||||||
|
# Making the .torrent |
||||||
|
|
||||||
|
This will most likely *only* work if you have your server on `127.0.0.1` since the torrent file is configured for that. Also, the default `npm run dev` will run the simple tracker implementation found in `services/tracker.js`. If you want to generate the torrents again then run: |
||||||
|
|
||||||
|
```shell |
||||||
|
mktorrent -v -w http://127.0.0.1:5001/images/sample_video.mp4 \ |
||||||
|
-a ws://127.0.0.1:9001/ \ |
||||||
|
-o static/images/sample_video.mp4.torrent \ |
||||||
|
-p -l 18 static/images/sample_video.mp4 |
||||||
|
cp -r static/images public/images |
||||||
|
``` |
||||||
|
|
||||||
|
It uses the [mktorrent](https://github.com/pobrn/mktorrent) to generate the torrent since it is faster. Be careful of the WebTorrent specific options I use in the `mktorrent` 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 18` -- Sets the piece length to 18, which is what WebTorrent seems to need. |
||||||
|
* `-w 127.0.0.1` -- Sets an initial web URL to use as a source. You |
||||||
|
should remove this if you want them to only go through the tracker and other |
||||||
|
users. |
||||||
|
* `-a ws://127.0.0.1:9001/` -- Tells WebTorrent where the tracker is located. |
||||||
|
|
||||||
|
# Bugs |
||||||
|
|
||||||
|
1. It looks like WebTorrent has a problem getting the last chunk in some situations and I'm quite sure why. If you have an idea let me know. |
||||||
|
|
||||||
|
# Download Link |
||||||
|
|
||||||
|
If you want to let people download the video then add the `download={ true }` property. |
@ -0,0 +1,10 @@ |
|||||||
|
import App from '$/admin/App.svelte'; |
||||||
|
|
||||||
|
const app = new App({ |
||||||
|
target: document.body, |
||||||
|
props: { |
||||||
|
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
export default app; |
@ -0,0 +1,66 @@ |
|||||||
|
<script> |
||||||
|
import { push, link } from 'svelte-spa-router'; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import Layout from "$/admin/Layout.svelte"; |
||||||
|
import Form from "$/client/components/Form.svelte"; |
||||||
|
import api from "$/client/api.js"; |
||||||
|
import { fade } from "svelte/transition"; |
||||||
|
import { defer } from "$/client/helpers.js"; |
||||||
|
|
||||||
|
export let params = {}; |
||||||
|
|
||||||
|
let data = {}; |
||||||
|
let error = ""; |
||||||
|
let schema = {}; |
||||||
|
const load_promise = defer(); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
schema = await api.schema(params.table); |
||||||
|
|
||||||
|
if(schema === undefined) { |
||||||
|
error = "Failed to load schema."; |
||||||
|
load_promise.reject(); |
||||||
|
} else { |
||||||
|
load_promise.resolve(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const create_record = async () => { |
||||||
|
let [status, row] = await api.put(`/api/admin/table?name=${params.table}`, data); |
||||||
|
|
||||||
|
if(status == 200) { |
||||||
|
error = ""; |
||||||
|
push(`/table/${params.table}/${row.id}/`); |
||||||
|
} else if(status == 401) { |
||||||
|
window.location = "/client/#/login"; |
||||||
|
} else { |
||||||
|
error = "Failed saving record."; |
||||||
|
data = row; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Layout authenticated={ true } testid="admin-create-page"> |
||||||
|
{#await load_promise} |
||||||
|
... loading ... |
||||||
|
{:then} |
||||||
|
<Form data={data} table={params.table} schema={ schema }> |
||||||
|
<a data-testid="button-back" href="/table/{ params.table }" use:link> |
||||||
|
<Icon name="arrow-left-circle" tooltip="Back to table." size="48" /> |
||||||
|
</a> |
||||||
|
|
||||||
|
<span data-testid="button-create" on:click={ create_record }> |
||||||
|
<Icon name="save" tooltip="Create." size="48" /> |
||||||
|
</span> |
||||||
|
</Form> |
||||||
|
{/await} |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<toast-list class="bottom right active"> |
||||||
|
<toast transition:fade|local> |
||||||
|
<error>{error}</error> |
||||||
|
</toast> |
||||||
|
</toast-list> |
||||||
|
{/if} |
||||||
|
</Layout> |
@ -0,0 +1,286 @@ |
|||||||
|
<script> |
||||||
|
import { link } from 'svelte-spa-router'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import Layout from '$/client/Layout.svelte'; |
||||||
|
import Icon from '$/client/components/Icon.svelte'; |
||||||
|
import Code from "$/client/components/Code.svelte"; |
||||||
|
import HTML from "$/client/components/HTML.svelte"; |
||||||
|
import api from "$/client/api.js"; |
||||||
|
|
||||||
|
let index = {}; |
||||||
|
let docs_data; |
||||||
|
let url = "/docs/"; |
||||||
|
export let params; |
||||||
|
|
||||||
|
const caps_to_icon = { |
||||||
|
"BUG": "bug", |
||||||
|
"TODO": "clipboard-check", |
||||||
|
"WARNING": "alert-triangle", |
||||||
|
"FOOTGUN": "bomb", |
||||||
|
"DEPRECATED": "axe" |
||||||
|
} |
||||||
|
|
||||||
|
const jump = (id) => { |
||||||
|
let node = document.getElementById(id); |
||||||
|
if(node) node.scrollIntoView(); |
||||||
|
} |
||||||
|
|
||||||
|
const jump_top = () => jump("top-scroll"); |
||||||
|
|
||||||
|
const load_docs = async (to_load) => { |
||||||
|
// only load if the URL changed |
||||||
|
if(to_load !== url) { |
||||||
|
const [status, data] = await api.get(`/docs/api/${to_load}.json`); |
||||||
|
|
||||||
|
docs_data = status === 200 ? data : {}; |
||||||
|
url = to_load; |
||||||
|
jump_top(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const load_index = async () => { |
||||||
|
const [status, data] = await api.get("/docs/api/index.json"); |
||||||
|
index = status === 200 ? data : {}; |
||||||
|
} |
||||||
|
|
||||||
|
const type_to_syntax = { |
||||||
|
"callexpression": "()", |
||||||
|
"objectexpression": "{}", |
||||||
|
"function": "()", |
||||||
|
"class": "{}", |
||||||
|
"method": "()" |
||||||
|
} |
||||||
|
|
||||||
|
onMount(load_index); |
||||||
|
|
||||||
|
$: if(params.wild) { |
||||||
|
// load_docs will ignore any calls where params.wild doesn't change url |
||||||
|
load_docs(params.wild) |
||||||
|
} else { |
||||||
|
url = "/docs/"; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
sidebar { |
||||||
|
max-width: 300px; |
||||||
|
min-height: 100vh; |
||||||
|
height: 100vh; |
||||||
|
max-height: 100vh; |
||||||
|
margin: unset; |
||||||
|
} |
||||||
|
|
||||||
|
sidebar items { |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
right { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
min-height: 100vh; |
||||||
|
height: 100vh; |
||||||
|
max-height: 100vh; |
||||||
|
overflow-y: auto; |
||||||
|
|
||||||
|
width: calc(100vw - 300px); |
||||||
|
min-width: calc(100vw - 300px); |
||||||
|
max-width: calc(100vw - 300px); |
||||||
|
} |
||||||
|
|
||||||
|
right > div { |
||||||
|
padding: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
export { |
||||||
|
border-top: 1px solid var(--value5); |
||||||
|
padding: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
export.member { |
||||||
|
padding-left: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
export.class-def { |
||||||
|
background-color: var(--value7); |
||||||
|
} |
||||||
|
|
||||||
|
export > heading { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
width: 100%; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
export > heading h4, |
||||||
|
export > heading h2, |
||||||
|
export > heading h1 |
||||||
|
{ |
||||||
|
margin-top: 0.5rem; |
||||||
|
font-family: var(--font-family); |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
export > heading meta-data { |
||||||
|
display: grid; |
||||||
|
gap: 0.5rem; |
||||||
|
grid-template-columns: repeat(4, 1fr); |
||||||
|
grid-template-rows: auto; |
||||||
|
} |
||||||
|
|
||||||
|
export > info { |
||||||
|
display: block; |
||||||
|
padding-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
export > info commment { |
||||||
|
display: block; |
||||||
|
} |
||||||
|
|
||||||
|
/* FOOTGUN: https://github.com/sveltejs/svelte/issues/2967 |
||||||
|
When you @html in Svelte it can't apply any styles from here. |
||||||
|
You have to scope the a tag style inside module-header, BUT, |
||||||
|
set it to global so Svelte will apply it to the whole doc, |
||||||
|
not its special class. |
||||||
|
*/ |
||||||
|
module-header :global(a) { |
||||||
|
color: var(--color-text-inverted); |
||||||
|
} |
||||||
|
|
||||||
|
module-header { |
||||||
|
display: block; |
||||||
|
padding: 0.5rem; |
||||||
|
background-color: var(--color-bg-inverted); |
||||||
|
color: var(--color-text-inverted); |
||||||
|
} |
||||||
|
|
||||||
|
module-header :global(h2), |
||||||
|
module-header :global(h3), |
||||||
|
module-header :global(h4) { |
||||||
|
margin-top: 3rem; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
toc { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(5, 1fr); |
||||||
|
background-color: var(--value3); |
||||||
|
color: var(--value9); |
||||||
|
} |
||||||
|
|
||||||
|
toc span { |
||||||
|
border: 1px solid black; |
||||||
|
padding: 0.5rem; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.no-doc { |
||||||
|
background-color: var(--color-error); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
|
||||||
|
<Layout footer={ false} header={ false } centered={ true } fullscreen={ true }> |
||||||
|
<sidebar> |
||||||
|
<top><h4><a href="/" use:link><Icon name="arrow-left-circle" /> API Docs</a> </h4></top> |
||||||
|
<items> |
||||||
|
<a class:active={ url === "/docs/" } href="/docs/" use:link>Introduction</a> |
||||||
|
{#each Object.keys(index) as item} |
||||||
|
<a class:active={ item === url } href="/docs/{item}" use:link>{item}</a> |
||||||
|
{/each} |
||||||
|
</items> |
||||||
|
</sidebar> |
||||||
|
|
||||||
|
<right> |
||||||
|
{#if url === "/docs/"} |
||||||
|
<div id="top-scroll"> |
||||||
|
<HTML url="/docs/api/index.html" /> |
||||||
|
</div> |
||||||
|
{:else if docs_data} |
||||||
|
<toc id="top-scroll"> |
||||||
|
{#each docs_data.exports as exp} |
||||||
|
<span class:no-doc={ !exp.comment } on:click={ () => jump(exp.slug) }>{ exp.name } |
||||||
|
{#each exp.caps as cap_word}<Icon name={ caps_to_icon[cap_word] } size="24" light={ true }/>{/each} |
||||||
|
</span> |
||||||
|
|
||||||
|
{#if exp.isa == "class"} |
||||||
|
{#each exp.methods as member} |
||||||
|
<span class:no-doc={ !member.comment } on:click={ () => jump(member.slug) }>.{ member.name } |
||||||
|
{#each member.caps as cap_word}<Icon name={ caps_to_icon[cap_word] } size="24" light={ true } />{/each} |
||||||
|
</span> |
||||||
|
{/each} |
||||||
|
{/if} |
||||||
|
|
||||||
|
{/each} |
||||||
|
</toc> |
||||||
|
<module-header> |
||||||
|
<h1>{ url }</h1> |
||||||
|
{#if docs_data.comment} |
||||||
|
{@html docs_data.comment} |
||||||
|
{/if} |
||||||
|
</module-header> |
||||||
|
|
||||||
|
{#each docs_data.exports as exp} |
||||||
|
{#if exp.isa === "class"} |
||||||
|
<export class="class-def"> |
||||||
|
<heading> |
||||||
|
<h2 on:click={ () => jump_top() } id={ exp.slug }>class { exp.name }</h2> |
||||||
|
<Code content={ exp.code } language="javascript" /> |
||||||
|
</heading> |
||||||
|
<comment> |
||||||
|
{#if exp.comment} |
||||||
|
{@html exp.comment} |
||||||
|
{/if} |
||||||
|
</comment> |
||||||
|
</export> |
||||||
|
|
||||||
|
{#each exp.methods as member} |
||||||
|
<export class="member"> |
||||||
|
<heading> |
||||||
|
<h4 id={ member.slug } on:click={ () => jump(exp.slug) }>.{member.name}{ type_to_syntax[member.isa] || "" } |
||||||
|
</h4> |
||||||
|
<meta-data> |
||||||
|
{docs_data.source}:{ member.line_start } |
||||||
|
<em>{ member.isa } of { exp.name }</em> |
||||||
|
{#if member.static}<b>static</b>{/if} |
||||||
|
{#if member.async}<b>async</b>{/if} |
||||||
|
{#if member.generator}<b>generator</b>{/if} |
||||||
|
</meta-data> |
||||||
|
</heading> |
||||||
|
<info> |
||||||
|
{#if member.comment} |
||||||
|
<comment> |
||||||
|
{ @html member.comment } |
||||||
|
</comment> |
||||||
|
{/if} |
||||||
|
<Code content={ member.code } language="javascript" /> |
||||||
|
</info> |
||||||
|
</export> |
||||||
|
{/each} |
||||||
|
{:else} |
||||||
|
<export> |
||||||
|
<heading> |
||||||
|
<h4 on:click={ () => jump_top() } |
||||||
|
id={ exp.slug }>{exp.name}{ type_to_syntax[exp.isa] || "" } |
||||||
|
</h4> |
||||||
|
<meta-data> |
||||||
|
{docs_data.source}:{ exp.line_start } |
||||||
|
<em>{ exp.isa }</em> |
||||||
|
{#if exp.static}<b>static</b>{/if} |
||||||
|
{#if exp.async}<b>async</b>{/if} |
||||||
|
{#if exp.generator}<b>generator</b>{/if} |
||||||
|
</meta-data> |
||||||
|
</heading> |
||||||
|
<info> |
||||||
|
{#if exp.comment} |
||||||
|
<comment> |
||||||
|
{@html exp.comment} |
||||||
|
</comment> |
||||||
|
{/if} |
||||||
|
<Code content={ exp.code } language="javascript" /> |
||||||
|
</info> |
||||||
|
</export> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
{/if} |
||||||
|
</right> |
||||||
|
</Layout> |
@ -0,0 +1,84 @@ |
|||||||
|
<script> |
||||||
|
import Sidebar from "$/client/components/Sidebar.svelte"; |
||||||
|
import { link } from "svelte-spa-router"; |
||||||
|
import Layout from '$/admin/Layout.svelte'; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import EmailDNS from "./EmailDNS.svelte"; |
||||||
|
import EmailSend from "./EmailSend.svelte"; |
||||||
|
|
||||||
|
export let params = {}; |
||||||
|
|
||||||
|
let panels = [ |
||||||
|
{title: "Send Test", active: false, icon: "send", component: EmailSend }, |
||||||
|
{title: "DNS Test", active: false, icon: "cast", component: EmailDNS }, |
||||||
|
] |
||||||
|
|
||||||
|
const select_named = () => { |
||||||
|
if(params.name) { |
||||||
|
return panels.find(p => p.title === params.name) || panels[0]; |
||||||
|
} else { |
||||||
|
return panels[0]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let selected = select_named(); |
||||||
|
selected.active = true; |
||||||
|
|
||||||
|
const sidebar_select = (event) => { |
||||||
|
const {index, item} = event.detail; |
||||||
|
|
||||||
|
selected = item; |
||||||
|
|
||||||
|
panels = panels.map((x, i) => { |
||||||
|
x.active = i == index; |
||||||
|
return x; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
div[slot="top"] span { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
contents { |
||||||
|
padding: 0.5rem; |
||||||
|
width: 100%; |
||||||
|
max-height: 100vh; |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
left { |
||||||
|
display: flex; |
||||||
|
height: 100vh; |
||||||
|
max-height: 100vh; |
||||||
|
min-height: 100vh; |
||||||
|
overflow-y: auto; |
||||||
|
overflow-x: hidden; |
||||||
|
max-width: min-content; |
||||||
|
width: min-content; |
||||||
|
min-width: min-content; |
||||||
|
background-color: var(--color-bg-secondary); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout fullscreen={ true } header={ true } footer={false } authenticated={ true } testid="page-email-config"> |
||||||
|
<left> |
||||||
|
<Sidebar on:select={ sidebar_select } menu={ panels }> |
||||||
|
<div slot="top"> |
||||||
|
<h3><a href="/" use:link><Icon name="home" /> Email Admin</h3> |
||||||
|
<span><Icon name="home" size="36" /></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div slot="bottom"> |
||||||
|
<p>Be careful where you send email.</p> |
||||||
|
</div> |
||||||
|
</Sidebar> |
||||||
|
</left> |
||||||
|
|
||||||
|
<contents> |
||||||
|
<h1>{selected.title}</h1> |
||||||
|
<svelte:component this={selected.component} /> |
||||||
|
</contents> |
||||||
|
</Layout> |
@ -0,0 +1,147 @@ |
|||||||
|
<script> |
||||||
|
import api from "$/client/api.js"; |
||||||
|
import Spinner from "$/client/components/Spinner.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
|
||||||
|
let dns; |
||||||
|
let domain_name; |
||||||
|
let error = ""; |
||||||
|
let tests; |
||||||
|
let querying = false; |
||||||
|
|
||||||
|
const activate = (id) => { |
||||||
|
tests = tests.map(r => { |
||||||
|
r.active = r.id == id; |
||||||
|
return r; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const run_test = async () => { |
||||||
|
if(domain_name) { |
||||||
|
querying = true; |
||||||
|
const [status, data] = await api.get('/api/admin/email', {domain_name}); |
||||||
|
querying = false; |
||||||
|
|
||||||
|
if(status === 200) { |
||||||
|
dns = data.dns; |
||||||
|
tests = data.tests; |
||||||
|
error = ""; |
||||||
|
} else { |
||||||
|
error = data.message || data._errors.domain_name[0]; |
||||||
|
dns = undefined; |
||||||
|
tests = undefined; // clear the test results |
||||||
|
} |
||||||
|
} else { |
||||||
|
error = "You must enter a valid domain name."; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
domain-input { |
||||||
|
display: flex; |
||||||
|
} |
||||||
|
|
||||||
|
form { |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
tr.error { |
||||||
|
color: var(--red); |
||||||
|
} |
||||||
|
|
||||||
|
td { |
||||||
|
text-align: left; |
||||||
|
max-width: 20%; |
||||||
|
min-width: 20%; |
||||||
|
width: 20%; |
||||||
|
} |
||||||
|
|
||||||
|
td.data { |
||||||
|
width: 80%; |
||||||
|
word-break: break-all; |
||||||
|
word-wrap: normal; |
||||||
|
white-space: normal; |
||||||
|
} |
||||||
|
|
||||||
|
table { |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<domain-input data-testid="domain-input"> |
||||||
|
<form on:submit|preventDefault={ run_test } action="/api/admin/email" method="GET"> |
||||||
|
<input name="domain_name" bind:value={ domain_name } id="domain_name" placeholder="Domain Name" /> |
||||||
|
{#if querying}<Spinner />{/if} |
||||||
|
</form> |
||||||
|
</domain-input> |
||||||
|
|
||||||
|
<h2>DNS Results { domain_name || "" }</h2> |
||||||
|
{#if dns } |
||||||
|
<table data-testid="domain-result"> |
||||||
|
<thead> |
||||||
|
<tr><th>Result</th><th>Value</th></tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{#if dns.ip4.host } |
||||||
|
<tr><td>IP4 Host</td><td class="data">{ dns.ip4.host[0] }</td></tr> |
||||||
|
<tr><td>IP4 Reverse</td><td class="data">{ dns.ip4.reverse[0] }</td></tr> |
||||||
|
{:else} |
||||||
|
<tr data-testid="error-dns" class="error"><td>IP4 Error</td><td class="data">{ dns.ip4.error.message }</td></tr> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if dns.ip6.host} |
||||||
|
<tr><td>IP6 Host</td><td class="data">{ dns.ip6.host[0] }</td></tr> |
||||||
|
<tr><td>IP6 Reverse</td><td class="data">{ dns.ip6.reverse[0] }</td></tr> |
||||||
|
{:else} |
||||||
|
<tr class="error"><td>IP6 Error</td><td class="data">{ dns.ip6.error.message }</td></tr> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if dns.mx} |
||||||
|
<tr><td>MX Exchange</td><td class="data">{ dns.mx[0].exchange }</td></tr> |
||||||
|
<tr><td>MX Priority</td><td class="data">{ dns.mx[0].priority }</td></tr> |
||||||
|
{:else} |
||||||
|
<tr class="error"><td>MX Error</td><td class="data">{ dns.mx_error.message }</td></tr> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if dns.spf} |
||||||
|
<tr><td>SPF</td><td class="data">{ dns.spf[0] }</td></tr> |
||||||
|
{:else} |
||||||
|
<tr class="error"><td>SPF</td><td class="data">{ dns.spf_error.message }</td></tr> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if dns.dmarc} |
||||||
|
<tr><td>DMARC</td><td class="data">{ dns.dmarc[0] }</td></tr> |
||||||
|
{:else} |
||||||
|
<tr class="error"><td>DMARC</td><td class="data">{ dns.dmarc_error.message }</td></tr> |
||||||
|
{/if} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{:else if error} |
||||||
|
<callout class="error">{ error }</callout> |
||||||
|
{:else} |
||||||
|
<callout>Enter a domain name to see the results.</callout> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if tests && tests.length > 0} |
||||||
|
<h2>DNS Analysis</h2> |
||||||
|
<p>The following are recommendations based on errors found in your DNS records for { domain_name }.</p> |
||||||
|
|
||||||
|
<tabs class="vertical"> |
||||||
|
{#each tests as result} |
||||||
|
<a class:active={ result.active } on:click={() => activate(result.id)}><Icon name="alert-triangle" /> { result.title }</a> |
||||||
|
<component> |
||||||
|
{#if result.active} |
||||||
|
<p>{ result.text }</p> |
||||||
|
{/if} |
||||||
|
</component> |
||||||
|
{/each} |
||||||
|
</tabs> |
||||||
|
{:else if tests && tests.length == 0} |
||||||
|
<h2>DNS Is Good</h2> |
||||||
|
<p>No common errors found so far in your DNS records. You should try sending yourself |
||||||
|
an email to test if it will deliver. These tests are performed against Googles public DNS at 8.8.8.8 so they should be visible in Gmail now. |
||||||
|
</p> |
||||||
|
{/if} |
@ -0,0 +1,61 @@ |
|||||||
|
<script> |
||||||
|
import api from "$/client/api.js"; |
||||||
|
import FormField from '$/client/components/FormField.svelte'; |
||||||
|
|
||||||
|
let form = { |
||||||
|
to_address: "", |
||||||
|
} |
||||||
|
|
||||||
|
let error; |
||||||
|
let message; |
||||||
|
let config; |
||||||
|
|
||||||
|
const send_test = async () => { |
||||||
|
const [status, data] = await api.post(`/api/admin/email`, form); |
||||||
|
|
||||||
|
if(status == 200) { |
||||||
|
message = data.message; |
||||||
|
config = data.config; |
||||||
|
} else { |
||||||
|
error = data.message; |
||||||
|
form = Object.assign(form, data); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<form> |
||||||
|
<card> |
||||||
|
<middle> |
||||||
|
<FormField form={ form } field="to_address" label="To Email"> |
||||||
|
<input type="text" id="to_address" bind:value={form.to_address } name="to_address"> |
||||||
|
</FormField> |
||||||
|
{#if message} |
||||||
|
<br/> |
||||||
|
<callout class="info" data-testid="result-message"> |
||||||
|
<span>{ message }</span> |
||||||
|
</callout> |
||||||
|
{/if} |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button data-testid="send-button" on:click|preventDefault={ () => send_test() }>Send</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
</form> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<callout class="error"> |
||||||
|
<span>Fatal Error: { error }</span> |
||||||
|
</callout> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if config} |
||||||
|
<h2>Mail Server Config</h2> |
||||||
|
<pre> |
||||||
|
<code> |
||||||
|
{JSON.stringify(config, null, 2)} |
||||||
|
</code> |
||||||
|
</pre> |
||||||
|
{/if} |
@ -0,0 +1,103 @@ |
|||||||
|
<script> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import Icon from '$/client/components/Icon.svelte'; |
||||||
|
import { link } from 'svelte-spa-router'; |
||||||
|
import Code from '$/client/components/Code.svelte'; |
||||||
|
import Spinner from '$/client/components/Spinner.svelte'; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
import api from '$/client/api.js'; |
||||||
|
import Layout from "$/admin/Layout.svelte"; |
||||||
|
import { defer } from "$/client/helpers.js"; |
||||||
|
|
||||||
|
let errors; |
||||||
|
let error_selected; |
||||||
|
|
||||||
|
const load_promise = defer(); |
||||||
|
|
||||||
|
const load_info = async () => { |
||||||
|
let [status, data] = await api.get('/api/devtools/info'); |
||||||
|
|
||||||
|
if(status == 200) { |
||||||
|
errors = data.errors; |
||||||
|
|
||||||
|
if(errors.length > 0) { |
||||||
|
error_selected = errors[0]; |
||||||
|
} |
||||||
|
|
||||||
|
load_promise.resolve(); |
||||||
|
} else { |
||||||
|
log.debug("failed to get info", status); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await load_info(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
/** Sidebar style can be found in static/global.css */ |
||||||
|
sidebar.fixed { |
||||||
|
overflow-y: auto; |
||||||
|
max-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
min-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
height: calc(100vh - var(--fixed-header-height)); |
||||||
|
} |
||||||
|
|
||||||
|
display { |
||||||
|
padding-left: 1rem; |
||||||
|
padding-right: 1rem; |
||||||
|
width: 100%; |
||||||
|
max-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
min-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
height: calc(100vh - var(--fixed-header-height)); |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout fullwidth={ true } fixed={ true } footer={false} authenticated={ true } testid="page-admin-errors"> |
||||||
|
{#await load_promise} |
||||||
|
<Spinner /> |
||||||
|
{:then} |
||||||
|
<content> |
||||||
|
<sidebar class="fixed"> |
||||||
|
<top> |
||||||
|
<h3><a href="/" use:link><Icon name="arrow-left-circle" size="36"/></a> Errors</h3> |
||||||
|
</top> |
||||||
|
<items> |
||||||
|
{#each errors as error} |
||||||
|
<a on:click={ () => error_selected = error }>{ error.location.file}</a> |
||||||
|
{:else} |
||||||
|
<h3>No Errors</h3> |
||||||
|
<span>You currently have no errors that are shown. Try refreshing.</span> |
||||||
|
{/each} |
||||||
|
</items> |
||||||
|
</sidebar> |
||||||
|
|
||||||
|
<display> |
||||||
|
{#if error_selected} |
||||||
|
<h1>{error_selected.location.file} <Icon name="alert-circle" size="36" /></h1> |
||||||
|
<h3>{ error_selected.text }</h3> |
||||||
|
<pre> |
||||||
|
<code> |
||||||
|
{#each error_selected.location.lineText.split('\n') as line, i} |
||||||
|
<span class:error={ error_selected.location.line === i + 1}>{ `${line}\n` }</span> |
||||||
|
{/each} |
||||||
|
</code> |
||||||
|
</pre> |
||||||
|
|
||||||
|
<h1>Notes</h1> |
||||||
|
{#each error_selected.notes as note} |
||||||
|
<h3>{note.location.file}</h3> |
||||||
|
<p>{note.text}</p> |
||||||
|
<pre> |
||||||
|
<code> |
||||||
|
{ note.location.line}: {note.location.lineText} |
||||||
|
</code> |
||||||
|
</pre> |
||||||
|
{/each} |
||||||
|
{/if} |
||||||
|
</display> |
||||||
|
</content> |
||||||
|
{/await} |
||||||
|
</Layout> |
@ -0,0 +1,104 @@ |
|||||||
|
<script> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import Sidebar from "$/client/components/Sidebar.svelte"; |
||||||
|
import TableIndex from "$/admin/pages/TableIndex.svelte"; |
||||||
|
import EmailDNS from "$/admin/pages/EmailDNS.svelte"; |
||||||
|
import EmailSend from "$/admin/pages/EmailSend.svelte"; |
||||||
|
import Stats from "$/admin/pages/Stats.svelte"; |
||||||
|
import IconFinder from "$/admin/bando/IconFinder.svelte"; |
||||||
|
import Djenterator from "$/admin/bando/Djenterator.svelte"; |
||||||
|
import Layout from "$/admin/Layout.svelte"; |
||||||
|
import { replace } from "svelte-spa-router"; |
||||||
|
|
||||||
|
export let params={what: "tables"}; |
||||||
|
|
||||||
|
let panels = [ |
||||||
|
{url: "tables", title: "Tables", active: true, icon: "database", component: TableIndex}, |
||||||
|
{url: "errors", title: "Errors", active: false, icon: "alert-circle", href: "/admin/#/errors/"}, |
||||||
|
{url: "icons", title: "Icons", active: false, icon: "smile", component: IconFinder}, |
||||||
|
{url: "demos", title: "Components", active: false, icon: "component", href: "/admin/#/bando/components/"}, |
||||||
|
{url: "docs", title: "API Docs", active: false, icon: "book", href: "/admin/#/docs/"}, |
||||||
|
{url: "routes", title: "Routes", active: false, icon: "code-2", href: "/admin/#/routes/"}, |
||||||
|
{url: "djenterator", title: "Djenterator", active: false, icon: "layout-template", component: Djenterator}, |
||||||
|
{url: "email-dns", title: "Email DNS", active: false, icon: "mail-check", component: EmailDNS}, |
||||||
|
{url: "email-send", title: "Email Send", active: false, icon: "mails", component: EmailSend}, |
||||||
|
{url: "stats", title: "Stats", active: false, icon: "activity", component: Stats}, |
||||||
|
]; |
||||||
|
|
||||||
|
let selected = panels[0]; |
||||||
|
|
||||||
|
const sidebar_select = (event) => { |
||||||
|
const { index, item } = event.detail; |
||||||
|
|
||||||
|
selected = item; |
||||||
|
|
||||||
|
replace(`/${ selected.url }/`); |
||||||
|
|
||||||
|
panels = panels.map((x, i) => { |
||||||
|
x.active = i == index; |
||||||
|
return x; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const url_select = (url) => { |
||||||
|
for(let panel of panels) { |
||||||
|
if(url === panel.url) { |
||||||
|
panel.active = true; |
||||||
|
selected = panel; |
||||||
|
} else { |
||||||
|
panel.active = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$:if(selected && params.what && params.what != selected.url) url_select(params.what); |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
if(params.what) { |
||||||
|
url_select(params.what); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
right { |
||||||
|
flex-direction: column; |
||||||
|
padding: 0.5rem; |
||||||
|
min-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
max-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
height: calc(100vh - var(--fixed-header-height)); |
||||||
|
min-width: calc(100% - 300px); |
||||||
|
max-width: calc(100% - 300px); |
||||||
|
width: calc(100% - 300px); |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
left { |
||||||
|
display: flex; |
||||||
|
max-width: min-content; |
||||||
|
min-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
max-height: calc(100vh - var(--fixed-header-height)); |
||||||
|
height: calc(100vh - var(--fixed-header-height)); |
||||||
|
} |
||||||
|
|
||||||
|
content { |
||||||
|
padding: 0px !important; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout fullwidth={ true } fixed={ true } footer={false} authenticated={ true } testid="page-admin-{ params.what }"> |
||||||
|
<content> |
||||||
|
<left> |
||||||
|
<Sidebar on:select={ sidebar_select } menu={ panels }> |
||||||
|
<div slot="top"> |
||||||
|
<h3>Admin Dashboard</h3> |
||||||
|
</div> |
||||||
|
</Sidebar> |
||||||
|
</left> |
||||||
|
|
||||||
|
<right> |
||||||
|
<svelte:component this={selected.component} /> |
||||||
|
</right> |
||||||
|
</content> |
||||||
|
</Layout> |
@ -0,0 +1,151 @@ |
|||||||
|
<script> |
||||||
|
import { push, link } from 'svelte-spa-router'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import Layout from "$/admin/Layout.svelte"; |
||||||
|
import Icon from "$/client/components/Icon.svelte"; |
||||||
|
import Modal from "$/client/components/Modal.svelte"; |
||||||
|
import Form from "$/client/components/Form.svelte"; |
||||||
|
import api from "$/client/api.js"; |
||||||
|
import { log } from "$/client/logging.js"; |
||||||
|
import { defer } from "$/client/helpers.js"; |
||||||
|
import Toasts from "$/client/components/Toasts.svelte"; |
||||||
|
|
||||||
|
export let params = {}; |
||||||
|
let send_toast; |
||||||
|
|
||||||
|
let form_data = {_errors: {}, _valid: false}; |
||||||
|
let delete_confirm = false; |
||||||
|
let load_promise = defer(); |
||||||
|
let schema = {}; |
||||||
|
|
||||||
|
const delete_record = async () => { |
||||||
|
let [status, data] = await api.del(`/api/admin/table?name=${params.table}&row_id=${params.row_id}`); |
||||||
|
|
||||||
|
if(status === 200) { |
||||||
|
form_data = data; |
||||||
|
form_data._errors = {}; // setup for errors later |
||||||
|
push(`/table/${params.table}/`); |
||||||
|
} else if(status == 401) { |
||||||
|
window.location = "/client/#/login"; |
||||||
|
} else { |
||||||
|
send_toast("Failed to delete."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const update_record = async () => { |
||||||
|
let [status, data] = await api.post(`/api/admin/table?name=${params.table}&row_id=${params.row_id}`, form_data); |
||||||
|
|
||||||
|
log.debug("update record results", data); |
||||||
|
|
||||||
|
if(status == 200) { |
||||||
|
send_toast("Update successful."); |
||||||
|
} else if(status == 401) { |
||||||
|
window.location = "/client/#/login"; |
||||||
|
} else { |
||||||
|
send_toast("Update failed."); |
||||||
|
form_data = Object.assign(form_data, data); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const json_copy = () => { |
||||||
|
const text = JSON.stringify(form_data, null, 4); |
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => { |
||||||
|
send_toast("JSON data copied to clipboard"); |
||||||
|
}, () => { |
||||||
|
send_toast("Failed copying to clipboard."); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
schema = await api.schema(params.table); |
||||||
|
|
||||||
|
if(schema === undefined) { |
||||||
|
send_toast("Failed to load schema."); |
||||||
|
load_promise.reject(); |
||||||
|
} else { |
||||||
|
let [status, data] = await api.get('/api/admin/table', { |
||||||
|
name: params.table, row_id: params.row_id |
||||||
|
}); |
||||||
|
|
||||||
|
if(status == 200) { |
||||||
|
form_data = data; |
||||||
|
load_promise.resolve(); |
||||||
|
} else if(status == 401) { |
||||||
|
window.location = "/client/#/login"; |
||||||
|
} else { |
||||||
|
sent_toast("Failed to load table data."); |
||||||
|
load_promise.reject(); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<style> |
||||||
|
card { |
||||||
|
background-color: var(--color-bg); |
||||||
|
} |
||||||
|
|
||||||
|
card top { |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
card middle { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<Layout authenticated={ true } testid="page-admin-readupdate"> |
||||||
|
{#await load_promise} |
||||||
|
<!-- form already has a spinner --> |
||||||
|
{:then} |
||||||
|
<Form data={form_data} table={params.table} schema={ schema }> |
||||||
|
<a href="/table/{ params.table }" data-testid="button-back" use:link> |
||||||
|
<Icon name="arrow-left-circle" tooltip="Back to table." size="48" /> |
||||||
|
</a> |
||||||
|
|
||||||
|
<span data-testid="button-copy" on:click={ json_copy }> |
||||||
|
<Icon name="copy" tooltip="Copy JSON." size="48" /> |
||||||
|
</span> |
||||||
|
|
||||||
|
<span data-testid="button-update" on:click={ update_record }> |
||||||
|
<Icon name="save" tooltip="Update." size="48" /> |
||||||
|
</span> |
||||||
|
|
||||||
|
<span data-testid="button-delete" on:click={ () => delete_confirm = true }> |
||||||
|
<Icon name="trash" tooltip="Delete!" size="48" /> |
||||||
|
</span> |
||||||
|
</Form> |
||||||
|
{:catch} |
||||||
|
<callout class="error"> |
||||||
|
<p>Return to the table: |
||||||
|
<a href="/table/{ params.table }" data-testid="button-back" use:link> |
||||||
|
<Icon name="arrow-left-circle" tooltip="Back to table." size="48" /> |
||||||
|
</a> |
||||||
|
</p> |
||||||
|
</callout> |
||||||
|
{/await} |
||||||
|
</Layout> |
||||||
|
|
||||||
|
<Toasts bind:send_toast orientation="bottom right"/> |
||||||
|
|
||||||
|
{#if delete_confirm} |
||||||
|
<Modal on:close={() => delete_confirm = false }> |
||||||
|
<card> |
||||||
|
<top> |
||||||
|
<h1>Really Delete?</h1> |
||||||
|
</top> |
||||||
|
|
||||||
|
<middle> |
||||||
|
<p>This action is permanent to make sure it's what you want.</p> |
||||||
|
</middle> |
||||||
|
|
||||||
|
<bottom> |
||||||
|
<button-group> |
||||||
|
<button type="button" data-testid="button-delete-no" on:click={() => delete_confirm = false }>Cancel</button> |
||||||
|
<button data-testid="button-delete-yes" on:click={ delete_record }>DELETE</button> |
||||||
|
</button-group> |
||||||
|
</bottom> |
||||||
|
</card> |
||||||
|
</Modal> |
||||||
|
{/if} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue