Browse Source

Initial commit after converting from the ljsth start project.

master
Zed A. Shaw 2 months ago
commit
ac75953d72
  1. 11
      .gitignore
  2. 1
      README.md
  3. 11
      api/_errors.js
  4. 19
      api/admin/schema.js
  5. 97
      api/admin/table.js
  6. 21
      api/devtools/djenterator.js
  7. 23
      api/devtools/info.js
  8. 36
      api/login.js
  9. 5
      api/logout.js
  10. 47
      api/password_reset.js
  11. 27
      api/register.js
  12. 45
      api/user/profile.js
  13. 66
      client/App.svelte
  14. 84
      client/api.js
  15. 31
      client/assert.js
  16. 425
      client/bando/Bandolier.svelte
  17. 137
      client/bando/Components.svelte
  18. 231
      client/bando/Djenterator.svelte
  19. 112
      client/bando/IconFinder.svelte
  20. 24
      client/bando/demos/Accordion.svelte
  21. 51
      client/bando/demos/Badge.svelte
  22. 30
      client/bando/demos/ButtonGroup.svelte
  23. 27
      client/bando/demos/Calendar.svelte
  24. 33
      client/bando/demos/Callout.svelte
  25. 48
      client/bando/demos/Cards.svelte
  26. 19
      client/bando/demos/Carousel.svelte
  27. 16
      client/bando/demos/Countdown.svelte
  28. 6
      client/bando/demos/Darkmode.svelte
  29. 66
      client/bando/demos/DataTable.svelte
  30. 53
      client/bando/demos/Flipper.svelte
  31. 88
      client/bando/demos/Form.svelte
  32. 13
      client/bando/demos/Login.svelte
  33. 36
      client/bando/demos/Modal.svelte
  34. 34
      client/bando/demos/Pagination.svelte
  35. 6
      client/bando/demos/Panels.svelte
  36. 21
      client/bando/demos/PlaceHolder.svelte
  37. 33
      client/bando/demos/Progress.svelte
  38. 36
      client/bando/demos/Sidebar.svelte
  39. 16
      client/bando/demos/SnapImage.svelte
  40. 6
      client/bando/demos/Spinner.svelte
  41. 48
      client/bando/demos/Switch.svelte
  42. 22
      client/bando/demos/Tabs.svelte
  43. 34
      client/bando/demos/Tiles.svelte
  44. 36
      client/bando/demos/Toast.svelte
  45. 70
      client/bando/demos/Tooltip.svelte
  46. 52
      client/bando/demos/Video.svelte
  47. 137
      client/components/Calendar.svelte
  48. 108
      client/components/Carousel.svelte
  49. 253
      client/components/Chat.svelte
  50. 82
      client/components/Countdown.svelte
  51. 32
      client/components/Darkmode.svelte
  52. 173
      client/components/DataTable.svelte
  53. 20
      client/components/Footer.svelte
  54. 100
      client/components/Form.svelte
  55. 16
      client/components/FormField.svelte
  56. 37
      client/components/Icon.svelte
  57. 85
      client/components/Login.svelte
  58. 43
      client/components/Modal.svelte
  59. 74
      client/components/Pagination.svelte
  60. 21
      client/components/PlaceHolder.svelte
  61. 7
      client/components/ProgressMeter.svelte
  62. 112
      client/components/Sidebar.svelte
  63. 81
      client/components/SnapImage.svelte
  64. 34
      client/components/Spinner.svelte
  65. 59
      client/components/Tabs.svelte
  66. 297
      client/components/Video.svelte
  67. 53
      client/fsm.js
  68. 10
      client/main.js
  69. 83
      client/pages/Home.svelte
  70. 56
      client/pages/Lesson.svelte
  71. 28
      client/pages/Login.svelte
  72. 91
      client/pages/Module.svelte
  73. 1
      client/pages/NotFound.svelte
  74. 102
      client/pages/Register.svelte
  75. 126
      client/pages/ResetPassword.svelte
  76. 112
      client/pages/UserProfile.svelte
  77. 32
      client/pages/admin/Create.svelte
  78. 95
      client/pages/admin/ReadUpdate.svelte
  79. 77
      client/pages/admin/Table.svelte
  80. 69
      client/pages/admin/index.svelte
  81. 29
      client/routes.js
  82. 25
      client/stores.js
  83. 45
      ecosystem.config.cjs
  84. 26
      emails/change_email.txt
  85. 16
      emails/config.js
  86. 555
      emails/invoice.html
  87. 59
      emails/invoice.txt
  88. 553
      emails/receipt.html
  89. 42
      emails/receipt.txt
  90. 538
      emails/register_email.html
  91. 35
      emails/register_email.txt
  92. 505
      emails/reset_email.html
  93. 32
      emails/reset_email.txt
  94. 504
      emails/reset_finished.html
  95. 25
      emails/reset_finished.txt
  96. 45
      knexfile.cjs
  97. 26
      lib/api.js
  98. 155
      lib/auth.js
  99. 13
      lib/devtools.js
  100. 37
      lib/email.js

11
.gitignore

@ -0,0 +1,11 @@
/node_modules/
/public/
/rendered/build/
/rendered/public/
.*.sw*
.DS_Store
*.sqlite3
debug/
static/thumbs
static/videos

1
README.md

@ -0,0 +1 @@

11
api/_errors.js

@ -0,0 +1,11 @@
import { log } from "../lib/logging.js";
export const missing = async (req, res) => {
res.status(404).json({message: "Not found", path: req.path});
}
export const exception = (err, req, res, next) => {
log.error(err);
res.status(500).json({message: "Server Error", status: res.status, path: req.path});
next();
}

19
api/admin/schema.js

@ -0,0 +1,19 @@
import { knex } from '../../lib/ormish.js';
import { API } from '../../lib/api.js';
import assert from 'assert';
export const get = async (req, res) => {
const api = new API(req, res);
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else {
let result = await knex("sqlite_schema").where({"type": "table"}).select(['type', 'name']);
for(let table of result) {
table._columns = await knex(table.name).columnInfo();
}
api.reply(200, result);
}
}

97
api/admin/table.js

@ -0,0 +1,97 @@
import { knex } from '../../lib/ormish.js';
import { developer_admin, API } from '../../lib/api.js';
import assert from 'assert';
export const get = async (req, res) => {
const api = new API(req, res);
const { name, search, row_id, currentPage } = req.query;
const page = parseInt(currentPage || 0);
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name && search) {
// get the list of columns to search
let columns = await knex(name).columnInfo();
let query = knex(name);
for(let col in columns) {
query.orWhere(col, "like", `%${search}%`);
}
// loop through and construct a mega where clause
let result = await query.paginate({perPage: 20, currentPage: page});
return api.reply(200, result);
} else if(name && !row_id) {
// TODO: restrict the tables that can be queried?
let result = await knex(name).paginate({perPage: 20, currentPage: page});
return api.reply(200, result);
} else if(name && row_id) {
let result = await knex(name).where({id: row_id}).first();
return api.reply(200, result);
} else {
return api.error(500, "name query parameter required, or name and row_id.");
}
}
get.authenticated = !developer_admin;
export const del = async (req, res) => {
const api = new API(req, res);
const { name, row_id } = req.query;
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name && row_id) {
let res = await knex(name).where({id: row_id}).delete();
return api.reply(200, {message: "OK", result: res});
} else {
return api.error(500, {message: "name and row_id required for delete"});
}
}
del.authenticated = !developer_admin;
export const post = async (req, res) => {
const api = new API(req, res);
const { name, row_id } = req.query;
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name && row_id) {
let data = req.body;
// remove id so it's not updated too
delete data.id;
let res = await knex(name).where({id: row_id}).update(data);
api.reply(200, {message: "OK", result: res});
} else {
return api.error(500, {message: "name and row_id required for delete"});
}
}
post.authenticated = !developer_admin;
export const put = async (req, res) => {
const api = new API(req, res);
const { name } = req.query;
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name) {
delete req.body.id;
let res = await knex(name).insert(req.body);
api.reply(200, {message: "OK", id: res[0]});
} else {
return api.error(500, {message: "name required for delete"});
}
}
put.authenticated = !developer_admin;

21
api/devtools/djenterator.js

@ -0,0 +1,21 @@
import logging from '../../lib/logging.js';
import { API } from '../../lib/api.js';
import glob from "glob";
import assert from 'assert';
import path from "path";
const log = logging.create(import.meta.url);
export const get = async (req, res) => {
const api = new API(req, res);
glob.glob("./static/djenterator/**/!(*.vars)", (err, files) => {
if(err) {
log.error(error);
api.error(500, error);
} else {
api.reply(200, files.map(f => path.basename(f)));
}
});
}

23
api/devtools/info.js

@ -0,0 +1,23 @@
import devtools from '../../lib/devtools.js';
import fs from 'fs';
export const get = async (req, res) => {
// the devtools module contains all of the errors from the service/api.js for api and sockets
// but to get at the svelte errors we have to read debug/errors/svelte.json
let svelte_errors = [];
try {
svelte_errors = JSON.parse(fs.readFileSync("debug/errors/svelte.json"));
} catch(error) {
// probably no errors written yet
console.error(error);
}
res.status(200).json({
api: devtools.api,
sockets: devtools.sockets,
errors: devtools.errors.concat(svelte_errors)});
}

36
api/login.js

@ -0,0 +1,36 @@
import { developer_admin } from "../lib/api.js";
/**
* Used only the DevTools to see if you're running as a developer
* and need the tools even if not logged in.
*/
export const options = (req, res) => {
res.status(200).json({developer: developer_admin, admin: req.user && req.user.admin});
}
export const get = (req, res) => {
const reply = {
id: req.user.id,
initials: req.user.initials,
full_name: req.user.full_name,
admin: req.user.admin,
email: req.user.email,
}
res.status(200).json(reply);
}
get.authenticated = true;
export const post = (req, res) => {
const reply = {
id: req.user.id,
initials: req.user.initials,
full_name: req.user.full_name,
admin: req.user.admin,
email: req.user.email
}
res.status(200).json(reply);
}
// this flags it as a login endpoint expecting a username/password
post.login = true;

5
api/logout.js

@ -0,0 +1,5 @@
export const get = (req, res) => {
req.logout();
res.status(200).json({message: "OK"});
}

47
api/password_reset.js

@ -0,0 +1,47 @@
import { API } from "../lib/api.js";
import logging from "../lib/logging.js";
import assert from "assert";
import { User } from "../lib/models.js";
import { v4 as uuidv4} from "uuid";
import { send_reset, send_reset_finished } from '../lib/queues.js';
const log = logging.create(import.meta.url);
export const post = async (req, res) => {
const api = new API(req, res);
const { email, code, password, password_repeat, finalize} = req.body;
assert(email, "email is required.");
try {
const user = await User.first({email});
assert(user, "User not found.");
if(finalize) {
assert(password && password_repeat, "password and password repeat required.");
assert(password === password_repeat, "password and password repeat do not match.");
assert(code, "Reset code required.");
// they have a code submitted, so check it
if(user.reset_code !== code) {
return res.status(400).json({message: "Reset code doesn't match."});
} else {
user.password = User.encrypt_password(password);
await User.update({email}, {password: user.password, reset_code: null, reset_sent_at: null});
send_reset_finished(user);
return res.status(200).json({message: "OK"});
}
} else {
// new request, send out a code
user.reset_code = uuidv4();
await User.update({email}, {reset_code: user.reset_code, reset_sent_at: Date.now()});
send_reset(user);
return res.status(200).json({message: "OK"});
}
} catch(error) {
return res.status(400).json({message: error.message});
}
}

27
api/register.js

@ -0,0 +1,27 @@
import { User } from "../lib/models.js";
import * as queues from "../lib/queues.js";
export const post = async (req, res) => {
// TODO: this is kind of weird so find a way to validate but allow one or more fields
let password_repeat = req.body.password_repeat;
delete req.body.password_repeat;
let [valid, errors] = await User.validate(req.body);
if(!valid) {
return res.status(400).json({message: `Invalid user attributes: ${JSON.stringify(errors)}`});
} else {
req.body.password_repeat = password_repeat;
let good = await User.register(req.body);
if(good) {
delete good.password;
queues.send_welcome(good);
res.status(200).json({message: "OK"});
} else {
res.status(403).json({message: "Failed to regiser."});
}
}
}

45
api/user/profile.js

@ -0,0 +1,45 @@
import { User } from "../../lib/models.js";
import * as queues from "../../lib/queues.js";
import logging from '../../lib/logging.js';
import assert from 'assert';
import { API } from '../../lib/api.js';
const log = logging.create(import.meta.url);
export const post = async (req, res) => {
const api = new API(req, res);
let user = req.body;
console.log("USER COMING IN IS", user);
try {
// need to process the password, which is annoying
if(user.password && user.password === user.password_repeat) {
user.password = User.encrypt_password(user.password);
delete user.password_repeat;
} else {
// don't update the password with a blank
delete user.password;
delete user.password_repeat;
}
// now we validate it matches the schema
let [valid, errors] = await User.validate(req.body);
if(valid) {
// TODO: need a way to share the validation
let result = await User.update({id: req.user.id}, user);
api.reply(200, { message: "OK" });
} else {
log.debug(errors);
return api.error(500, `Invalid User fields submitted. ${JSON.stringify(errors)}`);
}
} catch (error) {
log.error(error);
api.error(500, error);
}
}
post.authenticated = true;

66
client/App.svelte

@ -0,0 +1,66 @@
<style>
</style>
<script>
import { user, logout_user } from './stores';
import Router from 'svelte-spa-router';
import routes from './routes.js';
import {push, link} from 'svelte-spa-router';
import Darkmode from './components/Darkmode.svelte';
import Icon from './components/Icon.svelte';
import { onMount } from 'svelte';
import Footer from './components/Footer.svelte';
import api from './api.js';
/* #if process.env.DANGER_ADMIN
import Bandolier from './bando/Bandolier.svelte';
// #endif */
let show_devtools = false;
onMount(async () => {
let [status, data] = await api.get('/api/login');
if(status === 200) {
$user = data;
$user.authenticated = true;
} else {
console.log("/api/login returned", status, "json", data);
}
[status, data] = await api.options("/api/login");
if(status === 200) {
show_devtools = $user.admin || data.admin || data.developer;
}
});
</script>
<header>
<nav>
<a href="/" use:link><Icon name="pen-tool" size="48" /></a>
<ul>
{#if $user.authenticated }
<li><a href="#" on:click|preventDefault={ logout_user } data-testid="logout-link">Logout</a></li>
{:else}
<li><a href="/register/" use:link>Register</a></li>
<li><a href="/login/" use:link>Login</a></li>
{/if}
{#if $user.authenticated }
<li><a href="/profile/" use:link><Icon name="settings" /></a></li>
{/if}
<li><Darkmode /></li>
</ul>
</nav>
</header>
<Router {routes}/>
<Footer />
<!-- #if process.env.DANGER_ADMIN
{#if show_devtools}
<Bandolier shown={ false }/>
{/if}
<!-- #endif -->

84
client/api.js

@ -0,0 +1,84 @@
const MOCK_ROUTES = {};
export const mock = (config) => {
for(let route in config) {
MOCK_ROUTES[route] = config[route];
}
}
export const raw_mock = (url, method, body) => {
let error;
method = method.toLowerCase();
const config = MOCK_ROUTES[url];
const data = config[method];
if(data === undefined) {
error = `Mock ${url}:${method} is not in your mocks list. Did you forget to add it to the api.mock call?`;
} else if(data.length !== 2) {
error = `Mock ${url}:${method} does not have the correct config. Must be [status (int), {data}], like [200, {message: 'OK'}].`
}
if(error) {
console.error(error, MOCK_ROUTES);
return [500, {message: error}];
} else {
return data;
}
}
const parse_url_because_js_is_stupid = (url) => {
try {
// javascript is dumb as hell and thinks a typical /this/that is not a URL
return new URL(url);
} catch(error) {
// so fake it for the mock by tacking on ... localhost then ignoring it
return new URL(`http://localhost${url}`);
}
}
export const raw = async (url, method, body) => {
const parsed = parse_url_because_js_is_stupid(url);
if(parsed.pathname in MOCK_ROUTES) {
return raw_mock(parsed.pathname, method, body);
} else {
let options = {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
}
if(body) options.body = JSON.stringify(body);
/* this handy little gem exists because fetch likes to go:
* "the string did not match the expected pattern
* when the response body is not json...or at least that's
* what I think it means. Now I get the text, and use it
* for logging.
*/
let res = await fetch(url, options);
let text = await res.text();
try {
return [res.status, JSON.parse(text)];
} catch(error) {
console.error(error, "Failed to parse reply body as JSON. Text is:", text, "error", error, "URL", url);
}
}
}
export const get = async (url) => await raw(url, 'GET');
export const post = async (url, data) => await raw(url, 'POST', data);
export const put = async (url, data) => await raw(url, 'PUT', data);
export const del = async (url) => await raw(url, "DELETE");
export const options = async (url) => await raw(url, "OPTIONS");
export default {
post, get, put, del, mock, options
}

31
client/assert.js

@ -0,0 +1,31 @@
/* I can't believe I have to write this just so I can use the assert that should be standard in every javascript. */
class AssertionError extends Error {
/* This is from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
* which claims you have to do this weird stuff to make your error actually work.
*/
constructor(foo = 'bar', ...params) {
// Pass remaining arguments (including vendor specific ones) to parent constructor
super(...params);
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
// this "only on V8" is managed by the Error.captureStackTrace test above
Error.captureStackTrace(this, AssertionError);
}
this.name = 'AssertionError';
// Custom debugging information
this.foo = foo;
this.date = new Date();
}
}
const assert = (test, message) => {
console.assert(test, message);
if(!test) {
throw new AssertionError(message);
}
}
export default assert;

425
client/bando/Bandolier.svelte

@ -0,0 +1,425 @@
<script>
import { push, link } from 'svelte-spa-router';
import Icon from '../components/Icon.svelte';
import IconFinder from "./IconFinder.svelte";
import Djenterator from './Djenterator.svelte';
import { onMount } from 'svelte';
import { fade } from "svelte/transition";
import api from '../api.js';
// this is a unique string that you can grep for to make sure bando isn't in your build
const canary = '3a025dba-1a62-4169-ae9f-f7cd4104c4f4';
export let shown = true;
let icon_finder = true;
let tables = [];
let table_selected = false;
let api_register = {};
let api_selected = false;
let socket_register = {};
let socket_selected = false;
let errors = {};
let error_selected = false;
let has_errors = false;
let show_code = false;
let djenterator_selected = false;
let djenterators = [];
let listing_visible = true;
let notice = "";
$: has_errors = errors.length > 0;
const rephresh = async () => {
let [status, data] = await api.get('/api/admin/schema');
if(status == 200) {
tables = data;
table_selected = tables[0];
}
[status, data] = await api.get('/api/devtools/info');
if(status == 200) {
api_register = data.api;
socket_register = data.sockets;
errors = data.errors;
} else {
console.log("failed to get info", status);
}
[status, data] = await api.get('/api/devtools/djenterator');
if(status == 200) {
djenterators = data;
} else {
console.log("failed to load generators", status);
}
}
const handle_keypress = (event) => {
if(event.ctrlKey && event.altKey) {
if(event.key == "b") {
rephresh();
shown = !shown;
} else if(event.key == "l" && shown) {
listing_visible = !listing_visible;
}
} else if(event.key === "Escape") {
shown = false;
}
}
/* This is getting kind of stupid so improve this weirdo panel selector. */
const reset_view = (...selected) => {
// code is always reset
[icon_finder, api_selected, table_selected, socket_selected, show_code, error_selected] = selected;
}
const search_icons = () => {
console.log("ICONS!");
icon_finder = true;
reset_view(true, false, false, false, false, false);
}
const select_table = (index) => {
reset_view(false, false, tables[index], false, false, false);
}
const select_api = (name) => {
reset_view(false, api_register[name], false, false, false, false);
}
const select_socket = (name) => {
reset_view(false, false, false, socket_register[name], false, false);
}
const select_djenterator = (name) => {
djenterator_selected = name;
// djenterator is last in the list so we don't need its own true/false
reset_view(false, false, false, false, false, false);
}
const select_error = (index) => {
if(!shown) shown = true;
reset_view(false, false, false, false, errors[index], true);
}
const copy_schema = () => {
const text = JSON.stringify(table_selected, null, 4);
console.log("Copying to clip", text);
navigator.clipboard.writeText(text).then(() => {
notice = "Schema Copied";
}, () => {
console.log("Failed to copy.");
notice = "Failed copying to clipboard.";
});
}
console.log("Canary is compiled into build as", canary);
onMount(() => rephresh());
</script>
<svelte:window on:keydown={ handle_keypress} />
<style>
panel {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 0;
background-color: #fff;
border: 3px solid var(--color-accent);
display: flex;
flex-direction: row;
}
panel description {
overflow-y: auto;
padding: 0.5rem;
width: 100%;
}
panel listing {
padding-top: 0px;
background-color: var(--color-bg-secondary);
display: flex;
flex-grow: 1;
flex-direction: column;
flex-basis: 300px;
margin: 0rem;
border-right: 1px solid var(--color-border);
}
panel listing h1 {
padding-left: 0.5rem;
margin: 0px;
font-size: 1.4em;
}
panel listing ul {
list-style-type: none;
line-height: 1.2;
margin-top: 0px;
padding-inline-start: 10px;
}
panel listing ul li {
cursor: pointer;
padding: 0.1rem 0;
}
panel listing buttons {
display: flex;
flex-direction: row;
}
table {
margin-bottom: 1rem;
overflow-y: unset;
overflow-x: unset;
width: 100%;
}
tr {
width: 100%;
}
panel description buttons {
display: flex;
flex-direction: row-reverse;
}
errors {
position: fixed;
bottom: 0;
right: 0;
background-color: red;
padding-right: 1em;
border-radius: var(--border-radius);
color: var(--color-bg);
}
code span.error {
background-color: hsl(0, 20%, 30%);
}
buttons #listing-open {
position: absolute;
top: 0;
left: 0;
}
</style>
{#if shown}
<panel in:fade>
{#if listing_visible}
<listing>
<buttons>
<span on:click={ () => listing_visible = false }>
<Icon name="x" />
</span>
<span on:click={ () => search_icons() }><Icon name="feather" /></span>
<a href="/bando/components/" use:link on:click={ () => shown = !shown }><Icon name="tool" /></a>
</buttons>
<h1>Tables</h1>
<ul>
{#each tables as table, i}
<li on:click={ () => select_table(i) }>{ table.name }</li>
{/each}
</ul>
<h1>API</h1>
<ul>
{#each Object.keys(api_register) as api_name}
<li on:click={ () => select_api(api_name) } >{ api_name }</li>
{/each}
</ul>
<h1>Sockets</h1>
<ul>
{#each Object.keys(socket_register) as socket_name}
<li on:click={ () => select_socket(socket_name) }>{ socket_name }</li>
{/each}
</ul>
<h1>Generators</h1>
<ul>
{#each djenterators as generator}
<li on:click={() => select_djenterator(generator)}>{ generator }</li>
{/each}
</ul>
{#if has_errors }
<h1>Errors</h1>
<ul>
{#each errors as error, i}
<li on:click={ () => select_error(i) }>{ error.filename }</li>
{/each}
</ul>
{/if}
</listing>
{:else}
<buttons id="listing-open" on:click={ () => listing_visible = true}><Icon name="menu" /></buttons>
{/if}
<description>
{#if table_selected}
<h1><a href="/admin/table/{ table_selected.name }" use:link on:click={ () => shown = false }>{ table_selected.name } <Icon name="database" size="36" /></a></h1>
<table>
<thead>
<tr><th><em>{ table_selected.name }</em></th><th>type</th><th>max</th><th>null</th><th>default</th>
</thead>
<tbody>
{#each Object.keys(table_selected._columns) as colname, i}
<tr>
<td>{colname}</td>
<td>{table_selected._columns[colname].type}</td>
<td>{table_selected._columns[colname].maxLength}</td>
<td>{table_selected._columns[colname].nullable}</td>
<td>{table_selected._columns[colname].defaultValue}</td>
</tr>
{/each}
<tbody>
</table>
<buttons>
<a href="/admin/table/create/{ table_selected.name }/" on:click={ () => shown = false } use:link><Icon name="file-plus" size="48" /></a>
<a href="/admin/table/{ table_selected.name }" use:link on:click={ () => shown = false }><Icon name="database" size="48" /></a>
<span on:click={ copy_schema }><Icon name="copy" size="48" /></span>
<b>{ notice }</b>
</buttons>
{:else if api_selected}
<h1>{ api_selected.name } <Icon name="share-2" size="36" /></h1>
<table>
<thead>
<tr><th>function name</th><th>authenticated</th><th>login enabled</th></tr>
</thead>
<tbody>
{#each api_selected.functions as func}
<tr on:click={ () => show_code = func }>
<td>{func.name}</td>
<td>{func.authenticated}</td>
<td>{ func.login }</td>
</tr>
{/each}
</tbody>
</table>
{#if show_code }
<h1>{ show_code.name }</h1>
<pre>
<code>
{#each show_code.code.split('\n') as line, i}
<span>{ line + "\n" }</span>
{/each}
</code>
</pre>
<p>Line numbers are not from the actual source file, but only here for reference.</p>
{/if}
{:else if icon_finder}
<IconFinder />
{:else if socket_selected}
<h1>{ socket_selected.target_name } <Icon name="radio" size="36" /></h1>
<table>
<thead>
<tr><th>route_path</th><th>target_name</th><th>file_name</th></tr>
</thead>
<tbody>
<tr>
<td>{ socket_selected.route_path }</td>
<td>{ socket_selected.target_name }</td>
<td>{ socket_selected.file_name }</td>
</tr>
</tbody>
</table>
<h1>{ socket_selected.route_path }</h1>
<pre>
<code>
{#each socket_selected.code.split('\n') as line, i}
<span>{ line + "\n" }</span>
{/each}
</code>
</pre>
{:else if error_selected}
<h1>{error_selected.filename} <Icon name="alert-circle" size="36" /></h1>
{#if error_selected.plugin}
<table>
<thead>
<tr><th>error field</th><th>error value</th></tr>
</thead>
<tbody>
<tr><td><b>file name</b></td><td>{error_selected.filename}</td></tr>
<tr><td><b>plugin</b></td><td>{error_selected.plugin}</td></tr>
<tr><td><b>message</b></td><td>{ error_selected.message}</td></tr>
<tr><td><b>line</b></td><td>{ error_selected.start.line}</td></tr>
<tr><td><b>column</b></td><td>{ error_selected.start.column}</td></tr>
</tbody>
</table>
{:else}
<table>
<thead>
<tr><th>error field</th><th>error value</th></tr>
</thead>
<tbody>
<tr><td><b>file name</b></td><td>{error_selected.filename}</td></tr>
<tr><td><b>error name</b></td><td>{error_selected.error_name}</td></tr>
<tr><td><b>message</b></td><td>{ error_selected.message}</td></tr>
<tr><td><b>line</b></td><td>{ error_selected.line}</td></tr>
<tr><td><b>column</b></td><td>{ error_selected.column}</td></tr>
</tbody>
</table>
{/if}
{#if !error_selected.plugin }
<h2>Stack</h2>
<pre>
<code>
{ error_selected.stack }
</code>
</pre>
{/if}
<h2>Code</h2>
<pre>
<code>
{#if error_selected.plugin}
{#each error_selected.frame.split("\n") as line, i}
{ line += "\n" }
{/each}
{:else}
{#each error_selected.code.split('\n') as line, i}
<span class:error={ error_selected.line === i + 1}>{ line + "\n" }</span>
{/each}
{/if}
</code>
</pre>
<p>Note that this stack trace is <b>not</b> inside your file. It's only the stack as the loader runs. You'll have to manually run your file with <b>node { error_selected.filename}</b> to get more information (and with svelte it should have printed to the terminal).</p>
{:else if djenterator_selected}
<Djenterator template_file={ djenterator_selected }/>
{:else}
<h1>No database tables available. Are you logged in as admin?</h1>
{/if}
</description>
</panel>
{:else if has_errors}
<errors>
<ul>
{#each errors as error, i}
<li on:click={ () => select_error(i) }>{ error.filename }</li>
{/each}
</ul>
</errors>
{/if}

137
client/bando/Components.svelte

@ -0,0 +1,137 @@
<script>
import Accordion from "./demos/Accordion.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 Countdown from "./demos/Countdown.svelte";
import Darkmode from "./demos/Darkmode.svelte";
import Form from "./demos/Form.svelte";
import Icon from "../components/Icon.svelte";
import IconFinder from "./IconFinder.svelte";
import Login from "./demos/Login.svelte";
import Modal from "./demos/Modal.svelte";
import Pagination from "./demos/Pagination.svelte";
import PlaceHolder from "./demos/PlaceHolder.svelte";
import Progress from "./demos/Progress.svelte";
import Sidebar from "../components/Sidebar.svelte";
import SidebarDemo from "./demos/Sidebar.svelte";
import SnapImage from "./demos/SnapImage.svelte";
import Spinner from "./demos/Spinner.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 Tooltip from "./demos/Tooltip.svelte";
import Video from "./demos/Video.svelte";
import DataTable from "./demos/DataTable.svelte";
import Flipper from "./demos/Flipper.svelte";
import {onMount} from "svelte";
export let panels = [
{title: "Accordion", active: true, icon: "align-justify", component: Accordion},
{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: "Countdown", active: false, icon: "clock", component: Countdown},
{title: "Darkmode", active: false, icon: "sunrise", component: Darkmode},
{title: "Data Table", active: false, icon: "grid", component: DataTable},
{title: "Flipper", active: false, icon: "layers", component: Flipper},
{title: "Form", active: false, icon: "database", component: Form},
{title: "Icon", active: false, icon: "feather", component: IconFinder},
{title: "Login", active: false, icon: "log-in", component: Login},
{title: "Modal", active: false, icon: "maximize", component: Modal},
{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: "SnapImage", active: false, icon: "camera", component: SnapImage},
{title: "Spinner", active: false, icon: "rotate-cw", component: Spinner},
{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: "Tooltip", active: false, icon: "help-circle", component: Tooltip},
{title: "Video", active: false, icon: "video", component: Video},
];
let selected = panels[0];
const sidebar_select = (event) => {
const {index, item} = event.detail;
selected = item;
panels = panels.map((x,i) => {
x.active = i == index;
return x;
});
}
</script>
<style>
main {
display: flex;
flex-direction: row;
margin: 0px;
padding: 0px;
}
div[slot="top"] span {
display: none;
}
div[slot="bottom"] span {
display: none;
}
contents {
padding: 0.5rem;
width: 100%;
}
@media only screen and (max-width: 900px) {
div[slot="top"] h1 {
display: none;
}
div[slot="top"] span {
display: inline-block;
padding-top: 0.3rem;
}
div[slot="bottom"] {
display: none;
}
div[slot="bottom"] span {
display: inline-block;
padding-top: 0.3rem;
}
}
</style>
<main>
<Sidebar on:select={ sidebar_select } menu={ panels }>
<div slot="top">
<h1>Components</h1>
<span><Icon name="home" size="36" /></span>
</div>
<div slot="bottom">
<p>Code is in <b>client/bando/demos</b></p>
</div>
</Sidebar>
<contents>
<h1>{ selected.title }</h1>
<svelte:component this={selected.component} />
</contents>
</main>

231
client/bando/Djenterator.svelte

@ -0,0 +1,231 @@
<script>
import { push, link } from 'svelte-spa-router';
import { onMount } from 'svelte';
import template from "lodash/template";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import api from "../api.js";
export let template_file = "";
let showing_rendered = false;
let user_info = {};
let results = "";
let source = "";
let variable_json = "{ }";
let variables = {};
let renderer = () => source;
let notice = "";
let last_good = "";
$: if(variable_json) render_template();
// this reload the templates when you click on a new one
$: if(template_file) re_render(template_file);
const re_render = async (what) => {
await load_variables(what);
await load_template(what);
}
const render_template = () => {
try {
// avoid rendering when the current template doesn't match the renderer
if(template_file == renderer._template) {
variables = variable_json ? JSON.parse(variable_json): {};
results = renderer(variables);
notice = "";
last_good = results;
}
return true;
} catch(err) {
notice = err.message;
results = last_good;
return false;
}
}
const load_variables = async (template_name) => {
let res = await fetch(`/djenterator/${template_name}.vars`);
if(res.status == 200) {
variable_json = await res.text();
} else {
variable_json = '{}';
notice = `No ${template_name}.vars file found. ${res.status}`;
}
}
const load_template = async (template_name) => {
let res = await fetch(`/djenterator/${template_name}`);
if(res.status == 200) {
source = await res.text();
last_good = source;
try {
renderer = template(source);
// tag this template renderer so that we don't try to render it against the wrong one
renderer._template = template_name;
render_template();
} catch(error) {
console.error(error);
notice = `${error.message}`;
results = source;
}
} else {
notice = `Error loading ${template_name}: ${res.status}`;
}
}
const toggle_template = () => {
if(showing_rendered) {
render_template();
} else {
results = renderer.toString();
}
showing_rendered = !showing_rendered;
}
const copy_code = () => {
navigator.clipboard.writeText(results).then(() => {
notice = "Code copied to clipboard.";
}, () => {
notice = "Failed copying to clipboard.";
});
}
const canary = '3a025dba-1a62-4169-ae9f-f7cd4104c4f4';
console.log("Canary is compiled into build as", canary);
</script>
<style>
main {
display: flex;
flex-direction: row;
}
right pre {
display: flex;
font-size: 1em;
flex-basis: 100%;
margin: 0px;
padding: 0px;
}
right pre code {
padding-top: 2rem;
display: flex;
flex-basis: 100%;
border-radius: 0px 4px 4px 0px;
line-height: unset;
margin: 0px;
}
left {
display: flex;
flex-grow: 1;
flex-basis: 70ch;
}
left textarea {
border-radius: 4px 0px 0px 4px;
margin: 0px;
background-color: var(--color-secondary);
color: var(--color-bg);
}
right {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 3;
flex-basis: 100ch;
align-items: stretch;
}
status {
position: absolute;
z-index: 100;
top: 0;
padding-right: 1rem;
padding-left: 1rem;
padding-top: 0.1rem;
width: 100%;
color: var(--color-bg);
display: flex;
box-sizing: border-box;
background-color: var(--color-secondary);
border-radius: 0px 4px 0px 0px;
justify-content: space-evenly;
}
status file {
flex-basis: 90%;
text-align: right;
}
status buttons {
display: flex;
justify-content: space-around;
flex-basis: 10%;
padding-top: 0.5rem;
}
pre {
position: relative;
height: 90vh;
max-height: 90vh;
overflow-y: auto;
}
left textarea {
height: 90vh;
max-height: 90vh;
}
pre notice {
position: absolute;
bottom: 0;
left: 0.3rem;
right: 0.3rem;
text-align: right;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-tertiary);
border-radius: 4px 4px 0px 0px;
}
</style>
<main>
<left>
<textarea class="editor" bind:value={ variable_json } rows="15"></textarea>
</left>
<right>
<status>
<buttons>
<span on:click={ copy_code }><Icon name="copy" size="24" color="var(--color-bg)" /></span>
<span on:click={ toggle_template }><Icon name="code" size="24" color="var(--color-bg)" /></span>
</buttons>
<file>static/djenterator/{ template_file }</file>
</status>
<pre>
<code>
{results}
</code>
{#if notice}
<notice in:fade on:click={ () => notice = "" }>
<b>{ notice }</b>
</notice>
{/if}
</pre>
</right>
</main>

112
client/bando/IconFinder.svelte

@ -0,0 +1,112 @@
<script>
import Icon from "../components/Icon.svelte";
import { onMount } from "svelte";
import api from "../api.js";
let all_icons = [];
let inactive = false;
let icons = [];
export let size=48;
let search = "";
export let labels=true;
export let tight=false;
let message = "";
$: search_icons(search);
const search_icons = (pattern) => {
icons = all_icons.filter(i => i.includes(pattern));
}
const gen_code = (name) => {
let results = `<Icon name="${name}" size="${size}" />`;
navigator.clipboard.writeText(results).then(() => {
message = `${name} copied to clipboard.`;
}, () => {
message = `${name} copy FAILED.`;
});
}
onMount(async () => {
const [status, data] = await api.get("/feather-sprite.json");
if(status === 200) {
icons = data;
all_icons = icons;
} else {
console.error("Invalid response", status, data);
}
});
</script>
<style>
icons {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: auto;
row-gap: 1rem;
}
icons.tight {
grid-template-columns: repeat(10, 1fr);
}
icons icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
search-bar {
display: flex;
justify-content: space-evenly;
flex-wrap: nowrap;
}
search-bar input#size {
width: 6ch;
}
search-bar input#search {
min-width: 30ch;
max-wdith: 30ch;
width: 30ch;
}
search-bar span {
padding-right: 1rem;
}
@media only screen and (max-width: 900px) {
icons {
grid-template-columns: repeat(4, 1fr);
}
}
@media only screen and (max-width: 600px) {
icons {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
<search-bar>
<span on:click={ () => inactive = !inactive }><Icon name={ inactive ? 'eye' : 'eye-off'} size="24" /></span>
<input bind:value={ search } id="search" > <Icon name="x" size="24" /> <input bind:value={ size } id="size" >
<span>{ message }</span>
</search-bar>
<icons class:tight={ tight }>
{#each icons as name}
<icon on:click={ () => gen_code(name) }>
<Icon name={ name } size={ size } inactive={inactive}/>
{#if labels}
<span>{ name }</span>
{/if}
</icon>
{:else}
<h1>No Icons</h1>
{/each}
</icons>

24
client/bando/demos/Accordion.svelte

@ -0,0 +1,24 @@
<script>
import Tabs from "../../components/Tabs.svelte";
import Calendar from "./Calendar.svelte";
import Cards from "./Cards.svelte";
import Login from "./Login.svelte";
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) => {
console.log("SELECTED TAB", event.detail);
}
</script>
<Tabs panels={ panels } on:select={ tab_select } bind:selected vertical={true} />
<hr/>
<p>An Accordion is just the <b>Tabs</b> component with <b>vertical</b> set to <b>true</b>. </p>

51
client/bando/demos/Badge.svelte

@ -0,0 +1,51 @@
<script>
import Icon from "../../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="var(--font-size-badge)" width="1px" /></badge>
<p>Top left position with <b>class="top-left"</b>:</p>
<box>
<h3>Top Left</h3>
<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>
<h3>Bottom Left</h3>
<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>
<h3>Top Right</h3>
<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>
<h3>Bottom Right</h3>
<badge class="bottom-right"><Icon name="inbox" size="14px" width="1px" /></badge>
</box>

30
client/bando/demos/ButtonGroup.svelte

@ -0,0 +1,30 @@
<script>
import Icon from "../../components/Icon.svelte";
</script>
<style>
button#ok {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-bg-tertiary);
}
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>

27
client/bando/demos/Calendar.svelte

@ -0,0 +1,27 @@
<script>
import Calendar from "../../components/Calendar.svelte";