Files
ValidBoard/nullboard.html
Tom FluxandClaude Opus 5 7e2f280acc Store boards on the server rather than in localStorage
Adds Storage_Server next to upstream's Storage_Local and swaps which one
is instantiated; the rest of nullboard.html is untouched so upstream
changes still merge.

The Storage contract is synchronous — setItem has to return true or false
there and then — which no round trip can satisfy. So the whole keyspace
is held in memory and the server is somewhere to push it to:

  - The initial copy is embedded in the page by the server, already
    parsed and in hand before the app boots. In the page rather than a
    .js file so another site can't <script src> it and read the boards.
  - Writes are collected by key, so a note edited repeatedly is one
    write, and flushed shortly after as a single transaction. A failed
    batch goes back on the queue and retries with a backoff rather than
    being dropped.
  - The logo shows saving/not saved, and closing the tab with writes
    still queued asks first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
2026-08-14 22:41:01 +01:00

5606 lines
113 KiB
HTML

<!doctype html>
<html>
<head>
<!--
+-------------------------------------------------------------+
| |
| Nullboard, a minimalist kanban board |
| https://nullboard.io |
| |
+-------------------------------------------------------------+
LICENSE
The 2-clause BSD license with the Commons Clause condition.
IN SHORT
You can use, change and distribute the software free of charge
provided that you do not sell it or make money off it in
certain less direct ways as specified below. When distributed,
the software must include a complete copy of this license.
IN FULL
The Software is provided to you by the Licensor under the
License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of
rights under the License will not include, and the License does
not grant to you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or
all of the rights granted to you under the License to provide
to third parties, for a fee or other consideration (including
without limitation fees for hosting or consulting/ support
services related to the Software), a product or service whose
value derives, entirely or substantially, from the functionality
of the Software. Any license notice or attribution required
by the License must also include this Commons Clause License
Condition notice.
Software: Nullboard
License: The 2-clause BSD License
Licensor: Alexander Pankratov / swapped.ch
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nullboard</title>
<link rel="icon" href="extras/favicon-16.png"/>
<style>
/***/
@font-face {
font-family: 'f-barlow';
font-weight: 400;
font-style: normal;
src: url('extras/Barlow-Regular.woff') format('woff');
}
@font-face {
font-family: 'f-barlow';
font-weight: 500;
font-style: normal;
src: url('extras/Barlow-Medium.woff') format('woff');
}
/***/
@font-face {
font-family: 'f-ibm-plex';
font-weight: 400;
font-style: normal;
src: url('extras/IBMPlexSans-Regular.woff') format('woff');
}
@font-face {
font-family: 'f-ibm-plex';
font-weight: 500;
font-style: normal;
src: url('extras/IBMPlexSans-Medium.woff') format('woff');
}
/***/
@font-face {
font-family: 'f-open-sans';
font-weight: 400;
font-style: normal;
src: url('extras/OpenSans-Regular.woff') format('woff');
}
@font-face {
font-family: 'f-open-sans';
font-weight: 500;
font-style: normal;
src: url('extras/OpenSans-600.woff') format('woff');
}
/***/
@font-face {
font-family: 'f-segoe-ui';
font-weight: 400;
font-style: normal;
src: local('Segoe UI');
}
@font-face {
font-family: 'f-segoe-ui';
font-weight: 500;
font-style: normal;
src: local('Segoe UI Semibold');
}
/***/
@font-face {
font-family: 'f-maven-pro';
font-weight: 400;
font-style: normal;
src: url('extras/MavenPro-Regular.woff') format('woff');
}
@font-face {
font-family: 'f-maven-pro';
font-weight: 500;
font-style: normal;
src: url('extras/MavenPro-500.woff') format('woff');
}
/***/
html {
--ff: f-barlow, sans-serif;
--fs: 11; /* font-size */
--lh: calc( var(--fs) * 1.25 ); /* line-height */
--fs-head: 13.6; /* font-size of board heading */
--fs-nops: 9; /* font-size of the note ops */
--lw: calc(20 * var(--fs) + 30); /* .list width */
}
html, body, input, textarea {
font-family: var(--ff);
font-size: calc( var(--fs) * 1px );
line-height: calc( var(--lh) * 1px );
}
/***/
html.f-ibm-plex {
--ff: f-ibm-plex, sans-serif;
--fs: 11;
--fs-head: 12.5;
--lw: calc(20 * var(--fs) + 40); /* .list width */
}
/***/
html.f-open-sans {
--ff: f-open-sans, sans-serif;
--fs: 11;
--fs-head: 12.5;
--lw: calc(20 * var(--fs) + 50); /* .list width */
}
/***/
html.f-segoe-ui {
--ff: f-segoe-ui, sans-serif;
--fs: 11;
--fs-head: 13;
--fs-nops: 8;
--lw: calc(22 * var(--fs) + 40); /* .list width */
}
/***/
html.f-maven-pro {
--ff: f-maven-pro, sans-serif;
--fs: 12;
--fs-head: 13;
--lw: calc(20 * var(--fs) + 47); /* .list width */
}
/***/
html, body, h1, textarea, input {
padding: 0;
margin: 0;
}
body {
background: #fff;
background: #f8f9fb;
}
/***/
@keyframes shake {
33% { margin-left: -5px; }
66% { margin-left: 5px; }
100% { margin-left: 0px; }
}
a {
text-decoration: none;
transition: color 200ms;
}
a, a:active, a:focus, textarea, input {
outline: none;
}
tt {
display: none;
}
/***/
.ding {
animation-name: shake;
animation-duration: 200ms;
}
.no-user-select {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/***/
.clearfix:after,
.board:after,
.lists:after,
.notes:after,
.head:after,
.menu:after {
content: "";
display: table;
clear: both;
}
/***/
body.dragging {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/***/
.board {
min-width: calc( var(--lw) * 1px );
width: -moz-max-content; /* firefox */
width: -webkit-max-content; /* chrome */
width: intrinsic; /* safari */
margin: 0 auto;
padding: 20px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.crowded .board {
margin-top: 28px;
}
.board u {
text-decoration: none;
}
.board u:before {
content: '\00D7';
position: relative;
top: 2px;
font-size: calc(16rem / 11);
line-height: calc(10 / 17);
font-weight: 400;
}
/***/
.board .head {
padding: 5px 0;
margin: 1px 5px 0;
position: relative;
}
.board .head .text,
.board .head .edit {
font-weight: 500;
font-size: calc( var(--fs-head) / 11 * 1rem );
line-height: calc( 20 / var(--fs-head) );
padding: 0 5px 2px;
border: none;
}
.board .head .text {
min-height: 20px;
white-space: pre;
overflow: hidden;
}
.board .head .edit {
display: none;
}
.board .head .edit::placeholder {
font-weight: 400;
font-size: calc(10rem / 11);
line-height: calc(22 / 10);
text-transform: uppercase;
color: #1489db;
opacity: 0.8;
}
.board .head.editing .text {
display: none;
}
.board .head.editing .edit {
display: block;
outline: 1px solid #8eaedd;
}
.board .menu {
position: absolute;
top: 0;
right: 0;
height: 20px;
padding: 5px 6px 7px 30px;
background: linear-gradient(to right, #EAEDF000, #EAEDF0 10px);
line-height: calc(20 / 11);
}
.board .menu a,
.board .ops a {
color: #000000A0;
transition: color 200ms;
}
.board .menu a {
padding-left: 10px;
}
.board .menu a:hover,
.board .ops a:hover {
color: #000;
}
.board .menu a.warn:hover,
.board .ops a.warn:hover {
color: #c40;
}
.board .menu .undo-board,
.board .menu .redo-board {
display: none;
}
.board .head.editing .menu {
display: none;
}
.board > .head {
background: #EAEDF0;
padding: 5px;
margin: 0 0 10px;
border-radius: 2px;
position: relative;
}
.board > .head .menu {
margin-right: 5px;
}
/***/
.board .lists-scroller {
height: auto;
margin: -1px 0 10px;
overflow-x: auto;
overflow-y: hidden;
display: none;
}
.lists-scroller div {
height: 1px;
}
.board .lists {
white-space: nowrap;
overflow: auto;
scrollbar-width: none;
}
.board .list {
display: inline-block;
vertical-align: top;
width: calc( var(--lw) * 1px );
margin: 0 5px 10px;
background: linear-gradient(#EAEDF0 30px, #DDE1E5 90px);
border-radius: 2px;
}
.board .list::-webkit-scrollbar {
display: none;
}
.board .list:first-child {
margin-left: 0;
}
.board .list:last-child {
margin-right: 0;
}
.board .list .notes {
padding: 0 5px;
}
/***/
.board .head .menu .teaser {
position: absolute;
right: 3px;
top: 5px;
padding: 0 3px;
}
.board .head .menu .bulk {
display: none;
opacity: 0;
z-index: 1;
}
.board .head .menu:hover .bulk {
display: block;
opacity: 1;
}
.board .head .menu:hover .teaser {
display: none;
}
/***/
.board .list .menu .mov-list-r.half {
padding-left: 0;
}
.board .list .menu .full {
display: none;
}
.board .list:first-child .menu .half,
.board .list:last-child .menu .half {
display: none;
}
.board .list:first-child .menu .mov-list-r.full,
.board .list:last-child .menu .mov-list-l.full {
display: inline-block;
}
.board .list:first-child:last-child .menu .half,
.board .list:first-child:last-child .menu .full {
display: none;
}
/***/
.board .note {
background: #fff;
margin-top: 5px;
box-shadow: 0 1px 2px #bbb, 0 0 1px #ddd;
position: relative;
}
.board .note.dragging,
.board .note.dragging.raw {
background: #CED4DA;
box-shadow: 0 +1px 0 #0001 inset,
0 -1px 0 #0001 inset,
+1px 0 0 #0001 inset,
-1px 0 0 #0001 inset;
}
.board .note.dragging * {
opacity: 0 !important;
}
/***/
.board .note:last-child {
margin-bottom: 5px;
}
.board .note {
padding-bottom: 6px;
}
.board .note .text,
.board .note .edit {
padding: 5px 10px 0;
margin-right: 15px;
min-height: 100%;
}
.board .note .text {
white-space: pre-wrap;
overflow-wrap: anywhere;
min-height: calc( var(--lh) * 1px );
}
/***/
.board .head .text a,
.board .note .text a {
color: inherit;
cursor: default;
transition: none;
}
@keyframes whoomp {
0% { color: inherit; }
30% { color: #888; }
100% { color: inherit; }
}
.board .head .text a:hover,
.board .note .text a:hover {
animation-name: whoomp;
animation-duration: 700ms;
}
.reveal .board .head .text a,
.reveal .board .note .text a {
color: #1489db;
cursor: pointer;
}
.reveal .board .head .text a:hover,
.reveal .board .note .text a:hover {
animation-name: none;
}
/***/
.board .note .edit {
display: none;
border: none;
}
.board .note.editing {
box-shadow: none;
outline: 1px solid #8eaedd;
}
.board .note.editing .text {
display: none;
}
.board .note.editing .edit {
display: block;
resize: none;
}
/***/
.board .note .ops {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 400ms;
cursor: default;
font-size: calc(9rem / 11);
}
.board .note.editing .ops {
display: none;
}
.board .note:hover .ops {
opacity: 1;
}
.board .note .ops .teaser {
display: block;
margin-top: 2px;
margin-right: 1px;
padding-right: 3px;
}
.board .note .ops .teaser:before {
content: '\2261';
}
.board .note .ops .bulk {
display: none;
background: #fff;
border-left: none;
padding: 1px 0 2px 5px;
font-size: calc( var(--fs-nops) * 1px );
font-weight: 500;
border-left: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
.board .note .ops .bulk a {
padding-right: 4px;
}
.board .note .ops:hover .bulk {
display: block;
}
.board .note .ops:hover .teaser {
display: none;
}
/***/
.board .note.raw {
background: transparent;
box-shadow: none;
font-weight: 500;
}
.board .note.raw.editing {
background: #fff;
}
.board .note.raw .text {
}
/***/
.board .note.collapsed {
padding-bottom: 6px;
}
.board .note.collapsed .text,
.note-dragster.collapsed .text {
max-height: calc( var(--lh) * 1px );
overflow: hidden;
padding-bottom: 0;
}
.board .note.collapsed .ops {
opacity: 1;
}
.board .note.collapsed .ops .teaser {
padding: 1px 3px 0 1px;
}
.board .note.collapsed .ops .teaser:before {
content: '_';
top: 1px;
}
.board .note.collapsed:hover .ops .teaser:before {
content: '\2261';
}
/***/
.note-dragster {
z-index: 2;
position: absolute;
opacity: 0;
background: #fff;
white-space: pre-wrap;
cursor: move;
padding: 5px 25px 6px 10px;
box-shadow: 0 +1px 0 #ACB4BC inset,
0 -1px 0 #ACB4BC inset,
+1px 0 0 #ACB4BC inset,
-1px 0 0 #ACB4BC inset,
0px 1px 3px #00000030;
}
.note-dragster a {
color: #000;
}
/***/
.logo {
position: absolute;
top: 9px;
left: 9px;
line-height: calc(19 / 11);
padding: 6px 12px;
z-index: 3;
opacity: 0.6;
background: #f8f9fb;
}
.crowded .logo:hover {
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.logo:hover {
opacity: 1.0;
}
.logo .alert {
display: none;
font-style: normal;
margin-left: 5px;
color: #d20;
}
.logo .bulk {
overflow: hidden;
height: 0;
opacity: 0;
transition: opacity 400ms;
padding-top: 1px;
}
.logo:hover .bulk {
height: auto;
opacity: 1;
}
.logo a.site,
.logo .bulk a {
color: #000;
}
.logo .bulk a:hover {
color: #1489db;
}
.logo .bulk a {
display: block;
}
.logo .bulk a:before {
display: inline-block;
content: '-';
width: 11px;
}
/***/
.logo.updated {
opacity: 1;
}
.logo.updated .alert {
display: inline-block;
}
.logo.updated .bulk .view-changes {
color: #d20;
}
/*** ValidBoard: save state, next to the (updated) alert */
.logo .sync {
display: none;
font-style: normal;
margin-left: 5px;
}
.logo.sync-busy .sync,
.logo.sync-err .sync {
display: inline-block;
}
.logo.sync-busy .sync {
color: #999;
}
/* An unsaved board is worth breaking the logo's usual fade-out for. */
.logo.sync-err {
opacity: 1;
}
.logo.sync-err .sync {
color: #d20;
}
/***/
.config {
position: absolute;
top: 10px;
right: 21px;
line-height: calc(19 / 11);
z-index: 3;
background: #f8f9fb;
}
.crowded .config:hover,
.crowded.adjusting .config {
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.config a {
display: block;
color: #000;
transition: color 200ms;
}
.config a:hover {
color: #1489db;
}
.config .teaser {
padding: 5px;
color: #999;
position: relative;
}
.config .teaser i {
/* backups off */
font-style: normal;
}
.config .teaser u {
/* backups on */
display: none;
text-decoration: none;
}
.config .bulk {
margin-right: 20px;
padding: 5px 0 0 22px;
transition: opacity 400ms;
}
.config .bulk .boards {
display: none;
padding: 6px 2px;
margin: 6px -2px;
border-top: 1px solid #00000028;
border-bottom: 1px solid #00000028;
}
.config .bulk .boards a.load-board {
display: block;
padding-left: 5px;
margin-left: -5px;
transition: none;
}
.config .bulk .boards a.load-board.active {
color: #1489db;
}
.config .bulk .boards a.load-board.active:before {
content: '\2022 ';
display: inline-block;
width: 13px;
margin-left: -13px;
}
.config .bulk .boards a.load-board.active:hover {
color: #000;
}
.config .bulk .boards a.load-board.dragging {
background: #DDE1E5;
border-radius: 2px;
color: transparent;
transition: none;
box-shadow: 0 0 1px #0002 inset;
}
.config .bulk .boards a.load-board.dragging.active:before {
content: '';
}
.load-dragster {
z-index: 10;
position: absolute;
opacity: 0;
line-height: calc(19 / 11);
padding-left: 5px;
background: #fff;
color: #000;
border-radius: 2px;
cursor: move;
outline: 1px solid #ACB4BC;
box-shadow: 0px 1px 3px #00000030;
}
.config .bulk a i,
.config .bulk a b {
font-style: normal;
font-weight: inherit;
}
.config .bulk a i {
display: none;
}
.config .bulk .break {
padding: 6px 2px 0;
margin: 6px -2px 0;
border-top: 1px solid #00000028;
}
/***/
.config .bulk input.imp-board-select {
position: absolute;
width: 1px;
height: 1px;
visibility: hidden;
}
/***/
.config.backups-on .teaser i {
display: none;
}
.config.backups-on .teaser u {
display: inline-block;
font-size: calc(8rem / 11);
color: #555;
}
.config.backups-on.backing-up .teaser u {
opacity: 0.4;
}
.config.backups-on .bulk .auto-backup:before {
content: '\2713 ';
display: inline-block;
width: 15px;
margin-left: -15px;
}
/***/
.config.backups-on.backup-err .teaser i {
display: inline-block;
}
.config.backups-on.backup-err .teaser u {
display: inline-block;
position: absolute;
width: 4px;
height: 4px;
border-radius: 10px;
top: 13px;
right: -4px;
background: #d20;
color: transparent;
}
.config.backups-on.backup-err .bulk .auto-backup {
color: #d20;
}
.config.backups-on.backup-err .bulk .auto-backup:before {
content: '! ';
color: #d20;
width: 13px;
margin-left: -13px;
}
/***/
.config .bulk .section .title {
text-align: center;
width: 0;
display: inline-block;
white-space: pre;
transition: width 250ms, padding 250ms;
}
.config .bulk .section .title u {
text-decoration: none;
transition: opacity 100ms;
}
.config .bulk .section .details {
height: 0;
opacity: 0;
transition: opacity 250ms;
padding-right: 8px;
margin-right: -8px;
}
/***/
.config .bulk .section.open {
padding-bottom: 8px;
border-bottom: 1px solid #00000028;
}
.crowded .config .bulk .section.open {
border-bottom: none;
}
.config .bulk .section.open .title {
width: 100%;
font-weight: 500;
padding-left: 5px;
transition: width 250ms, padding 250ms;
}
.config .bulk .section.open .title u {
opacity: 0;
transition: opacity 100ms;
}
.config .bulk .section.open .details {
height: 100%;
opacity: 1;
transition: opacity 250ms;
}
/***/
.config .bulk .ui-prefs .f-prefs table {
width: 10px; /* mobile safari */
padding: 0;
margin: 0;
border-collapse: collapse;
}
.config .bulk .ui-prefs .f-prefs td {
margin: 0;
padding: 0 0 0 5px;
}
.config .bulk .ui-prefs .f-prefs td.name {
width: 100%;
white-space: pre;
padding-left: 0;
}
.config .bulk .ui-prefs .f-prefs tr td.val {
text-align: center;
}
.fs-set .config .bulk .ui-prefs .f-prefs tr.ui-fs td.val,
.lh-set .config .bulk .ui-prefs .f-prefs tr.ui-lh td.val,
.lw-set .config .bulk .ui-prefs .f-prefs tr.ui-lw td.val {
color: #1489db;
cursor: pointer;
}
.config .bulk .ui-prefs .f-prefs a {
display: inline-block;
padding: 0 2px;
transition: padding 250ms;
}
.config .bulk .ui-prefs .f-prefs a.less:hover {
padding: 0 4px 0 0;
transition: padding 250ms;
}
.config .bulk .ui-prefs .f-prefs a.more:hover {
padding: 0 0 0 4px;
transition: padding 250ms;
}
.config .bulk .ui-prefs .f-prefs a.more:hover {
padding: 0 0 0 4px;
transition: padding 250ms;
}
/***/
.config .bulk .ui-prefs .switch-font.active:before {
content: '\2022 ';
display: inline-block;
width: 13px;
margin-left: -13px;
}
/***/
.config .bulk {
display: none;
opacity: 0;
}
.config:hover .teaser,
.adjusting .config .teaser {
display: none;
}
.config:hover .bulk,
.adjusting .config .bulk {
display: block;
opacity: 1;
}
/***/
.adjusting * {
cursor: row-resize !important;
}
/***/
.overlay {
position: fixed;
z-index: 10;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
color: #000;
display: none; /* flex */
justify-content: center;
}
.overlay > div {
font-size: calc(12rem / 11);
line-height: calc(16 / 12);
background: #fff;
align-self: start;
margin-top: 100px;
border-radius: 1px;
box-shadow: 0 2px 6px #000;
}
/***/
.overlay > div.license {
white-space: pre-wrap;
padding: 20px 25px 22px;
overflow-y: auto;
width: calc( (var(--fs) * 36 + 6) * 1px ); /* 402px, 476px */
}
.overlay > div.license a {
color: #1489db;
}
.overlay > div.license span {
display: inline-block;
padding-right: 8px;
}
/***/
.overlay > div.about {
text-align: center;
padding: 50px 50px;
position: relative;
}
.overlay > div.about h1 {
font-size: calc(15rem / 11);
font-weight: 500;
margin-bottom: 10px;
}
.overlay > div.about a {
display: block;
color: #1489db;
}
.overlay > div.about div {
position: absolute;
bottom: -30px;
left: 0;
width: 100%;
color: #fff9;
}
.overlay > div.about div span {
display: inline-block;
text-align: left;
padding-right: 10px;
}
/***/
.overlay .backup-conf {
padding: 20px 40px;
min-width: 300px;
position: relative;
}
.overlay .backup-conf .close {
position: absolute;
top: 0px;
right: 12px;
font-size: calc(22rem / 11);
color: #000;
opacity: 0.3;
transition: opacity 300ms;
}
.overlay .backup-conf .close:hover {
opacity: 1;
transition: opacity 300ms;
}
.overlay .backup-conf h3 {
font-size: calc(15rem / 11);
font-weight: 500;
text-align: center;
margin: 20px 0 0 0;
}
.overlay .backup-conf .what {
display: block;
text-align: center;
border-bottom: 1px solid #888;
margin: 0 -10px 20px;
padding: 0 10px 30px;
}
.overlay .backup-conf .what a {
color: #1489db;
}
.overlay .backup-conf .opt {
margin-top: 5px;
}
.overlay .backup-conf .opt:before {
--col: #333;
content: ' ';
display: inline-block;
margin-top: -1px;
display: inline-block;
content: ' ';
width: calc(9rem / 11);
height: calc(9rem / 11);
margin-right: 8px;
position: relative;
top: 1px;
background: var(--col);
border: 1px solid var(--col);
border-radius: 1px;
box-shadow: 0 2px 0 #fff inset, 0 -2px 0 #fff inset, 2px 0 0 #fff inset, -2px 0 0 #fff inset;
}
.overlay .backup-conf .opt:hover:before {
--col: #1489db;
}
.overlay .backup-conf .off .opt:before {
background: #fff;
}
.overlay .backup-conf .opt:hover {
color: #1489db;
}
.overlay .backup-conf .opt {
cursor: pointer;
}
.overlay .backup-conf .etc {
margin-top: 3px;
margin-bottom: 10px;
margin-left: calc( 9rem / 22 );
padding: 2px 0 0 14px;
border-left: 1px solid #888;
}
.overlay .backup-conf .off .etc {
display: none;
}
.overlay .backup-conf .etc span {
display: inline-block;
min-width: calc( 3px + var(--fs) * 7px );
margin-right: calc( var(--fs) * 1px );
}
.overlay .backup-conf .etc input {
display: inline-block;
width: 170px;
padding: 0 5px;
line-height: calc(20 / 11);
margin-bottom: 3px;
border: 1px solid #ccc;
}
.overlay .backup-conf .etc input:focus {
border-bottom: 1px solid #888;
}
.overlay .backup-conf .etc input::placeholder {
font-weight: 400;
font-size: calc(10rem / 11);
line-height: calc(20 / 10);
text-transform: uppercase;
color: #000;
opacity: 0.4;
}
.overlay .backup-conf .etc .status {
display: none;
margin: 0;
}
.overlay .backup-conf .etc .status input {
color: #222;
background: #0000000E;
border: 1px solid transparent;
}
.overlay .backup-conf .etc .status.error input {
color: #d20;
background: #dd220018;
}
.overlay .backup-conf .save {
display: block;
text-align: center;
margin: 20px -10px 5px;
padding: 20px 10px 0;
border-top: 1px solid #888;
word-spacing: 30px;
position: relative;
}
.overlay .backup-conf .save a {
color: #1489db;
}
.overlay .backup-conf.off .save .check {
color: #aaa;
pointer-events: none;
cursor: default;
}
/***/
@media print {
.logo, .config,
.board .head .teaser,
.list .head .teaser {
visibility: hidden;
display: none;
}
.board .note {
box-shadow: none;
text-shadow: none !important;
outline: 1px solid #888;
}
.list {
outline: 1px solid #555;
}
.board .note .raw {
outline: none;
text-shadow: none;
}
.board .head .text a,
.board .note .text a {
color: #333;
text-shadow: none;
text-decoration: underline;
}
}
/*
* Dark mode
*/
.theme-dark body {
background: #09090A;
color: #d6d6d6;
}
.theme-dark .board .head {
background: #17171a;
}
/***/
.theme-dark .board .menu {
background: linear-gradient(to right, #17171a00, #17171a 10px);
}
.theme-dark .board .menu a {
color: #aaa;
}
.theme-dark .board .menu a:hover {
color: #fc4;
}
.theme-dark .board .menu a.warn:hover,
.theme-dark .board .ops a.warn:hover {
color: #fc5555 !important;
text-shadow: 0 0 1px #fc555544;
}
.theme-dark .board .head {
color: #eee;
}
.theme-dark .board .head .edit::placeholder {
color: #bf9d21;
}
.theme-dark .board > .head {
box-shadow: 0 0 2px #fff1 inset;
}
/***/
.theme-dark .board .list {
background: #17171a;
box-shadow: 0 0 2px #fff1 inset;
}
.theme-dark .board .note {
background: linear-gradient(#242426, #202022);
box-shadow: 0 1px 3px #000a;
text-shadow: 0 0 4px #0008;
}
.theme-dark .reveal .board .head .text a,
.theme-dark .reveal .board .note .text a {
color: #fc0;
}
@keyframes whoomp-dark {
0% { color: inherit; }
30% { color: #fff; }
100% { color: inherit; }
}
.theme-dark .board .head .text a:hover,
.theme-dark .board .note .text a:hover {
animation-name: whoomp-dark;
}
.theme-dark .board .note.raw {
background: transparent;
box-shadow: none;
}
.theme-dark .board .note.raw .text {
color: #eee;
text-shadow: 0 0 4px #0008;
}
.theme-dark .board .note .ops .teaser {
color: #ccc;
}
.theme-dark .board .note .ops .bulk {
background: #202022;
border-left: 1px solid #111;
border-bottom: 1px solid #111;
}
.theme-dark .board .note.raw .ops .bulk {
border: none;
}
.theme-dark .board .note .ops .bulk a {
color: #ccc;
}
.theme-dark .board .note .ops .bulk a:hover {
color: #fc2;
}
/***/
.theme-dark .board .note.dragging,
.theme-dark .board .note.dragging.raw {
background: #09090A;
outline: 1px solid #fff2;
}
.theme-dark .note-dragster {
box-shadow: 0px 1px 4px #000;
background: linear-gradient(#242426, #202022);
outline: 1px solid #fc28;
}
.theme-dark .note-dragster a {
color: #d6d6d6;
}
/***/
.theme-dark .wrap textarea,
.theme-dark .wrap input {
background: #111315;
color: #eee;
}
.theme-dark .board .head.editing .edit {
outline: 1px solid #bc9908;
}
.theme-dark .board .note.editing {
background: #111315;
outline: 1px solid #bc9908;
}
/***/
.theme-dark .logo a {
color: #ccc;
}
.theme-dark .logo,
.theme-dark.crowded .logo:hover {
background: #09090A !important;
}
.theme-dark .logo a:hover {
color: #fc2;
}
.theme-dark .logo > a:hover {
color: #fff;
}
/***/
.theme-dark .config,
.theme-dark.crowded .config:hover {
background: #09090A !important;
}
.theme-dark .config a {
color: #ddd;
}
.theme-dark .config a:hover {
color: #fc2;
}
.theme-dark .config .bulk .boards {
border-top: 1px solid #fff2;
border-bottom: 1px solid #fff2;
}
.theme-dark .config .bulk .boards a.load-board.active {
color: #fc2;
}
.theme-dark .config .bulk .boards a.load-board.active:hover {
color: #ddd;
}
.theme-dark .config .bulk .break {
border-top: 1px solid #fff2;
}
.theme-dark.fs-set .config .bulk .ui-prefs .f-prefs tr.ui-fs td.val,
.theme-dark.lh-set .config .bulk .ui-prefs .f-prefs tr.ui-lh td.val,
.theme-dark.lw-set .config .bulk .ui-prefs .f-prefs tr.ui-lw td.val {
color: #fc2;
}
.theme-dark .config a.switch-theme i {
display: inline;
}
.theme-dark .config a.switch-theme b {
display: none;
}
/***/
.theme-dark .config .bulk .boards .load-board.dragging,
.theme-dark .config .bulk .boards .load-board.active.dragging {
background: #fff2;
color: transparent;
}
.theme-dark .load-dragster {
background: #09090A;
box-shadow: 0px 2px 4px #000;
outline: 1px solid #fc2a;
color: #ddd;
}
</style>
</head>
<body>
<div class="logo">
<a class=site href=/>ValidBoard</a>
<i class=alert></i>
<i class=sync></i>
<div class=bulk>
<a href=# class=view-about>About</a>
<a href=# class=view-license>License</a>
<a href=https://nullboard.io/changes target=_blank class=view-changes>Changes</a>
<a href=https://nullboard.io target=_blank>Nullboard</a>
<a href=# class=sign-out>Sign out</a>
</div>
</div>
<!--
ValidBoard: the server drops the whole keyspace in here before the app
script runs, so Storage_Server can answer nullboard's synchronous
getItem() from memory without a blocking request. Embedded in the page
rather than served as a .js file so it can't be read cross-site.
-->
<script type="application/json" id="nb-bootstrap">{}</script>
<form class=sign-out-form method=post action=/logout hidden></form>
<div class='config no-user-select'>
<a href=# class=teaser><i>&equiv;</i><u>&#x2714;</u></a>
<div class=bulk>
<a href=# class=add-board>Add new board...</a>
<div class=boards draggable=false>
<!-- here'll be boards -->
</div>
<a href=# class=exp-board>Export current board...</a>
<a href=# class=imp-board>Import boards...</a>
<input class=imp-board-select type="file" accept=".nbx">
<a href=# class=auto-backup>Auto-backup...</a>
<div class="section ui-prefs break">
<a href=# class=title>UI preferences<u>...</u></a>
<div class=details>
<a href="#" class="switch-font active break" font="barlow">Use Barlow</a>
<a href="#" class="switch-font" font="ibm-plex">Use IBM Plex Sans</a>
<a href="#" class="switch-font" font="open-sans">Use Open Sans</a>
<a href="#" class="switch-font" font="segoe-ui">Use Segoe UI</a>
<a href="#" class="switch-font" font="maven-pro">Use Maven Pro</a>
<div class='f-prefs break'>
<table>
<tr class='ui-fs'><td class=name>Font size:</td> <td><a href=# class=less>&lt;</a></td><td class=val></td><td><a href=# class=more>&gt;</a></td></tr>
<tr class='ui-lh'><td class=name>Line height:</td><td><a href=# class=less>&lt;</a></td><td class=val></td><td><a href=# class=more>&gt;</a></td></tr>
<tr class='ui-lw'><td class=name>List width:</td> <td><a href=# class=less>&lt;</a></td><td class=val></td><td><a href=# class=more>&gt;</a></td></tr>
</table>
</div>
<a href="#" class="switch-theme break">Use <i>light</i><b>dark</b> theme</a>
</div>
</div>
</div>
</div>
</div>
<div class=wrap>
</div>
<div class=overlay>
</div>
<tt>
<!-- templates -->
<div class=board>
<div class=head>
<div class=text></div>
<input class=edit spellcheck=false placeholder='Name of the board'>
<div class=menu>
<a href=# class=teaser>&equiv;</a>
<div class=bulk>
<a href=# class='del-board warn'><u></u> Board</a>
<a href=# class='undo-board'>Undo</a>
<a href=# class='redo-board'>Redo</a>
<a href=# class='add-list'>+ List</a>
</div>
</div>
</div>
<div class=lists-scroller><div></div></div>
<div class=lists>
</div>
</div>
<div class=list>
<div class=head>
<div class=text></div>
<input class=edit spellcheck=false placeholder='Name of the list'>
<div class=menu>
<a href=# class=teaser>&equiv;</a>
<div class=bulk>
<a href=# class='del-list warn'><u></u> List</a>
<a href=# class='mov-list-l full'>&lt; Move</a>
<a href=# class='mov-list-l half'>&lt; Mo</a><a href=# class='mov-list-r half'>ve &gt;</a>
<a href=# class='mov-list-r full'>Move &gt;</a>
<a href=# class='add-note'>+ Note</a>
</div>
</div>
</div>
<div class=notes>
</div>
</div>
<div class=note>
<div class=ops>
<a href=# class=teaser></a>
<div class=bulk>
<a href=# class='del-note warn'>X</a>
<a href=# class='raw-note'>R</a>
<a href=# class='collapse'>_</a>
</div>
</div>
<div class=text> </div>
<textarea class=edit spellcheck=false></textarea>
</div>
<a href=# class=load-board></a>
<textarea class=encoder></textarea>
<div class=about>
<h1>Nullboard</h1>
Minimalist locally-stored kanban board
<a href=https://nullboard.io target=_blank>https://nullboard.io</a>
<div></div>
</div>
<div class=license>
</div>
<div class=backup-conf>
<a href=# class=close>&times;</a>
<h3>Auto-backup</h3>
<div class=what>
<a href=https://nullboard.io/backups target=_blank>What is this?</a>
</div>
<div class=loc>
<div class=opt>Local backup</div>
<div class=etc>
<span>Access token</span> <input class=auth placeholder="required"><br>
<p class=status><span>Status</span> <input disabled></p>
</div>
</div>
<div class=rem>
<div class=opt>Remote backup</div>
<div class=etc>
<span>URL</span> <input class=base placeholder="API entry point"><br>
<span>Access token</span> <input class=auth placeholder="required"><br>
<p class=status><span>Status</span> <input disabled></p>
</div>
</div>
<div class=save><a href=# class=check>Check...</a> <a href=# class=ok>Save</a></div>
</div>
</tt>
</body>
<script src="extras/jquery-3.6.0.min.js"></script>
<script>window.jQuery || document.write('<script src="https://code.jquery.com/jquery-3.6.0.min.js">\x3C/script>')</script>
<script type="text/javascript">
Number.prototype.clamp = function(min, max)
{
return Math.min(Math.max(this, min), max);
};
/*
* add a blank line to push 'Prevent this page from opening ...'
* tack-on from the actual message we are trying to display
*/
var confirm_org = window.confirm;
var alert_org = window.alert;
window.confirm = function(msg) { return confirm_org(msg + "\n "); }
window.alert = function(msg) { return alert_org (msg + "\n "); }
</script>
<script type="text/javascript">
function AppConfig()
{
this.verLast = null; // last used codeVersion
this.verSeen = null; // latest codeVersion they saw the changelog for
this.maxUndo = 50; // board revisions to keep
this.fontName = null; // font-family
this.fontSize = null; // font-size
this.lineHeight = null; // line-height
this.listWidth = null; // list-width
this.theme = null; // default or 'dark'
this.fileLinks = false; // mark up `foo` as <a href=file:///foo>...</a>
this.board = null; // active board
this.backups =
{
agents : [ ], // [ { type, id, enabled, conf } ];
nextId : 1
};
this.backupStatus = { }; // agentId => [ 'conf' ]
}
const default_backup_agents =
[
{ base: 'http://127.0.0.1:10001', auth: '' }, // local agent
{ base: '', auth: '' } // remote agent
];
//
function BoardMeta()
{
this.title = '';
this.current = 1; // revision
this.ui_spot = 0; // 0 = not set
this.history = [ ]; // revision IDs
this.backupStatus = { }; // agentId => [ what's backed up ]
}
class Storage
{
constructor()
{
this.type = '?';
this.conf = new AppConfig();
this.boardIndex = new Map();
this.backups =
{
status : '', // '', 'ok', 'busy', 'failed'
agents : [ ], // BackupStorage instances
};
}
open()
{
return this.openInner();
}
wipe()
{
return this.wipeInner();
}
getConfig()
{
return this.conf;
}
setVerLast()
{
if (this.conf.verLast == NB.codeVersion)
return true;
this.conf.verLast = NB.codeVersion;
return this.saveConfig();
}
setVerSeen(ver)
{
this.conf.verSeen = ver || NB.codeVersion;
return this.saveConfig();
}
setActiveBoard(board_id)
{
console.log('setActiveBoard [' + this.conf.board + '] -> [' + board_id + ']');
var meta = board_id ? this.boardIndex.get(board_id) : true;
if (! meta)
throw `Invalid board_id in setActiveBoard(... ${board_id})`;
if (this.conf.board == board_id)
return true;
this.conf.board = board_id;
return this.saveConfig();
}
setTheme(theme)
{
if (this.conf.theme == theme) return;
this.conf.theme = theme;
return this.saveConfig();
}
setFontName(fname)
{
if (this.conf.fontName == fname) return;
this.conf.fontName = fname;
return this.saveConfig();
}
setFontSize(fs)
{
if (this.conf.fontSize == fs) return;
this.conf.fontSize = fs;
return this.saveConfig();
}
setLineHeight(lh)
{
if (this.conf.lineHeight == lh) return;
this.conf.lineHeight = lh;
return this.saveConfig();
}
setListWidth(lw)
{
if (this.conf.listWidth == lw) return;
this.conf.listWidth = lw;
return this.saveConfig();
}
saveConfig()
{
this.backupConfig();
return this.setJson('config', this.conf);
}
//
getBoardIndex()
{
return this.boardIndex;
}
saveBoard(board)
{
/*
* 1. assign new revision (next unused)
* 2. trim all in-between revisions bypassed by undos if any
* 3. cap history as per config
*/
var meta = this.boardIndex.get(board.id);
var ok_data, ok_meta;
delete board.history; // remove temporarily
if (! meta)
{
board.revision = 1;
ok_data = this.setJson('board.' + board.id + '.' + board.revision, board);
meta = new BoardMeta();
meta.title = board.title || '(Untitled board)';
meta.current = board.revision;
meta.history = [ board.revision ];
this.boardIndex.set(board.id, meta);
}
else
{
var rev_old = board.revision;
var rev_new = meta.history[0] + 1;
board.revision = rev_new;
ok_data = this.setJson('board.' + board.id + '.' + board.revision, board);
meta.title = board.title || '(Untitled board)';
meta.current = board.revision;
// trim revisions skipped over with undo and cap the revision count
var rebuild = [ board.revision ];
for (var rev of meta.history)
{
if ( (rev_old < rev && rev < rev_new) || (rebuild.length >= this.conf.maxUndo) )
{
this.delItem('board.' + board.id + '.' + rev);
console.log( `Deleted revision ${rev} of ${board.id} (${board.title})` );
}
else
{
rebuild.push(rev);
}
}
meta.history = rebuild;
}
/*
* save meta
*/
ok_meta = this.setJson('board.' + board.id + '.meta', meta) &&
this.setJson('board.' + board.id, meta.current); // for older versions
/*
* run backups
*/
if (ok_meta && ok_data)
this.backupBoard(board.id, board, meta)
board.history = meta.history; // restore
console.log( `Saved revision ${board.revision} of ${board.id} (${board.title}), ok = ${ok_data} | ${ok_meta}` );
return ok_data && ok_meta;
}
loadBoard(board_id, revision)
{
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in loadBoard(${board_id}, ${revision})`;
if (revision == null)
revision = meta.current;
if (! meta.history.includes(revision))
throw `Invalid revision in loadBoard(${board_id}, ${revision})`;
var board = this.getJson('board.' + board_id + '.' + revision);
if (! board)
return false;
if (board.format != NB.blobVersion)
{
console.log('Board ' + board_id + '/' + revision + ' format is unsupported');
console.log('Have [' + board.format + '], need [' + NB.blobVersion);
return false;
}
if (board.revision != revision)
{
console.log('Board ' + board_id + '/' + revision + ' revision is wrong');
console.log('Have [' + board.revision + ']');
return false;
}
board.history = meta.history;
console.log( `Loaded revision ${board.revision} of ${board.id} (${board.title})` );
return Object.assign(new Board(), board);
}
nukeBoard(board_id)
{
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in nukeBoard(${board_id})`;
var title = meta.title + '';
for (var rev of meta.history)
this.delItem('board.' + board_id + '.' + rev);
this.delItem('board.' + board_id + '.meta');
this.boardIndex.delete(board_id);
this.backups.agents.forEach(function(store){
store.nukeBoard(board_id);
});
console.log( `Deleted board ${board_id} (${title})` );
}
getBoardHistory(board_id)
{
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in getBoardHistory(${board_id})`;
return meta.history;
}
setBoardRevision(board_id, revision)
{
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in setBoardRevision(${board_id}, ${revision})`;
if (! meta.history.includes(revision))
throw `Invalid revision in setBoardRevision(${board_id}, ${revision})`;
if (meta.current == revision) // wth
return true;
meta.current = revision;
this.backupBoard(board_id, null, meta);
return this.setJson('board.' + board_id + '.meta', meta) &&
this.setJson('board.' + board_id, revision); // for older versions
}
setBoardUiSpot(board_id, ui_spot)
{
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in setBoardUiSpot(${board_id}, ${ui_spot})`;
meta.ui_spot = ui_spot;
this.backupBoard(board_id, null, meta);
return this.setJson('board.' + board_id + '.meta', meta);
}
/*
* private
*/
getItem(name) { throw 'implement-me'; }
setItem(name) { throw 'implement-me'; }
delItem(name) { throw 'implement-me'; }
openInner() { throw 'implement-me'; }
wipeInner() { throw 'implement-me'; }
getJson(name)
{
var foo = this.getItem(name);
if (! foo) return false;
try { foo = JSON.parse(foo); } catch (x) { return false; }
return foo;
}
setJson(name, val)
{
if (! this.setItem(name, JSON.stringify(val)))
{
console.log("setJson(" + name + ") failed");
return false;
}
return true;
}
/*
* config
*/
fixupConfig(newInstall)
{
var conf = this.conf;
var simp = (new SimpleBackup).type;
if (conf.board && ! this.boardIndex.has(conf.board))
conf.board = null;
if (! conf && ! newInstall) // pre-20210410 upgrade
{
conf.verLast = 20210327;
conf.verSeen = 20200220; // 20200429;
}
var agents = conf.backups.agents;
if (agents.length != 2 ||
agents[0].type != simp || agents[0].conf.base != 'http://127.0.0.1:10001' ||
agents[1].type != simp)
{
const def = default_backup_agents;
console.log('Unexpected backup config, will re-initialize.', agents);
conf.backups.agents = [];
conf.backups.agents.push({
// localhost
type : simp,
id : simp + '-' + (conf.backups.nextId++),
enabled : def[0].base && def[0].auth,
conf : def[0],
})
conf.backups.agents.push({
// remote
type : simp,
id : simp + '-' + (conf.backups.nextId++),
enabled : def[1].base && def[1].auth,
conf : def[1],
})
this.saveConfig();
}
}
/*
* backups
*/
initBackups(onBackupStatus)
{
var self = this;
var pending = 0;
var success = true;
var store_id = 1;
self.backups.agents = [];
onBackupStatus(null);
this.conf.backups.agents.forEach(function(b){
var T = NB.backupTypes.get(b.type);
if (! T)
{
console.log( `Unknown backup type "${b.type}" - skipped` );
return;
}
if (! b.enabled)
return;
var agent = new T(b.id, b.conf, onBackupStatus);
self.backups.agents.push(agent);
console.log( `Added backup agent - type '${agent.type}', id '${agent.id}'` );
agent.checkStatus(null); // will need just onBackupStatus() callbacks
});
}
backupBoard(board_id, board, meta)
{
var self = this;
var was = meta.backupStatus || {};
meta.backupStatus = {};
if (! this.backups.agents.length)
{
if (was['data'] || was['meta'])
self.setJson('board.' + board_id + '.meta', meta);
return;
}
console.log( `Backing up ${board_id}...` );
this.backups.agents.forEach(function(agent){
var fields = was[agent.id] || {};
if (board) delete fields.data;
if (meta) delete fields.meta;
meta.backupStatus[agent.id] = fields;
agent.saveBoard(board_id, board, meta, function(ok){
var what = 'Backup of ' + board_id + (board ? '' : ' (meta)');
console.log( `${what} to '${agent.id}' -> ${ok ? 'ok' : 'failed'}` );
if (ok)
{
if (board) fields.data = + new Date();
if (meta) fields.meta = + new Date();
meta.backupStatus[agent.id] = fields;
}
self.setJson('board.' + board_id + '.meta', meta);
});
});
}
backupConfig()
{
var self = this;
var was = self.conf.backupStatus || {};
self.conf.backupStatus = {};
if (! this.backups.agents.length)
{
if (was['conf'])
this.setJson('config', this.conf);
return;
}
this.backups.agents.forEach(function(agent){
var fields = { };
self.conf.backupStatus[agent.id] = fields;
agent.saveConfig(self.conf, function(ok){
if (ok)
{
fields.conf = + new Date()
self.conf.backupStatus[agent.id] = fields;
}
self.setJson('config', self.conf);
});
});
}
};
class Storage_Local extends Storage
{
constructor()
{
super();
this.type = 'LocalStorage';
}
getItem(name)
{
return localStorage.getItem('nullboard.' + name);
}
setItem(name, val)
{
localStorage.setItem('nullboard.' + name, val);
return true;
}
delItem(name)
{
localStorage.removeItem('nullboard.' + name);
return true;
}
openInner()
{
var conf = this.getJson('config');
var newInstall = true;
// if (conf && (conf.format != NB.confVersion))
// {
// if (! confirm('Preferences are stored in an unsupported format. Reset them?'))
// return false;
//
// conf = null;
// }
if (conf)
{
this.conf = Object.assign(new AppConfig(), conf);
}
else
{
this.conf.theme = this.getItem('theme');
if (this.getItem('fsize') == 'z1')
{
this.conf.fontSize = 13;
this.conf.lineHeight = 17;
}
if (! this.setJson('config', this.conf))
{
this.conf = null;
return false;
}
this.conf.board = this.getItem('last_board');
}
this.boardIndex = new Map();
// new format
for (var i=0; i<localStorage.length; i++)
{
var k = localStorage.key(i);
var m = k.match(/^nullboard\.board\.(\d+).meta$/);
if (! m)
continue;
var board_id = parseInt(m[1]);
var meta = this.getJson('board.' + board_id + '.meta');
if (! meta.hasOwnProperty('history'))
{
console.log( `Invalid meta for board ${board_id}` );
continue;
}
for (var rev of meta.history)
if (! this.getJson('board.' + board_id + '.' + rev))
{
console.log( `Invalid revision ${rev} in history of ${board_id}` );
meta = this.rebuildMeta(board_id);
break;
}
if (! meta)
continue;
delete meta.backingUp; // run-time var
delete meta.needsBackup; // ditto
meta = Object.assign(new BoardMeta(), meta);
this.boardIndex.set(board_id, meta);
}
// old format
for (var i=0; i<localStorage.length; i++)
{
var k = localStorage.key(i);
var m = k.match(/^nullboard\.board\.(\d+)$/);
if (! m)
continue;
newInstall = false;
var board_id = parseInt(m[1]);
if (this.boardIndex.has(board_id))
continue;
var meta = this.rebuildMeta(board_id);
if (! meta)
continue;
meta = Object.assign(new BoardMeta(), meta);
this.boardIndex.set(board_id, meta);
}
this.fixupConfig(newInstall);
this.type = 'LocalStorage';
return true;
}
wipeInner()
{
for (var i=0; i<localStorage.length; )
{
var k = localStorage.key(i);
var m = k.match(/^nullboard\./);
if (m) localStorage.removeItem(k);
else i++;
}
this.conf = new AppConfig();
this.boardIndex = new Map();
}
/*
* private
*/
rebuildMeta(board_id)
{
var meta = new BoardMeta();
console.log( `Rebuilding meta for ${board_id} ...` );
// get current revision
meta.current = this.getItem('board.' + board_id); // may be null
// load history
var re = new RegExp('^nullboard\.board\.' + board_id + '\.(\\d+)$');
var revs = new Array();
for (var i=0; i<localStorage.length; i++)
{
var m = localStorage.key(i).match(re);
if (m) revs.push( parseInt(m[1]) );
}
if (! revs.length)
{
console.log('* No revisions found');
this.delItem('board.' + board_id);
return false;
}
revs.sort(function(a,b){ return b-a; });
meta.history = revs;
// validate current revision
if (! meta.history.includes(meta.current))
meta.current = meta.history[meta.history.length-1];
// get board title
var board = this.getJson('board.' + board_id + '.' + meta.current)
meta.title = (board.title || '(untitled board)');
this.setJson('board.' + board_id + '.meta', meta);
return meta;
}
}
/*
* ValidBoard: boards live in SQLite on the server.
*
* The Storage contract above is synchronous — setItem() has to say yes or
* no there and then — and a round trip can't answer that fast. So this
* keeps the whole keyspace in memory and treats the server as somewhere to
* push it: reads are memory reads, writes are optimistic and batched.
*
* The initial copy is embedded in the page by the server (see #nb-bootstrap
* in the body), so it is already here by the time openInner() runs and no
* blocking request is needed at load.
*
* Keys are the same names Storage_Local uses, minus its 'nullboard.'
* prefix — the server's table is ours alone and needs no namespacing.
*/
class Storage_Server extends Storage
{
constructor()
{
super();
this.type = 'Server';
this.items = new Map(); // key -> value; the copy the app reads
this.pending = new Map(); // key -> op, queued for the next batch
this.inflight = null; // the batch currently being POSTed
this.timer = null;
this.failures = 0;
this.status = 'ok'; // 'ok' | 'busy' | 'error'
this.warned = false; // "session expired" alert is once-only
}
/*
* Storage interface
*/
getItem(name)
{
var val = this.items.get(name);
return (val === undefined) ? null : val;
}
setItem(name, val)
{
this.items.set(name, val);
this.enqueue(name, { op: 'set', key: name, value: val });
return true;
}
delItem(name)
{
this.items.delete(name);
this.enqueue(name, { op: 'del', key: name });
return true;
}
openInner()
{
if (! this.hydrate())
return false;
// Decide this before saving a default config, or the write below
// would make every new install look like an existing one.
var newInstall = (this.items.size == 0);
var conf = this.getJson('config');
if (conf)
{
this.conf = Object.assign(new AppConfig(), conf);
}
else if (! this.setJson('config', this.conf))
{
this.conf = null;
return false;
}
this.boardIndex = new Map();
// Array.from() because rebuildMeta() may write while we iterate.
for (var k of Array.from(this.items.keys()))
{
var m = k.match(/^board\.(\d+)\.meta$/);
if (! m)
continue;
var board_id = parseInt(m[1]);
var meta = this.getJson('board.' + board_id + '.meta');
if (! meta || ! meta.hasOwnProperty('history'))
{
console.log( `Invalid meta for board ${board_id}` );
continue;
}
for (var rev of meta.history)
if (! this.getJson('board.' + board_id + '.' + rev))
{
console.log( `Invalid revision ${rev} in history of ${board_id}` );
meta = this.rebuildMeta(board_id);
break;
}
if (! meta)
continue;
delete meta.backingUp; // run-time var
delete meta.needsBackup; // ditto
this.boardIndex.set(board_id, Object.assign(new BoardMeta(), meta));
}
this.fixupConfig(newInstall);
this.type = 'Server';
return true;
}
wipeInner()
{
this.items.clear();
this.pending.clear();
this.inflight = null;
var self = this;
fetch('/api/items', { method: 'DELETE', credentials: 'same-origin' })
.then(function(rsp){ if (! rsp.ok) throw new Error('server said ' + rsp.status); })
.catch(function(err){ self.setStatus('error', 'wipe failed: ' + err.message); });
this.conf = new AppConfig();
this.boardIndex = new Map();
}
/*
* private
*/
hydrate()
{
var el = document.getElementById('nb-bootstrap');
if (! el)
{
alert("ValidBoard: this page is missing its board data.\n\nReload to try again.");
return false;
}
try
{
var data = JSON.parse(el.textContent);
for (var k in data)
this.items.set(k, data[k]);
}
catch (x)
{
alert("ValidBoard: couldn't read the boards sent by the server.\n\n" + x);
return false;
}
console.log( `Loaded ${this.items.size} key(s) from the server` );
// Nothing else needs it, and leaving a copy of every board sitting
// in the DOM only invites confusion when debugging.
el.textContent = '{}';
return true;
}
// Same recovery as Storage_Local: a board whose meta has gone stale is
// worth rebuilding from whatever revisions did survive.
rebuildMeta(board_id)
{
var meta = new BoardMeta();
console.log( `Rebuilding meta for ${board_id} ...` );
meta.current = this.getItem('board.' + board_id); // may be null
var re = new RegExp('^board\\.' + board_id + '\\.(\\d+)$');
var revs = new Array();
for (var k of this.items.keys())
{
var m = k.match(re);
if (m) revs.push( parseInt(m[1]) );
}
if (! revs.length)
{
console.log('* No revisions found');
this.delItem('board.' + board_id);
return false;
}
revs.sort(function(a,b){ return b-a; });
meta.history = revs;
if (! meta.history.includes(meta.current))
meta.current = meta.history[meta.history.length-1];
var board = this.getJson('board.' + board_id + '.' + meta.current)
meta.title = (board.title || '(untitled board)');
this.setJson('board.' + board_id + '.meta', meta);
return meta;
}
/*
* write-behind
*
* Changes are collected by key — a note edited five times in a second
* is one write — and flushed as a single transaction shortly after.
*/
enqueue(key, op)
{
this.pending.set(key, op);
this.scheduleFlush();
}
// No argument: batch up whatever lands in the next FLUSH_DELAY ms, but
// don't let a steady stream of edits push the flush back forever.
// With one: a retry, which overrides any sooner flush already queued.
scheduleFlush(delay)
{
if (delay === undefined)
{
if (this.timer !== null)
return;
delay = Storage_Server.FLUSH_DELAY;
}
else if (this.timer !== null)
{
clearTimeout(this.timer);
}
var self = this;
this.timer = setTimeout(function(){
self.timer = null;
self.flush();
}, delay);
}
flush()
{
if (this.inflight || ! this.pending.size)
return;
this.inflight = this.pending;
this.pending = new Map();
var self = this;
var ops = Array.from(this.inflight.values());
this.setStatus('busy');
fetch('/api/items', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials : 'same-origin',
body : JSON.stringify({ ops: ops })
})
.then(function(rsp){
if (rsp.status == 401)
{
self.onSessionLost();
throw new Error('not signed in');
}
if (! rsp.ok)
throw new Error('server said ' + rsp.status);
self.inflight = null;
self.failures = 0;
if (self.pending.size)
{
self.setStatus('busy');
self.scheduleFlush();
}
else
{
self.setStatus('ok');
}
})
.catch(function(err){
// The batch goes back on the queue, so nothing is lost while
// the server is unreachable — it just keeps trying.
self.requeue();
self.failures++;
self.setStatus('error', err.message);
var delay = Math.min(30000, 1000 * Math.pow(2, self.failures - 1));
console.log( `Save failed (${err.message}), retrying in ${delay}ms` );
self.scheduleFlush(delay);
});
}
requeue()
{
if (! this.inflight)
return;
var merged = this.inflight;
// Anything queued while the batch was in flight is newer, so it
// wins over the copy going back on the queue.
this.pending.forEach(function(op, key){ merged.set(key, op); });
this.pending = merged;
this.inflight = null;
}
// The cookie expired, or you signed out in another tab. Keep retrying
// rather than dropping the edits — signing back in anywhere makes the
// next attempt succeed.
onSessionLost()
{
if (this.warned)
return;
this.warned = true;
alert("ValidBoard: your session has expired, so recent changes aren't saved yet.\n\n" +
"Sign in again in another tab and they'll be saved automatically.");
}
hasUnsavedChanges()
{
return (this.pending.size > 0) || (this.inflight !== null);
}
setStatus(status, detail)
{
this.status = status;
var $logo = $('.logo').removeClass('sync-busy sync-err');
var $sync = $('.logo .sync').attr('title', detail || '');
if (status == 'busy')
{
$logo.addClass('sync-busy');
$sync.text('saving...');
}
else if (status == 'error')
{
$logo.addClass('sync-err');
$sync.text('not saved');
}
else
{
$sync.text('');
}
}
}
Storage_Server.FLUSH_DELAY = 300; // ms
/*
*
*/
class BackupStorage
{
constructor(id, conf, onStatusChange)
{
this.type = '?';
this.id = id;
this.conf = conf;
this.status = '';
this.lastOp = '';
this.lastXhr = { op: '', text: '', code: 0 };
this.onStatusChange = onStatusChange;
this.queue = [];
}
checkStatus(cb) { return false; }
saveConfig(conf, cb) { throw 'implement-me'; }
saveBoard (id, data, meta, cb) { throw 'implement-me'; }
nukeBoard (id, cb) { throw 'implement-me'; }
}
class SimpleBackup extends BackupStorage
{
constructor(id, conf, onStatusChange)
{
super(id, null, onStatusChange);
this.type = 'simp';
this.conf = { base: '', auth: '' }
this.conf = Object.assign(this.conf, conf);
}
checkStatus(cb)
{
this.queue.push({
what : 'checkStatus',
cb : cb,
args :
{
url: this.conf.base + '/config',
type: 'put',
headers: { 'X-Access-Token': this.conf.auth },
data:
{
self: document.location.href,
// conf: -- without the data --
},
dataType: 'json'
}
});
this.runQueue();
}
saveConfig(conf, cb)
{
this.queue.push({
what : 'saveConfig',
cb : cb,
args :
{
url: this.conf.base + '/config',
type: 'put',
headers: { 'X-Access-Token': this.conf.auth },
data:
{
self: document.location.href,
conf: JSON.stringify(conf)
},
dataType: 'json'
}
});
this.runQueue();
}
saveBoard(id, data, meta, cb)
{
this.queue.push({
what : 'saveBoard',
cb : cb,
args :
{
url: this.conf.base + '/board/' + id,
type: 'put',
headers: { 'X-Access-Token': this.conf.auth },
data:
{
self: document.location.href,
data: data ? JSON.stringify(data) : null,
meta: meta ? JSON.stringify(meta) : null
},
dataType: 'json'
}
});
this.runQueue();
}
nukeBoard(id, cb)
{
this.queue.push({
what : 'saveBoard',
cb : cb,
args :
{
url: this.conf.base + '/board/' + id,
type: 'delete',
headers: { 'X-Access-Token': this.conf.auth },
}
});
this.runQueue();
}
/*
* private
*/
runQueue()
{
var self = this;
if (! this.queue.length)
return;
if (this.status == 'busy')
return;
var req = this.queue.shift();
this.setStatus('busy', req.what);
$.ajax(req.args)
.done(function(d, s, x) { self.onRequestDone(req, true, x); })
.fail(function(x, s, e) { self.onRequestDone(req, false, x); })
}
onRequestDone(req, ok, xhr)
{
console.log( `Backup agent '${this.id}', ${this.lastOp}() -> ${ok ? 'ok' : 'failed'}` );
var code = xhr.status;
var text = xhr.responseText || (code ? `Response code ${code}` : 'Offline or CORS-blocked');
this.lastXhr = { text: text, code: code };
if (req.cb) req.cb.call(this, ok);
if (! this.queue.length)
{
this.setStatus(ok ? 'ready' : 'error', this.lastOp);
return;
}
this.status = 'pre-busy';
this.runQueue();
}
setStatus(status, op)
{
if (status == 'busy' && this.status == 'busy')
throw `Backup agent ${this.id} is already busy!`;
console.log( `Backup agent '${this.id}' status: '${this.status}' -> '${status}'` );
this.status = status;
this.lastOp = op;
this.onStatusChange(this);
}
}
</script>
<script type="text/javascript">
function Note(text)
{
this.text = text;
this.raw = false;
this.min = false;
}
function List(title)
{
this.title = title;
this.notes = [ ];
this.addNote = function(text)
{
var x = new Note(text);
this.notes.push(x);
return x;
}
}
function Board(title)
{
this.format = NB.blobVersion;
this.id = +new Date();
this.revision = 0;
this.title = title || '';
this.lists = [ ];
this.addList = function(title)
{
var x = new List(title);
this.lists.push(x);
return x;
}
}
</script>
<script type="text/javascript">
function Drag2()
{
// config
this.listSel = null;
this.itemSel = null;
this.dragster = null;
this.onDragging = function(started) { }
this.swapAnimMs = 200;
// state
this.item = null;
this.priming = null;
this.primeXY = { x: 0, y: 0 };
this.$drag = null;
this.mouseEv = null;
this.delta = { x: 0, y: 0 };
this.inSwap = 0;
// api
this.prime = function(item, ev)
{
var self = this;
this.item = item;
this.priming = setTimeout(function(){ self.onPrimed.call(self); }, ev.altKey ? 1 : 500);
this.primeXY = { x: ev.clientX, y: ev.clientY };
this.mouseEv = ev;
}
this.cancelPriming = function()
{
if (! this.item || ! this.priming)
return;
clearTimeout(this.priming);
this.priming = null;
this.item = null;
}
this.end = function()
{
this.cancelPriming();
this.stopDragging();
}
this.isActive = function()
{
return this.item && (this.priming == null);
}
this.onPrimed = function()
{
clearTimeout(this.priming);
this.priming = null;
removeTextSelection();
var $item = $(this.item);
$item.addClass('dragging');
$('body').append('<div class=' + this.dragster + '></div>');
var $drag = $('body .' + this.dragster).last();
$drag.outerWidth ( $item.outerWidth() );
$drag.outerHeight( $item.outerHeight() );
this.$drag = $drag;
if (this.onDragging)
this.onDragging.call(this, true); // started
var $win = $(window);
var scroll_x = $win.scrollLeft();
var scroll_y = $win.scrollTop();
var pos = $item.offset();
this.delta.x = pos.left - this.mouseEv.clientX - scroll_x;
this.delta.y = pos.top - this.mouseEv.clientY - scroll_y;
this.adjustDrag();
$drag.css({ opacity: 1 });
$('body').addClass('dragging');
}
this.adjustDrag = function()
{
if (! this.$drag)
return;
var drag = this;
var $drag = this.$drag;
var $win = $(window);
var scroll_x = $win.scrollLeft();
var scroll_y = $win.scrollTop();
var drag_x = drag.mouseEv.clientX + drag.delta.x + scroll_x;
var drag_y = drag.mouseEv.clientY + drag.delta.y + scroll_y;
$drag.offset({ left: drag_x, top: drag_y });
if (drag.inSwap)
return;
/*
* see if a swap is in order
*/
var pos = $drag.offset();
var x = pos.left + $drag.width()/2 - $win.scrollLeft();
var y = pos.top + $drag.height()/2 - $win.scrollTop();
var targetList = null;
var targetItem = null; // if over some item
var before = false; // if should go before targetItem
var $target;
$(this.listSel).each(function(){
var list = this;
var rcList = list.getBoundingClientRect();
var yTop, itemTop = null;
var yBottom, itemBottom = null;
if (x <= rcList.left || rcList.right <= x)
return;
$(list).find(drag.itemSel).each(function(){
var rcItem = this.getBoundingClientRect();
if (! itemTop || rcItem.top < yTop)
{
itemTop = this;
yTop = rcItem.top;
}
if (! itemBottom || yBottom < rcItem.bottom)
{
itemBottom = this;
yBottom = rcItem.bottom;
}
if (y <= rcItem.top || rcItem.bottom <= y)
return;
if (this == drag.item)
return;
targetList = list;
targetItem = this;
before = (y < (rcItem.top + rcItem.bottom)/2);
});
if (y < rcList.top)
{
targetList = list;
targetItem = itemTop;
before = true;
}
else
if (y >= rcList.bottom)
{
targetList = list;
targetItem = itemBottom;
before = false;
}
});
if (! targetList)
return;
if (targetItem)
{
if (targetItem == drag.item)
return;
$target = $(targetItem);
if (! before && $target.next()[0] == drag.item ||
before && $target.prev()[0] == drag.item)
return;
}
/*
* swap 'em
*/
var have = drag.item;
var $have = $(have);
var $want = $have.clone();
$want.css({ display: 'none' });
if (targetItem)
{
if (before)
{
$want.insertBefore($target);
$want = $target.prev();
}
else
{
$want.insertAfter($target);
$want = $target.next();
}
}
else
{
var $list = $(targetList);
$want = $list.append($want).find(drag.itemSel)
}
drag.item = $want[0];
if (! drag.swapAnimMs)
{
$have.remove();
$want.show();
return;
}
/*
* see if it's a same-list move
*/
if (targetList == have.parentNode)
{
var delta = $have.offset().top - $target.offset().top;
var d_bulk = 0;
var d_have = 0;
var $bulk = $();
if (delta < 0) // item is moving down
{
for (var $i = $have.next(); $i.length && $i[0] != $want[0]; $i = $i.next())
$bulk = $bulk.add($i);
}
else
{
for (var $i = $want.next(); $i.length && $i[0] != $have[0]; $i = $i.next())
$bulk = $bulk.add($i);
}
d_bulk = $have.outerHeight(true);
d_have = $bulk.last().offset().top + $bulk.last().outerHeight(true) - $bulk.first().offset().top;
if (delta < 0) d_bulk = -d_bulk;
else d_have = -d_have;
$have.parent().css({ position: 'relative' });
$have.css({ position: 'relative', 'z-index': 0 });
$bulk.css({ position: 'relative', 'z-index': 1 });
drag.inSwap = 1 + $bulk.length;
$have.animate({ top: d_have }, drag.swapAnimMs, function(){ if (! --drag.inSwap) swapCleanUp(); });
$bulk.animate({ top: d_bulk }, drag.swapAnimMs, function(){ if (! --drag.inSwap) swapCleanUp(); });
function swapCleanUp()
{
$have.parent().css({ position: '' });
$have.remove();
$want.show();
$bulk.css({ position: '', 'z-index': '', top: '' });
drag.adjustDrag();
}
}
else
{
drag.inSwap = 1;
$want.slideDown(drag.swapAnimMs);
$have.slideUp(drag.swapAnimMs, function() {
$have.remove();
drag.inSwap = 0;
drag.adjustDrag();
});
}
}
this.onMouseMove = function(ev)
{
this.mouseEv = ev;
if (! this.item)
return;
if (this.priming)
{
var x = ev.clientX - this.primeXY.x;
var y = ev.clientY - this.primeXY.y;
if (x*x + y*y > 5*5)
this.onPrimed();
}
else
{
this.adjustDrag();
}
}
this.stopDragging = function()
{
var $item = $(this.item);
$item.removeClass('dragging');
$('body').removeClass('dragging');
if (this.$drag)
{
this.$drag.remove();
this.$drag = null;
removeTextSelection();
if (this.onDragging)
this.onDragging.call(this, false); // stopped
}
this.item = null;
}
}
</script>
<script type="text/javascript">
function VarAdjust()
{
// state
this.onChange = null;
this.onFinish = null;
this.startY = 0;
this.used = false;
// api
this.start = function(ev, onChange, onFinish)
{
if (! onChange)
return;
this.onChange = onChange;
this.onFinish = onFinish;
this.startY = ev.clientY;
this.used = false;
var self = this;
setTimeout(function(){
if (! self.onChange)
return;
$('body').addClass('adjusting');
self.used = true;
}, 250);
}
this.onMouseMove = function(ev)
{
if (! this.onChange)
return;
$('body').addClass('adjusting');
self.used = true;
this.onChange(ev.clientY - this.startY);
}
this.end = function()
{
if (! this.onChange)
return;
$('body').removeClass('adjusting');
this.onChange = null;
if (this.onFinish) this.onFinish();
}
}
</script>
<script type="text/javascript">
/*
* poor man's error handling -- $fixme
*/
var easyMartina = false;
window.onerror = function(message, file, line, col, e){
var cb1;
if (! easyMartina) alert("Error occurred: " + e.message);
return false;
};
window.addEventListener("error", function(e) {
var cb2;
if (! easyMartina) alert("Error occurred: " + e.error.message);
return false;
});
/*
* notes / lists / boards
*/
function addNote($list, $after, $before)
{
var $note = $('tt .note').clone();
var $notes = $list.find('.notes');
$note.find('.text').html('');
$note.addClass('brand-new');
if ($before && $before.length)
{
$before.before($note);
$note = $before.prev();
}
else
if ($after && $after.length)
{
$after.after($note);
$note = $after.next();
}
else
{
$notes.append($note);
$note = $notes.find('.note').last();
}
$note.find('.text').click();
}
function deleteNote($note)
{
$note
.animate({ opacity: 0 }, 'fast')
.slideUp('fast')
.queue(function(){
$note.remove();
saveBoard();
});
}
function noteLocation($item)
{
var loc = 0;
for (var $p = $item.closest('.note'); $p.length; $p = $p.prev(), loc += 1);
for (var $p = $item.closest('.list'); $p.length; $p = $p.prev(), loc += 10000);
return loc;
}
//
function addList()
{
var $board = $('.wrap .board');
var $lists = $board.find('.lists');
var $list = $('tt .list').clone();
$list.find('.text').html('');
$list.find('.head').addClass('brand-new');
$lists.append($list);
$board.find('.lists .list .head .text').last().click();
var lists = $lists[0];
lists.scrollLeft = Math.max(0, lists.scrollWidth - lists.clientWidth);
setupListScrolling();
}
function deleteList($list)
{
var empty = true;
$list.find('.note .text').each(function(){
empty &= ($(this).html().length == 0);
});
if (! empty && ! confirm("Delete this list and all its notes?"))
return;
$list
.animate({ opacity: 0 })
.queue(function(){
$list.remove();
saveBoard();
});
setupListScrolling();
}
function moveList($list, left)
{
var $a = $list;
var $b = left ? $a.prev() : $a.next();
var $menu_a = $a.find('> .head .menu .bulk');
var $menu_b = $b.find('> .head .menu .bulk');
var pos_a = $a.offset().left;
var pos_b = $b.offset().left;
$a.css({ position: 'relative' });
$b.css({ position: 'relative' });
$menu_a.hide();
$menu_b.hide();
$a.animate({ left: (pos_b - pos_a) + 'px' }, 'fast');
$b.animate({ left: (pos_a - pos_b) + 'px' }, 'fast', function(){
if (left) $list.prev().before($list);
else $list.before($list.next());
$a.css({ position: '', left: '' });
$b.css({ position: '', left: '' });
$menu_a.css({ display: '' });
$menu_b.css({ display: '' });
saveBoard();
});
}
//
function openBoard(board_id)
{
closeBoard(true);
NB.board = NB.storage.loadBoard(board_id, null);
NB.storage.setActiveBoard(board_id);
showBoard(true);
}
function reopenBoard(revision)
{
var board_id = NB.board.id;
var via_menu = $('.wrap .board > .head .menu .bulk').is(':visible');
NB.storage.setBoardRevision(board_id, revision);
openBoard(board_id);
if (via_menu)
{
var $menu = $('.wrap .board > .head .menu');
var $teaser = $menu.find('.teaser');
var $bulk = $menu.find('.bulk');
$teaser.hide().delay(100).queue(function(){ $(this).css('display', '').dequeue(); });
$bulk.show().delay(100).queue(function(){ $(this).css('display', '').dequeue(); });
}
}
function closeBoard(quick)
{
if (! NB.board)
return;
var $board = $('.wrap .board');
if (quick)
$board.remove();
else
$board
.animate({ opacity: 0 }, 'fast')
.queue(function(){ $board.remove(); });
NB.board = null;
NB.storage.setActiveBoard(null);
// updateUndoRedo();
updateBoardIndex();
updatePageTitle();
}
//
function addBoard()
{
closeBoard(true);
NB.board = new Board();
showBoard(true);
$('.wrap .board .head').addClass('brand-new');
$('.wrap .board .head .text').click();
}
function saveBoard()
{
var $board = $('.wrap .board');
var board = Object.assign(new Board(), NB.board); // id, revision & title
board.lists = [];
$board.find('.list').each(function(){
var $list = $(this);
var l = board.addList( getText($list.find('.head .text')) );
$list.find('.note').each(function(){
var $note = $(this)
var n = l.addNote( getText($note.find('.text')) );
n.raw = $note.hasClass('raw');
n.min = $note.hasClass('collapsed');
});
});
NB.storage.saveBoard(board);
NB.board = board;
updateUndoRedo();
updateBoardIndex();
}
function deleteBoard()
{
var $list = $('.wrap .board .list');
var board_id = NB.board.id;
if ($list.length && ! confirm("PERMANENTLY delete this board, all its lists and their notes?"))
return;
closeBoard();
NB.storage.nukeBoard(board_id);
updateBoardIndex();
}
//
function undoBoard()
{
if (! NB.board)
return false;
var hist = NB.storage.getBoardHistory(NB.board.id);
var have = NB.board.revision;
var want = 0;
for (var i=0; i<hist.length-1 && ! want; i++)
if (have == hist[i])
want = hist[i+1];
if (! want)
{
console.log('Undo - failed');
return false;
}
console.log('Undo -> ' + want);
reopenBoard(want);
return true;
}
function redoBoard()
{
if (! NB.board)
return false;
var hist = NB.storage.getBoardHistory(NB.board.id);
var have = NB.board.revision;
var want = 0;
for (var i=1; i<hist.length && ! want; i++)
if (have == hist[i])
want = hist[i-1];
if (! want)
{
console.log('Redo - failed');
return false;
}
console.log('Redo -> ' + want);
reopenBoard(want);
return true;
}
//
function showBoard(quick)
{
var board = NB.board;
var $wrap = $('.wrap');
var $bdiv = $('tt .board');
var $ldiv = $('tt .list');
var $ndiv = $('tt .note');
var $b = $bdiv.clone();
var $b_lists = $b.find('.lists');
$b[0].board_id = board.id;
setText( $b.find('.head .text'), board.title );
board.lists.forEach(function(list){
var $l = $ldiv.clone();
var $l_notes = $l.find('.notes');
setText( $l.find('.head .text'), list.title );
list.notes.forEach(function(n){
var $n = $ndiv.clone();
setText( $n.find('.text'), n.text );
if (n.raw) $n.addClass('raw');
if (n.min) $n.addClass('collapsed');
$l_notes.append($n);
});
$b_lists.append($l);
});
if (quick)
$wrap.html('').append($b);
else
$wrap.html('')
.css({ opacity: 0 })
.append($b)
.animate({ opacity: 1 });
updatePageTitle();
updateUndoRedo();
updateBoardIndex();
setupListScrolling();
}
/*
* demo board
*/
function createDemoBoard()
{
var blob =
'{"format":20190412,"id":1555071015420,"revision":581,"title":"Welcome to Nullboard","lists":[{"title":"The Use' +
'r Manual","notes":[{"text":"This is a note.\\nA column of notes is a list.\\nA set of lists is a board.","raw"' +
':false,"min":false},{"text":"All data is saved locally.\\nThe whole thing works completely offline.","raw":fal' +
'se,"min":false},{"text":"Last 50 board revisions are retained.","raw":false,"min":false},{"text":"Ctrl-Z is Un' +
'do - goes one revision back.\\nCtrl-Y is Redo - goes one revision forward.","raw":false,"min":false},{"tex' +
't":"Caveats","raw":true,"min":false},{"text":"Desktop-oriented.\\nMobile support is basically untested.","raw"' +
':false,"min":false},{"text":"Works in Firefox, Chrome is supported.\\nShould work in Safari, may work in Edge.' +
'","raw":false,"min":false},{"text":"Still very much in beta. Caveat emptor.","raw":false,"min":false},{"text":' +
'"Issues and suggestions","raw":true,"min":false},{"text":"Post them on Github.\\nSee \\"Nullboard\\" at the to' +
'p left for the link.","raw":false,"min":false}]},{"title":"Things to try","notes":[{"text":"\u2022 Click on ' +
'a note to edit.","raw":false,"min":false},{"text":"\u2022 Click outside of it when done editing.\\n\u2022 ' +
'Alternatively, use Shift-Enter.","raw":false,"min":false},{"text":"\u2022 To discard changes press Escape.",' +
'"raw":false,"min":false},{"text":"\u2022 Try Ctrl-Enter, see what it does.\\n\u2022 Try Ctrl-Shift-Enter t' +
'oo.","raw":false,"min":false},{"text":"\u2022 Hover over a note to show its \u2261 menu.\\n\u2022 Hover ' +
'over \u2261 to reveal the options.","raw":false,"min":false},{"text":"\u2022 X deletes the note.\\n\u2022' +
' R changes how a note looks.\\n\u2022 _ collapses the note.","raw":false,"min":false},{"text":"This is a ' +
'raw note.","raw":true,"min":false},{"text":"This is a collapsed note. Only its first line is visible. Useful f' +
'or keeping lists compact.","raw":false,"min":true}, {"text":"Links","raw":true,"min":false}, {"text":"Links pu' +
'lse on hover and can be opened via the right-click menu - https://nullboard.io","raw":false,"min":false}, {"tex' +
't":"Pressing CapsLock or Control highlights all links and makes them left-clickable.","raw":false,"min":false}]},{"title"' +
':"More things to try","notes":[{"text":"\u2022 Drag notes around to rearrange.\\n\u2022 Works between the ' +
'lists too.","raw":false,"min":false},{"text":"\u2022 Click on a list name to edit.\\n\u2022 Enter to save,' +
' Esc to cancel.","raw":false,"min":false},{"text":"\u2022 Try adding a new list.\\n\u2022 Try deleting one' +
'. This _can_ be undone.","raw":false,"min":false},{"text":"\u2022 Same for the board name.","raw":false,"m' +
'in":false},{"text":"Boards","raw":true,"min":false},{"text":"\u2022 Check out \u2261 at the top right.",' +
'"raw":false,"min":false},{"text":"\u2022 Try adding a new board.\\n\u2022 Try switching between the boards' +
'.","raw":false,"min":false},{"text":"\u2022 Try deleting a board. Unlike deleting a\\n list this _canno' +
't_ be undone.","raw":false,"min":false},{"text":"\u2022 Export the board (save to a file, as json)\\n' +
'\u2022 Import the board (load from a save)","raw":false,"min":false}]}]}';
var demo = JSON.parse(blob);
if (! demo)
return false;
demo.id = +new Date();
demo.revision = 0;
NB.storage.saveBoard(demo);
NB.storage.setActiveBoard(demo.id);
return Object.assign(new Board(), demo);
}
/*
* board export / import
*/
function exportBoard()
{
var blob, file;
if (! NB.board)
{
var index = NB.storage.getBoardIndex();
var all = [];
boards.forEach(function(meta, board_id){
all.push( NB.storage.loadBoard(board_id, null) );
})
blob = JSON.stringify(all);
file = `Nullboard.nbx`;
}
else
{
var board = NB.board;
blob = JSON.stringify(board);
file = `Nullboard-${board.id}-${board.title}.nbx`;
}
blob = encodeURIComponent(blob);
blob = "data:application/octet-stream," + blob;
return { blob: blob, file: file };
}
function checkImport(foo)
{
var props = [ 'format', 'id', 'revision', 'title', 'lists' ];
for (var i=0; i<props.length; i++)
if (! foo.hasOwnProperty(props[i]))
return "Required board properties are missing.";
if (! foo.id || ! foo.revision || ! Array.isArray(foo.lists))
return "Required board properties are empty.";
if (foo.format != NB.blobVersion)
return `Unsupported blob format "${foo.format}", expecting "${NB.blobVersion}".`;
return null;
}
function importBoard(blob)
{
var data;
try
{
data = JSON.parse(blob);
}
catch (x)
{
alert('File is not in a valid JSON format.');
return false;
}
if (! Array.isArray(data))
data = [ data ];
var index = NB.storage.getBoardIndex();
var msg, one, all = '';
for (var i=0; i<data.length; i++)
{
var board = data[i];
var whoops = checkImport(board);
if (whoops)
{
alert(whoops);
return false;
}
var title = board.title || '(untitled board)';
one = `"${title}", ID ${board.id}, revision ${board.revision}`;
all += ` ID ${board.id}, revision ${board.revision} - "${title}" \n`;
}
if (data.length == 1) msg = `Import a board called ${one} ?`;
else msg = `About to import the following boards:\n\n${all}\nProceed?`;
if (! confirm(msg))
return false;
var to_open = '';
for (var i=0; i<data.length; i++)
{
var board = data[i];
var check_title = true;
// check ID
if (index.has(board.id))
{
var which = (data.length == 1) ? "with the same ID" : board.id;
if (confirm(`Board ${which} already exists. Overwrite it?`) &&
confirm(`OVERWRITE for sure?`))
{
console.log(`Import: ${board.id} (${board.title} - will overwrite existing one`);
check_title = false;
}
else
if (confirm(`Import the board under a new ID?`))
{
var new_id = +new Date();
console.log(`Import: ${board.id} (${board.title} - will import as ${new_id}`);
board.id = new_id;
}
else
{
console.log(`Import: ${board.id} (${board.title} - ID conflict, will not import`);
continue;
}
}
if (check_title)
{
var retitle = false;
index.forEach( have => { retitle |= (have.title == board.title) } );
if (retitle) board.title += ' (imported)';
}
// ok, do the deed
board.revision--; // save will ++ it back
if (! NB.storage.saveBoard(board)) // this updates 'index'
{
alert(`Failed to save board ${board.id}. Import failed.`);
return false;
}
if (! to_open) to_open = data[0].id;
}
if (to_open) openBoard(to_open);
}
/*
*
*/
function findBackupAgent(which)
{
var a = null;
NB.storage.backups.agents.forEach(function(agent){
if (agent.type == which.type &&
agent.conf.auth == which.conf.auth &&
agent.conf.base == which.conf.base)
{
a = agent;
}
});
return a;
}
function setBackupConfigUi($div, backupConf)
{
if (! backupConf.enabled)
{
$div.addClass('off');
return;
}
var $status = $div.find('.status');
var b = findBackupAgent(backupConf);
var text = 'OK';
if (b && b.status == 'error')
{
text = b.lastXhr.text;
$status.addClass('error');
}
$status.find('input').val(text);
$status.css({ display: 'block' });
}
function getBackupConfigUi()
{
var conf = NB.storage.getConfig();
var loc = conf.backups.agents[0];
var rem = conf.backups.agents[1];
var $div = $('.overlay .backup-conf');
var $loc = $div.find('.loc');
var $rem = $div.find('.rem');
var ret =
{
loc: jsonClone(loc),
rem: jsonClone(rem)
};
ret.loc.enabled = ! $loc.hasClass('off');
ret.loc.conf.auth = $loc.find('.auth').val();
ret.rem.enabled = ! $rem.hasClass('off');
ret.rem.conf.base = $rem.find('.base').val();
ret.rem.conf.auth = $rem.find('.auth').val();
//
if (ret.loc.enabled && ! ret.loc.conf.auth)
{
shakeControl($loc.find('.auth'));
return null;
}
if (ret.rem.enabled && ! ret.rem.conf.base)
{
shakeControl($rem.find('.base'));
return null;
}
if (ret.rem.enabled && ! ret.rem.conf.auth)
{
shakeControl($rem.find('.auth'));
return null;
}
return ret;
}
function checkBackupConfig(backupConf, $div, onDone)
{
var $status = $div.find('.status');
var $text = $status.find('input');
$text.val('Checking...');
$status.removeClass('error').slideDown();
$div.delay(850).queue(function(){
var T = NB.backupTypes.get(backupConf.type);
var foo = new T(backupConf.id, backupConf.conf, function(){});
foo.checkStatus(function(ok){
if (ok)
{
$text.val('OK');
}
else
{
$text.val(foo.lastXhr.text);
$status.addClass('error');
}
onDone();
});
$(this).dequeue();
});
}
function configBackups()
{
var conf = NB.storage.getConfig();
if (conf.backups.agents.length != 2)
throw 'Invalid conf.backups.agents[]'; // as per fixupConfig()
//
var $div = $('tt .backup-conf').clone();
var div = $div[0];
var $loc = $div.find('.loc');
var $rem = $div.find('.rem');
var typ = (new SimpleBackup).type;
var loc = conf.backups.agents[0];
var rem = conf.backups.agents[1];
div.checking = 0;
//
$loc.find('.auth').val( loc.conf.auth );
$rem.find('.auth').val( rem.conf.auth );
$rem.find('.base').val( rem.conf.base );
setBackupConfigUi($loc, loc);
setBackupConfigUi($rem, rem);
if (! loc.enabled && ! rem.enabled)
$div.addClass('off');
//
$div.find('.opt').click(function(){
var $opt = $(this).parent();
if ($opt.hasClass('off'))
{
$opt.find('.etc')
.css({ opacity: 0 })
.slideDown('fast')
.animate({ opacity: 1 }, 'fast')
.queue(function(){
$opt.removeClass('off');
$div.removeClass('off');
$(this).css('opacity', '').dequeue();
})
$opt.find('input').first()
.delay(800)
.queue(function(){ $(this).focus().dequeue(); });
}
else
{
$opt.find('.etc')
.animate({ opacity: 0 }, 'fast')
.slideUp('fast')
.queue(function(){
$opt.addClass('off');
if ($loc.hasClass('off') && $rem.hasClass('off'))
$div.addClass('off');
$(this).css({ opacity: '' }).dequeue();
})
}
return false;
});
$div.find('.check').click(function(){
if (div.checking)
return false;
var foo = getBackupConfigUi();
if (! foo)
return false;
if (foo.loc.enabled)
{
div.checking++;
checkBackupConfig(foo.loc, $loc, function(){ div.checking--; });
}
if (foo.rem.enabled)
{
div.checking++;
checkBackupConfig(foo.rem, $rem, function(){ div.checking--; });
}
return false;
});
$div.find('.ok').click(function(){
var foo = getBackupConfigUi();
if (! foo)
return false;
if (foo.loc.enabled && ! loc.enabled)
foo.loc.id = typ + '-' + (conf.backups.nextId++);
if (foo.rem.enabled && ! rem.enabled)
foo.rem.id = typ + '-' + (conf.backups.nextId++);
conf.backups.agents[0] = foo.loc;
conf.backups.agents[1] = foo.rem;
NB.storage.initBackups(onBackupStatusChange);
NB.storage.saveConfig();
hideOverlay();
});
$div.find('a.close').click(function(){
hideOverlay();
});
showOverlay($div);
}
function onBackupStatusChange(agent)
{
var agents = NB.storage.backups.agents;
var $config = $('.config');
var $status = $('.config .teaser u')
// if (agent) console.log( `onBackupStatusChange: ${agent.id}, status ${agent.status}, op ${agent.lastOp}, xhr '${agent.lastXhr.text}' / ${agent.lastXhr.code}` );
// else console.log( `onBackupStatusChange: <generic>` );
if (! agents.length)
{
$config.removeClass('backups-on backup-err backing-up');
return;
}
$config.addClass('backups-on');
var busy = 0;
var error = 0;
var ready = 0;
agents.forEach(function(agent){
if (agent.status == 'busy') busy++; else
if (agent.status == 'error') error++; else
if (agent.status == 'ready') ready++; else
throw `Unknown status [${agent.status}] on backup agent ${agent.id}`;
});
if (error > 0) $config.addClass('backup-err').removeClass('backing-up'); else
if (busy > 0) $config.addClass('backing-up').removeClass('backup-err'); else
$config.removeClass('backing-up backup-err');
// process all pending backups if needed
if (! error && ! busy)
runPendingBackups();
}
function needsBackingUp(backupStatus, fields, agentIds)
{
var stale = false;
agentIds.forEach(function(id){
var obj = backupStatus[id];
if (obj) fields.forEach(function(f){ stale = !obj[f]; });
else stale = true;
});
return stale;
}
function runPendingBackups()
{
console.log('Checking for pending backups...');
var conf = NB.storage.getConfig();
var agentIds = [];
NB.storage.backups.agents.forEach(function(agent){
agentIds.push(agent.id);
});
if (needsBackingUp(conf.backupStatus, [ 'conf' ], agentIds))
{
console.log(" Backing up app config...");
NB.storage.backupConfig();
}
var boards = NB.storage.getBoardIndex();
boards.forEach(function(meta, id){
if (! needsBackingUp(meta.backupStatus, [ 'data', 'meta' ], agentIds))
return;
console.log(` Backing up board ${id}...`);
var board = NB.storage.loadBoard(id);
if (! board)
return;
NB.storage.backupBoard(id, board, meta)
});
}
/*
*
*/
function saveBoardOrder()
{
var $index = $('.config .load-board');
var spot = 1;
$index.each(function(){
var id = parseInt( $(this).attr('board_id') );
NB.storage.setBoardUiSpot(id, spot++);
});
}
/*
*
*/
function updatePageTitle()
{
var title = 'Nullboard';
if (NB.board)
{
title = NB.board.title;
title = 'NB - ' + (title || '(untitled board)');
}
document.title = title;
}
function updateUndoRedo()
{
var $undo = $('.board .menu .undo-board');
var $redo = $('.board .menu .redo-board');
var undo = false;
var redo = false;
if (NB.board && NB.board.revision)
{
var history = NB.storage.getBoardHistory(NB.board.id);
var rev = NB.board.revision;
undo = (rev != history[history.length-1]);
redo = (rev != history[0]);
}
if (undo) $undo.show(); else $undo.hide();
if (redo) $redo.show(); else $redo.hide();
}
function updateBoardIndex()
{
var $index = $('.config .boards');
var $export = $('.config .exp-board');
var $backup = $('.config .auto-backup');
var $entry = $('tt .load-board');
var $board = $('.wrap .board');
var id_now = NB.board && NB.board.id;
var empty = true;
$index.html('');
$index.hide();
var boards = NB.storage.getBoardIndex();
var index = [];
boards.forEach(function(meta, id){ index.push({ id: id, meta: meta }); });
index.sort(function(a, b){ return b.meta.ui_spot && a.meta.ui_spot > b.meta.ui_spot; });
index.forEach(function(entry){
var $e = $entry.clone();
$e.attr('board_id', entry.id);
$e.html(entry.meta.title);
if (entry.id == id_now)
$e.addClass('active');
$index.append($e);
empty = false;
});
if (! empty)
{
if (id_now) $export.html('Export this board...').show();
else $export.html('Export all boards...').show();
$backup.show();
}
else
{
$export.hide();
$backup.hide();
}
if (! empty) $index.show();
}
function setWhatsNew()
{
var conf = NB.storage.getConfig();
if (conf.verSeen && conf.verSeen < NB.codeVersion)
{
$('.logo').addClass('updated');
$('.logo .alert').html("(updated)");
}
var $link = $('.logo .view-changes');
var link = $link.attr('href') + '/?have=' + NB.codeVersion;
if (conf.verSeen) link += '&seen=' + conf.verSeen;
if (conf.verLast) link += '&last=' + conf.verLast;
$link.attr('href', link);
}
/*
* generic utils
*/
function jsonMatch(a, b)
{
return JSON.stringify(a) == JSON.stringify(b);
}
function jsonClone(x)
{
return JSON.parse(JSON.stringify(x));
}
function htmlEncode(raw)
{
return $('tt .encoder').text(raw).html();
}
function setText($note, text)
{
$note.attr('_text', text);
text = htmlEncode(text);
var hmmm = /\b(https?:\/\/[^\s]+)/mg;
text = text.replace(hmmm, function(url){
return '<a href="' + url + '" target=_blank>' + url + '</a>';
});
if ( NB.peek('fileLinks') )
{
var xmmm = /`(.*?)`/mg;
text = text.replace(xmmm, function(full, text){
link = 'file:///' + text.replace('\\', '/');
return '`<a href="' + link + '" target=_blank>' + text + '</a>`';
});
}
$note.html(text); // ? text : ' ');
}
function getText($note)
{
return $note.attr('_text');
}
function removeTextSelection()
{
if (window.getSelection) { window.getSelection().removeAllRanges(); }
else if (document.selection) { document.selection.empty(); }
}
function shakeControl($x)
{
$x
.css({ position: 'relative' })
.focus()
.animate({ left: '+4px' }, 60)
.animate({ left: '-3px' }, 60)
.animate({ left: '+2px' }, 60)
.animate({ left: '0px' }, 60)
.queue(function(){
$x.css({ position: '', left: '' }).dequeue();
});
}
/*
* inline editing
*/
function startEditing($text, ev)
{
var $note = $text.parent();
var $edit = $note.find('.edit');
$note[0]._collapsed = $note.hasClass('collapsed');
$note.removeClass('collapsed');
$edit.val( getText($text) );
$edit.width( $text.width() );
$edit.height( $text.height() );
$note.addClass('editing');
$edit.focus();
}
function stopEditing($edit, via_escape, via_xclick)
{
var $item = $edit.parent();
if (! $item.hasClass('editing'))
return;
$item.removeClass('editing');
if ($item[0]._collapsed)
$item.addClass('collapsed')
//
var $text = $item.find('.text');
var text_now = $edit.val().trimRight();
var text_was = getText( $text );
//
var brand_new = $item.hasClass('brand-new');
$item.removeClass('brand-new');
if (via_escape)
{
if (brand_new)
$item.closest('.note, .list, .board').remove();
return;
}
if (via_xclick && brand_new && !text_now.length)
{
$item.closest('.note, .list, .board').remove();
return;
}
if (text_now != text_was || brand_new)
{
setText( $text, text_now );
if ($item.parent().hasClass('board'))
NB.board.title = text_now;
updatePageTitle();
saveBoard();
}
//
if (brand_new && $item.hasClass('list'))
addNote($item);
}
function handleTab(ev)
{
var $this = $(this);
var $note = $this.closest('.note');
var $sibl = ev.shiftKey ? $note.prev() : $note.next();
if ($sibl.length)
{
stopEditing($this, false, false);
$sibl.find('.text').click();
}
}
//
function setRevealState(ev)
{
var raw = ev.originalEvent;
var do_reveal = raw.getModifierState && (raw.getModifierState( 'CapsLock' ) || raw.getModifierState( 'Control' ));
if (do_reveal) $('body').addClass('reveal');
else $('body').removeClass('reveal');
}
//
function showDing()
{
$('body')
.addClass('ding')
.delay(250)
.queue(function(){ $(this).removeClass('ding').dequeue(); });
}
/*
* overlay
*/
function showOverlay($div)
{
$('.overlay')
.html('')
.append($div)
.css({ opacity: 0, display: 'flex' })
.animate({ opacity: 1 });
}
function hideOverlay()
{
$('.overlay').animate({ opacity: 0 }, function(){
$(this).hide();
});
}
function haveOverlay()
{
return $('.overlay').css('display') != 'none';
}
/*
* license popup
*/
function formatLicense()
{
var text = document.head.childNodes[1].nodeValue;
var pos = text.search('LICENSE');
var qos = text.search('Software:');
var bulk;
bulk = text.substr(pos, qos-pos);
bulk = bulk.replace(/([^\n])\n\t/g, '$1 ');
bulk = bulk.replace(/\n\n\t/g, '\n\n');
bulk = bulk.replace(/([A-Z ]{7,})/g, '<u>$1</u>');
//
var c1 = [];
var c2 = [];
text.substr(qos).trim().split('\n').forEach(function(line){
line = line.split(':');
c1.push( line[0].trim() + ':' );
c2.push( line[1].trim() );
});
bulk += '<span>' + c1.join('<br>') + '</span>';
bulk += '<span>' + c2.join('<br>') + '</span>';
//
var links =
[
{ text: '2-clause BSD license', href: 'https://opensource.org/licenses/BSD-2-Clause/' },
{ text: 'Commons Clause', href: 'https://commonsclause.com/' }
];
links.forEach(function(l){
bulk = bulk.replace(l.text, '<a href="' + l.href + '" target=_blank>' + l.text + '</a>');
});
return bulk.trim();
}
/*
* adjust this and that
*/
function adjustLayout()
{
var $body = $('body');
var $board = $('.board');
if (! $board.length)
return;
var list_w = getListWidth();
var lists = $board.find('.list').length;
var lists_w = (lists < 2) ? list_w : (list_w + 10) * lists - 10;
var body_w = $body.width();
if (lists_w + 190 <= body_w)
{
$board.css('max-width', '');
$body.removeClass('crowded');
}
else
{
var max = Math.floor( (body_w - 40) / (list_w + 10) );
max = (max < 2) ? list_w : (list_w + 10) * max - 10;
$board.css('max-width', max + 'px');
$body.addClass('crowded');
}
}
//
function adjustListScroller()
{
var $board = $('.board');
if (! $board.length)
return;
var $lists = $('.board .lists');
var $scroller = $('.board .lists-scroller');
var $inner = $scroller.find('div');
var max = $board.width();
var want = $lists[0].scrollWidth;
var have = $inner.outerWidth();
if (want <= max+5)
{
$scroller.hide();
return;
}
$scroller.show();
if (want == have)
return;
$inner.width(want);
cloneScrollPos($lists, $scroller);
}
function cloneScrollPos($src, $dst)
{
var src = $src[0];
var dst = $dst[0];
if (src._busyScrolling)
{
src._busyScrolling--;
return;
}
dst._busyScrolling++;
dst.scrollLeft = src.scrollLeft;
}
function setupListScrolling()
{
var $lists = $('.board .lists');
var $scroller = $('.board .lists-scroller');
adjustListScroller();
$lists[0]._busyScrolling = 0;
$scroller[0]._busyScrolling = 0;
$scroller.on('scroll', function(){ cloneScrollPos($scroller, $lists); });
$lists .on('scroll', function(){ cloneScrollPos($lists, $scroller); });
adjustLayout();
}
/*
* dragsters
*/
function initDragAndDrop()
{
NB.noteDrag = new Drag2();
NB.noteDrag.listSel = '.board .list .notes';
NB.noteDrag.itemSel = '.note';
NB.noteDrag.dragster = 'note-dragster';
NB.noteDrag.onDragging = function(started)
{
var drag = this;
var $note = $(drag.item);
if (started)
{
var $drag = drag.$drag;
if ($note.hasClass('collapsed'))
$drag.addClass('collapsed');
$drag.html('<div class=text></div>');
$drag.find('.text').html( $note.find('.text').html() );
drag.org_loc = noteLocation($note);
if ($note.hasClass('collapsed'))
drag.$drag.addClass('collapsed');
}
else
{
if (this.org_loc != noteLocation($note))
saveBoard();
}
}
NB.loadDrag = new Drag2();
NB.loadDrag.listSel = '.config .boards';
NB.loadDrag.itemSel = 'a.load-board';
NB.loadDrag.dragster = 'load-dragster';
NB.loadDrag.onDragging = function(started)
{
var drag = this;
if (started)
{
var $drag = drag.$drag;
$('.config .teaser').css({ display: 'none' });
$('.config .bulk').css({ display: 'block', opacity: 1 });
$drag.html( $(this.item).html() );
}
else
{
$('.config .teaser').css({ display: '' });
$('.config .bulk')
.show()
.delay(250)
.queue(function(){ $(this).css({ display: '', opacity: '' }).dequeue(); });
saveBoardOrder();
}
}
}
/*
* fonts
*/
function initFonts()
{
var toGo = 0;
var loaded = [];
var failed = [];
NB.font = null; // current font
//
function isUsable(f)
{
return ! failed.includes(f) && loaded.includes(f);
}
function onFontsLoaded()
{
var conf = NB.storage.getConfig();
$('.config .switch-font').each(function(){
if (! isUsable($(this).attr('font')))
$(this).remove();
});
if (conf.fontName && ! isUsable(conf.fontName))
{
NB.storage.setFontName(null);
selectFont(null);
}
selectFont(conf.fontName || 'barlow');
if (conf.fontSize)
setFontSize(conf.fontSize);
if (conf.lineHeight)
setLineHeight(conf.lineHeight);
if (conf.listWidth)
setListWidth(conf.listWidth);
updateVarsAndLayout();
}
function onFontLoaded(f, ok)
{
var m = f.family.match(/["']?f-([^"']*)/);
var f_name = m ? m[1] : ''; /* ios safari will set 'family' to 'weight' on failure ! */
if (! ok)
{
console.log( `! Failed to load ${f.family} ${f.weight}` );
failed.push(f_name);
}
else
{
loaded.push(f_name);
}
if (! --toGo)
onFontsLoaded();
}
document.fonts.forEach(function(f){
if (f.status == 'loaded')
return;
console.log( `Loading ${f.family} ${f.weight} ...` );
toGo++;
f.load()
.then(function(){ onFontLoaded(f, true); })
.catch(function(){ onFontLoaded(f, false); });
});
}
function selectFont(font)
{
var $html = $('html');
$html.removeClass('f-' + NB.font).addClass('f-' + font);
NB.font = font;
var $list = $('.config .switch-font');
$list.removeClass('active');
$list.filter('[font="' + font + '"]').addClass('active');
updateVarsAndLayout();
}
//
function getVar(name)
{
var v = $('html').css(name);
var m = v.match(/^\s*calc\((.*)\)$/);
if (m) v = eval(m[1]);
return parseFloat( v );
}
function getFontSize()
{
return getVar('--fs');
}
function getLineHeight()
{
return getVar('--lh');;
}
function getListWidth()
{
return parseInt( getVar('--lw') );
}
//
function updateFontSize()
{
var val = getFontSize();
$('.config .f-prefs .ui-fs .val').html( val.toFixed(1) );
return val;
}
function updateLineHeight()
{
var val = getLineHeight();
$('.config .f-prefs .ui-lh .val').html( val.toFixed(1) );
return val;
}
function updateListWidth()
{
var val = getListWidth();
$('.config .f-prefs .ui-lw .val').html( val.toFixed(0) );
return val;
}
function updateVarsAndLayout()
{
updateFontSize();
updateLineHeight();
updateListWidth();
adjustLayout();
}
//
function setFontSize(fs)
{
fs = fs.clamp(9, 24);
$('html').css('--fs', fs + '').addClass('fs-set');
updateVarsAndLayout();
if (getLineHeight() < fs)
setLineHeight(fs);
return getFontSize();
}
function setLineHeight(lh)
{
var fs = getFontSize();
lh = parseInt(10*lh) / 10.; // trim to a single decimal digit
lh = lh.clamp(fs, 3*fs);
$('html').css('--lh', lh + '').addClass('lh-set');
updateVarsAndLayout();
return getLineHeight();
}
function setListWidth(lw)
{
lw = lw.clamp(200, 400);
$('html').css('--lw', lw + '').addClass('lw-set');
updateVarsAndLayout();
return getListWidth();
}
//
function resetFontSize()
{
$('html').css('--fs', '').removeClass('fs-set');
updateVarsAndLayout();
return updateFontSize();
}
function resetLineHeight()
{
$('html').css('--lh', '').removeClass('lh-set');
updateVarsAndLayout();
return updateLineHeight();
}
function resetListWidth()
{
$('html').css('--lw', '').removeClass('lw-set');
updateVarsAndLayout();
return updateListWidth();
}
//
function saveUiPrefs()
{
var $html = $('html');
NB.storage.setFontSize ( $html.hasClass('fs-set') ? getFontSize() : null );
NB.storage.setLineHeight ( $html.hasClass('lh-set') ? getLineHeight() : null );
NB.storage.setListWidth ( $html.hasClass('lw-set') ? getListWidth() : null );
}
/*
* event handlers
*/
$(window).on('blur', function(){
$('body').removeClass('reveal');
});
$(document).on('keydown', function(ev){
setRevealState(ev);
});
$(document).on('keyup', function(ev){
var raw = ev.originalEvent;
setRevealState(ev);
if (ev.target.nodeName == 'TEXTAREA' ||
ev.target.nodeName == 'INPUT')
return;
if (ev.ctrlKey && (raw.code == 'KeyZ'))
{
var ok = ev.shiftKey ? redoBoard() : undoBoard();
if (! ok)
showDing();
}
else
if (ev.ctrlKey && (raw.code == 'KeyY'))
{
if (! redoBoard())
showDing();
}
});
$('.wrap').on('click', '.board .text', function(ev){
if (this.was_dragged)
{
this.was_dragged = false;
return false;
}
NB.noteDrag.cancelPriming();
startEditing($(this), ev);
return false;
});
$('.wrap').on('click', '.board .note .text a', function(ev){
if (! $('body').hasClass('reveal'))
return true;
ev.stopPropagation();
return true;
});
//
$('.wrap').on('keydown', '.board .edit', function(ev){
var $this = $(this);
var $note = $this.closest('.note');
var $list = $this.closest('.list');
var isNote = $note.length > 0;
var isList = $list.length > 0;
// esc
if (ev.keyCode == 27)
{
stopEditing($this, true, false);
return false;
}
// tab
if (ev.keyCode == 9)
{
handleTab.call(this, ev);
return false;
}
// done
if (ev.keyCode == 13 && ev.altKey ||
ev.keyCode == 13 && ev.shiftKey && ! ev.ctrlKey)
{
stopEditing($this, false, false);
return false;
}
// done + (add after / add before)
if (ev.keyCode == 13 && ev.ctrlKey)
{
stopEditing($this, false, false);
if (isNote)
{
if (ev.shiftKey) // ctrl-shift-enter
addNote($list, null, $note);
else
addNote($list, $note);
}
else
if (isList)
{
$note = $list.find('.note').last();
addNote($list, $note);
}
else
{
addList();
}
return false;
}
// done on Enter if editing board or list title
if (ev.keyCode == 13 && ! isNote)
{
stopEditing($this, false, false);
return false;
}
// done + collapse
if (isNote && ev.altKey && ev.key == 'ArrowUp')
{
var $item = $this.parent();
$item[0]._collapsed = true;
stopEditing($this, false, false);
return false;
}
// done + expand
if (isNote && ev.altKey && ev.key == 'ArrowDown')
{
var $item = $this.parent();
$item[0]._collapsed = false;
stopEditing($this, false, false);
return false;
}
// done + toggle 'raw'
if (isNote && ev.altKey && ev.keyCode == 82)
{
$this.parent().toggleClass('raw');
stopEditing($this, false, false);
return false;
}
// ctrl-shift-8
if (isNote && ev.key == '*' && ev.ctrlKey)
{
var have = this.value;
var pos = this.selectionStart;
var want = have.substr(0, pos) + '\u2022 ' + have.substr(this.selectionEnd);
$this.val(want);
this.selectionStart = this.selectionEnd = pos + 2;
return false;
}
return true;
});
$('.wrap').on('keypress', '.board .edit', function(ev){
// tab
if (ev.keyCode == 9)
{
handleTab.call(this, ev);
return false;
}
});
//
$('.wrap').on('blur', '.board .edit', function(ev){
if (document.activeElement != this)
stopEditing($(this), false, true);
else
; // switch away from the browser window
});
//
$('.wrap').on('input propertychange', '.board .note .edit', function(){
var delta = $(this).outerHeight() - $(this).height();
$(this).height(10);
if (this.scrollHeight > this.clientHeight)
$(this).height(this.scrollHeight - delta);
});
//
$('.config').on('click', '.add-board', function(){
addBoard();
return false;
});
$('.config').on('click', '.load-board', function(){
var board_id = parseInt( $(this).attr('board_id') );
NB.loadDrag.cancelPriming();
if (NB.board && (NB.board.id == board_id))
closeBoard();
else
openBoard(board_id);
return false;
});
$('.wrap').on('click', '.board .del-board', function(){
deleteBoard();
return false;
});
$('.wrap').on('click', '.board .undo-board', function(){
undoBoard();
return false;
});
$('.wrap').on('click', '.board .redo-board', function(){
redoBoard();
return false;
});
//
$('.wrap').on('click', '.board .add-list', function(){
addList();
return false;
});
$('.wrap').on('click', '.board .del-list', function(){
deleteList( $(this).closest('.list') );
return false;
});
$('.wrap').on('click', '.board .mov-list-l', function(){
moveList( $(this).closest('.list'), true );
return false;
});
$('.wrap').on('click', '.board .mov-list-r', function(){
moveList( $(this).closest('.list'), false );
return false;
});
//
$('.wrap').on('click', '.board .add-note', function(){
addNote( $(this).closest('.list') );
return false;
});
$('.wrap').on('click', '.board .del-note', function(){
deleteNote( $(this).closest('.note') );
return false;
});
$('.wrap').on('click', '.board .raw-note', function(){
$(this).closest('.note').toggleClass('raw');
saveBoard();
return false;
});
$('.wrap').on('click', '.board .collapse', function(){
$(this).closest('.note').toggleClass('collapsed');
saveBoard();
return false;
});
//
$('.wrap').on('mousedown', '.board .note .text', function(ev){
NB.noteDrag.prime(this.parentNode, ev);
});
$('.config').on('mousedown', 'a.load-board', function(ev){
if ($('.config a.load-board').length > 1)
NB.loadDrag.prime(this, ev);
});
//
$('.config').on('mousedown', '.ui-fs .val', function(ev){
var org = getFontSize();
NB.varAdjust.start(ev, function(delta){ setFontSize( org + delta/50. ); }, saveUiPrefs);
});
$('.config').on('mousedown', '.ui-lh .val', function(ev){
var org = getLineHeight();
NB.varAdjust.start(ev, function(delta){ setLineHeight( org + delta/50. ); }, saveUiPrefs);
});
$('.config').on('mousedown', '.ui-lw .val', function(ev){
var org = getListWidth();
NB.varAdjust.start(ev, function(delta){ setListWidth( org + delta/5. ); }, saveUiPrefs);
});
//
$(document).on('mouseup', function(ev){
NB.noteDrag.end();
NB.loadDrag.end();
NB.varAdjust.end();
});
$(document).on('mousemove', function(ev){
setRevealState(ev);
NB.noteDrag.onMouseMove(ev);
NB.loadDrag.onMouseMove(ev);
NB.varAdjust.onMouseMove(ev);
});
//
$('.config .imp-board').on('click', function(ev){
$('.config .imp-board-select').click();
return false;
});
$('.config .imp-board-select').on('change' , function(){
var files = this.files;
var reader = new FileReader();
reader.onload = function(ev){ importBoard(ev.target.result); };
reader.readAsText(files[0]);
return true;
});
$('.config .exp-board').on('click', function(){
var pack = exportBoard();
$(this).attr('href', pack.blob);
$(this).attr('download', pack.file);
return true;
});
$('.config .auto-backup').on('click', function(){
configBackups();
});
//
$('.config .section .title').on('click', function(){
$(this).closest('.section').toggleClass('open');
return false;
});
$('.config').on('click', '.switch-font', function(){
var font = $(this).attr('font');
selectFont(font);
NB.storage.setFontName(font);
return false;
});
//
$('.config .f-prefs .ui-fs .less').on('click', function(){
setFontSize( parseInt(10*getFontSize()) / 10. - 0.5 );
saveUiPrefs();
return false;
});
$('.config .f-prefs .ui-fs .val').on('click', function(){
if (NB.varAdjust.used) return false;
var fs = resetFontSize();
if (getLineHeight() < fs) setLineHeight(fs);
saveUiPrefs();
return false;
});
$('.config .f-prefs .ui-fs .more').on('click', function(){
setFontSize( parseInt(10*getFontSize()) / 10. + 0.5 );
saveUiPrefs();
return false;
});
//
$('.config .f-prefs .ui-lh .less').on('click', function(){
setLineHeight( parseInt(10*getLineHeight()) / 10. - 0.1 );
saveUiPrefs();
return false;
});
$('.config .f-prefs .ui-lh .val').on('click', function(){
if (NB.varAdjust.used) return false;
var lh = resetLineHeight();
if (lh < getFontSize()) setFontSize(lh);
saveUiPrefs();
return false;
});
$('.config .f-prefs .ui-lh .more').on('click', function(){
setLineHeight( parseInt(10*getLineHeight()) / 10. + 0.1 );
saveUiPrefs();
return false;
});
//
$('.config .f-prefs .ui-lw .less').on('click', function(){
setListWidth( getListWidth() - 5 );
saveUiPrefs();
return false;
});
$('.config .f-prefs .ui-lw .val').on('click', function(){
if (NB.varAdjust.used) return false;
resetListWidth();
saveUiPrefs();
return false;
});
$('.config .f-prefs .ui-lw .more').on('click', function(){
setListWidth( getListWidth() + 5 );
saveUiPrefs();
return false;
});
//
$('.config .switch-theme').on('click', function() {
var $html = $('html');
$html.toggleClass('theme-dark');
NB.storage.setTheme($html.hasClass('theme-dark') ? 'dark' : '');
return false;
});
//
$('.overlay').click(function(ev){
if (ev.originalEvent.target != this)
return true;
hideOverlay();
return false;
});
$(window).keydown(function(ev){
if (haveOverlay() && ev.keyCode == 27)
hideOverlay();
});
$('.view-about').click(function(){
var $div = $('tt .about').clone();
$div.find('div').html(`Version ${NB.codeVersion}`);
showOverlay($div);
return false;
});
$('.view-license').click(function(){
var $div = $('tt .license').clone();
$div.html(formatLicense());
showOverlay($div);
return false;
});
$('.view-changes').click(function(){
if (! $('.logo').hasClass('updated'))
return;
NB.storage.setVerSeen();
$('.logo').removeClass('updated');
});
/***/
$(window).resize(adjustLayout);
$('body').on('dragstart', function(){ return false; });
/*
* the init()
*/
var NB =
{
codeVersion: 20231105,
blobVersion: 20190412, // board blob format in Storage
board: null,
storage: null,
peek: function(name)
{
return this.storage.getConfig()[name];
},
poke: function(name, val)
{
var conf = this.storage.getConfig();
conf[name] = val;
return this.storage.saveConfig();
}
};
NB.storage = new Storage_Server();
if (! NB.storage.open())
{
easyMartina = true;
throw new Error();
}
/*
* ValidBoard: writes are asynchronous now, so a tab can be closed while a
* batch is still queued. Say so rather than losing it.
*/
window.addEventListener('beforeunload', function(e){
if (! NB.storage.hasUnsavedChanges())
return;
e.preventDefault();
e.returnValue = '';
return '';
});
$('.logo .sign-out').click(function(){
if (NB.storage.hasUnsavedChanges() &&
! confirm("Some changes haven't reached the server yet.\n\nSign out anyway?"))
return false;
$('.sign-out-form').submit();
return false;
});
var boards = NB.storage.getBoardIndex();
boards.forEach( function(meta, board_id) {
var hist = meta.history.join(', ');
console.log( `Found board ${board_id} - "${meta.title}", revision ${meta.current}, history [${hist}], backup ${JSON.stringify(meta.backupStatus)}` );
});
//
var conf = NB.storage.getConfig();
console.log( `Active: [${conf.board}]` );
console.log( `Theme: [${conf.theme}]` );
console.log( `Font: [${conf.fontName}], size [${conf.fontSize || '-'}], line-height [${conf.lineHeight || '-'}]` );
console.log( `FileLinks: [${conf.fileLinks}]` );
console.log( 'Backups: ', conf.backups);
/*
* backups
*/
NB.backupTypes = new Map();
NB.backupTypes.set( (new SimpleBackup).type, SimpleBackup );
NB.storage.initBackups(onBackupStatusChange);
/*
* the ui
*/
initFonts();
initDragAndDrop();
NB.varAdjust = new VarAdjust()
//
if (conf.theme)
$('html').addClass('theme-' + conf.theme);
if (conf.board)
openBoard(conf.board);
adjustLayout();
updateBoardIndex();
setWhatsNew();
NB.storage.setVerLast();
//
if (! NB.board && ! $('.config .load-board').length)
NB.board = createDemoBoard();
if (NB.board)
showBoard(true);
//
setInterval(adjustListScroller, 100);
setupListScrolling();
</script>
</html>