Browse Source

Initial version

shortcutme 7 years ago
parent
commit
217898e1e8
60 changed files with 6420 additions and 0 deletions
  1. 70 0
      content.json
  2. 17 0
      css/Activity.css
  3. 31 0
      css/Button.css
  4. 10 0
      css/Comment.css
  5. 9 0
      css/Editable.css
  6. 15 0
      css/Head.css
  7. 6 0
      css/Hub.css
  8. 27 0
      css/Post.css
  9. 2 0
      css/Uploadable.css
  10. 29 0
      css/User.css
  11. 81 0
      css/ZeroMe.css
  12. 335 0
      css/all.css
  13. 11 0
      css/fonts.css
  14. 61 0
      css/icons.css
  15. 101 0
      dbschema.json
  16. BIN
      img/loading-circle.gif
  17. BIN
      img/loading.gif
  18. BIN
      img/logo.png
  19. BIN
      img/unkown.png
  20. 24 0
      index.html
  21. 1035 0
      js-external/pngencoder.js
  22. 136 0
      js/ActivityList.coffee
  23. 40 0
      js/AnonUser.coffee
  24. 142 0
      js/ContentCreateProfile.coffee
  25. 50 0
      js/ContentFeed.coffee
  26. 171 0
      js/ContentProfile.coffee
  27. 46 0
      js/ContentSignup.coffee
  28. 42 0
      js/ContentUsers.coffee
  29. 49 0
      js/Head.coffee
  30. 159 0
      js/Post.coffee
  31. 63 0
      js/PostCreate.coffee
  32. 77 0
      js/PostList.coffee
  33. 251 0
      js/User.coffee
  34. 72 0
      js/UserList.coffee
  35. 296 0
      js/ZeroMe.coffee
  36. 1152 0
      js/all.js
  37. 23 0
      js/lib/Class.coffee
  38. 3 0
      js/lib/Dollar.coffee
  39. 90 0
      js/lib/Promise.coffee
  40. 2 0
      js/lib/Property.coffee
  41. 8 0
      js/lib/Prototypes.coffee
  42. 54 0
      js/lib/RateLimitCb.coffee
  43. 27 0
      js/lib/anime.min.js
  44. 8 0
      js/lib/clone.js
  45. 773 0
      js/lib/maquette.js
  46. 5 0
      js/lib/marked.min.js
  47. 162 0
      js/utils/Animation.coffee
  48. 70 0
      js/utils/Autosize.coffee
  49. 23 0
      js/utils/Class.coffee
  50. 13 0
      js/utils/Debug.coffee
  51. 3 0
      js/utils/Dollar.coffee
  52. 56 0
      js/utils/Editable.coffee
  53. 26 0
      js/utils/ItemList.coffee
  54. 69 0
      js/utils/Menu.coffee
  55. 8 0
      js/utils/Prototypes.coffee
  56. 40 0
      js/utils/RateLimitCb.coffee
  57. 140 0
      js/utils/Text.coffee
  58. 39 0
      js/utils/Time.coffee
  59. 72 0
      js/utils/Uploadable.coffee
  60. 96 0
      js/utils/ZeroFrame.coffee

+ 70 - 0
content.json

@@ -0,0 +1,70 @@
+{
+ "address": "1MeFqFfFFGQfa1J3gJyYYUvb5Lksczq7nH",
+ "background-color": "#F2F4F6",
+ "description": "ZeroMe",
+ "files": {
+  "css/all.css": {
+   "sha512": "6ae696529deeefd1344e92a2622aaf0776e8a1b9ace27621f6d194cf8dd51921",
+   "size": 116601
+  },
+  "dbschema.json": {
+   "sha512": "f32d615dce15c30dfb48ef7856ea60b46668475296c72567f0bd8ff440d0e39d",
+   "size": 2823
+  },
+  "img/loading-circle.gif": {
+   "sha512": "339baf1bccb9b80ae29c2493e73b40cf0588c96fc095954b475dea0538aaa929",
+   "size": 2346
+  },
+  "img/loading.gif": {
+   "sha512": "8a42b98962faea74618113166886be488c09dad10ca47fe97005edc5fb40cc00",
+   "size": 723
+  },
+  "img/logo.png": {
+   "sha512": "d78789ff8f7095d45ad484943b3d4e214289aea942cd5bec03dcde3f20a4c7a7",
+   "size": 3771
+  },
+  "img/unkown.png": {
+   "sha512": "a972f05819097c3a38801b9004bf5011a2dcca257b915617d8ea44c202f2cdbd",
+   "size": 595
+  },
+  "index.html": {
+   "sha512": "638654b07d4fbbc3dc858cfbfdff5892b7cecad06c885dd6066de6d34cb2b721",
+   "size": 612
+  },
+  "js-external/pngencoder.js": {
+   "sha512": "ecaadaad552d2610336995b48360253cd6d847b6aa590fc3d96e7245a5d5fe11",
+   "size": 53602
+  },
+  "js/all.js": {
+   "sha512": "f333379c280ade7d7da004adbc01f965e218f53b7e7b06aa65a7c1f32f0c86e4",
+   "size": 199577
+  }
+ },
+ "ignore": "(merged-.*|(js|css)/(?!all.(js|css)))",
+ "inner_path": "content.json",
+ "modified": 1470837985.396,
+ "postmessage_nonce_security": true,
+ "settings": {
+  "default_hubs": {
+   "1BLueGvui1GdbtsjcKqCf4F67uKfritG49": {
+    "description": "Hub for ZeroMe users. Runner: Nofish",
+    "title": "Blue hub"
+   },
+   "1RedkCkVaXuVXrqCMpoXQS29bwaqsuFdL": {
+    "description": "Hub for ZeroMe users. Runner: Nofish",
+    "title": "Red hub"
+   }
+  }
+ },
+ "sign": [
+  110948105486279959611413659669131704729027586619339149714476092929816973146340,
+  52223804688441424973627428066806239722329433729068609123045495269451631601524
+ ],
+ "signers_sign": "G/UCyELE5shc5f/FSJSe2KvxGlZiS5fzvn7Ezhha0gN/QOFeznJtZWS61J20FfkGfHdp1HcpWv//anioez1iOW0=",
+ "signs": {
+  "1MeFqFfFFGQfa1J3gJyYYUvb5Lksczq7nH": "G3GBut8Q89NfzGJvTHkyIuvqfvQaCJilBqas0L4ZnLWWvTjsY8E5l4x/nTJZWVoB2RJiDTPhCmVly2vihm3N38A="
+ },
+ "signs_required": 1,
+ "title": "ZeroMe",
+ "zeronet_version": "0.4.0"
+}

+ 17 - 0
css/Activity.css

@@ -0,0 +1,17 @@
+.activity-list { margin-bottom: 30px }
+.activity-list .items a { color: #555; font-weight: bold }
+
+.activity-list .items { position: relative; margin-left: -6px; margin-bottom: -10px }
+.activity-list .bg-line {
+	height: calc(100% - 40px); width: 2px; position: absolute; background-color: #c7c7c8;
+	margin-left: 12px; margin-top: 5px; box-sizing: border-box; z-index: 0;
+}
+
+.activity-list .circle {
+	width: 8px; height: 8px; border: 2px solid #c5c5c5; position: absolute; pointer-events: none;
+	margin-left: -28px; border-radius: 15px; background-color: #f6f7f8
+}
+
+.activity { padding-left: 35px; padding-bottom: 19px; font-family: Roboto, Helvetica, Arial; font-size: 15px; line-height: 1.5em; color: #888 }
+.activity .body { top: -5px; position: relative; }
+.activity:last-child { background-color: #F6F7F8 }

+ 31 - 0
css/Button.css

@@ -0,0 +1,31 @@
+.button {
+	margin-top: 4px; border: 1px solid hsla(236,100%,79%,1); color: #5d68ff; border-radius: 33px; display: inline-block;
+	font-size: 19px; font-weight: lighter; text-align: center; transition: all 0.3s; padding: 8px 30px; background-position: -200px center;
+}
+.button:hover { background-color: #5d68ff; color: #F6F7F8; text-decoration: none; border-color: #5d68ff; transition: none }
+.button:hover .icon { background-color: #FFF; transition: none }
+.button:focus { transition: all 0.3s }
+.button:active { transform: translateY(1px); transition: all 0.3s, transform none; box-shadow: inset 0px 5px 7px -3px rgba(212, 212, 212, 0.41); outline: none; transition: none }
+
+.button.loading {
+	color: rgba(0,0,0,0) !important; background: url(../img/loading.gif) no-repeat center center !important; border-color: rgba(0,0,0,0) !important;
+	transition: all 0.5s ease-out; pointer-events: none; transition-delay: 0.5s
+}
+
+/* Follow */
+.button-follow { width: 32px; line-height: 32px; padding: 0px; border: 1px solid #aaa; color: #999; padding-left: 1px; padding-bottom: 1px; }
+.button-follow:hover { background-color: rgba(255,255,255,0.3) !important; border-color: #2ecc71 !important; color: #2ecc71 }
+.button-follow-big { padding-left: 25px; float: none; border: 1px solid #2ecc71; color: #2ecc71; min-width: 100px; }
+.button-follow-big .icon-follow { margin-right: 10px; display: inline-block; transition: transform 0.3s ease-in-out }
+.button-follow-big:hover { border-color: #2ecc71 !important; color: #2ecc71; background-color: white; text-decoration: underline; }
+
+/* Submit */
+.button-submit {
+	padding: 12px 30px; border-radius: 3px; margin-top: 11px; background-color: #5d68ff; /*box-shadow: 0px 1px 4px rgba(93, 104, 255, 0.41);*/
+	border: none; border-bottom: 2px solid #4952c7; font-weight: bold;  color: #ffffff; font-size: 12px; text-transform: uppercase; margin-left: 10px;
+}
+.button-submit:hover { color: white; background-color: #6d78ff }
+
+.button-small { padding: 7px 20px; margin-left: 10px }
+.button-outline { background-color: white; border: 1px solid #EEE; border-bottom: 2px solid #EEE; color: #AAA; }
+.button-outline:hover { background-color: white; border: 1px solid #CCC; border-bottom: 2px solid #CCC; color: #777 }

+ 10 - 0
css/Comment.css

@@ -0,0 +1,10 @@
+.comment-list {
+    background-color: #fafafa; padding-left: 80px; margin-left: -80px; padding-right: 20px; margin-right: -20px; margin-bottom: -17px; padding-bottom: 10px;
+    border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; padding-top: 19px; margin-top: 10px; border-top: 1px solid #E3E3E3; font-size: 85%;
+}
+.comment-list .body p { padding: 0px }
+
+.comment-create textarea { width: 100%; margin-bottom: 11px; box-sizing: border-box; }
+.comment { padding-top: 10px; padding-bottom: 10px }
+.comment-list .comment .user { padding-bottom: 0px }
+.comment .body { white-space: pre-wrap; padding-top: 4px }

+ 9 - 0
css/Editable.css

@@ -0,0 +1,9 @@
+.editable .icon-edit { margin-left: -24px; padding: 7px; border-radius: 30px; margin-top: -5px; position: absolute; opacity: 0; transition: all 0.3s }
+.editable:hover .icon-edit { opacity: 0.7 }
+.editable .icon-edit:hover { opacity: 1; transition: none }
+.editable .editablebuttons { text-align: right }
+.editable .empty { opacity: 0.6; }
+.editable.editing.overlay {
+	padding: 10px; margin-left: -10px; background-color: rgba(255,255,255,0.9);
+	box-shadow: 0px 0px 20px #EEE; margin-bottom: -62px; z-index: 999; position: relative;
+}

+ 15 - 0
css/Head.css

@@ -0,0 +1,15 @@
+.head-container { background-color: white; box-shadow: 0px -7px 32px rgba(0,0,0,0.15); }
+
+.head .logo {
+	height: 50px; padding: 4px 6px; box-sizing: border-box; display: inline-block;
+	color: white; font-size: 30px; font-weight: lighter; text-decoration: none
+}
+
+.head .right { float: right; }
+.head .user { display: inline-block; vertical-align: top; margin-right: 20px; text-align: right; padding-top: 7px; }
+.head .user .name { color: #5d68ff; font-weight: normal; }
+.head .user .address { display: block }
+.head .settings {
+	display: inline-block; height: 50px; width: 50px; text-align: center; vertical-align: middle;
+	border-left: 1px solid #EEE; line-height: 50px; font-size: 20px; color: #AAA; font-weight: normal; text-decoration: none;
+}

+ 6 - 0
css/Hub.css

@@ -0,0 +1,6 @@
+.hub.card { padding: 21px; text-align: left; font-size: 18px; width: 80%; display: block; margin-left: auto; margin-right: auto; }
+.hub .intro { font-weight: lighter; font-size: 16px; margin-top: 7px; }
+.hub .avatars { float: right; }
+.hub .button-join { float: right; margin-left: 20px; }
+.hub .avatar { margin-left: 5px }
+.hubselect { padding-top: 30px }

+ 27 - 0
css/Post.css

@@ -0,0 +1,27 @@
+.post {
+	background-color: white; padding: 16px 20px; padding-left: 80px; border-radius: 4px;
+	border: 1px solid #EEF0F1; border-bottom: 2px solid #ECEDEE; margin-bottom: 12px;
+}
+.post .user { padding-bottom: 15px; height: 21px; line-height: 19px; }
+.post .user .address, .post .added, .post .sep { font-size: 14px; color: #AAA;}
+.post .body { font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; line-height: 1.5em; color: #333; }
+.post .actions { height: 30px; margin-left: -5px; }
+.post .actions .icon { margin-right: 1px }
+.post .actions .link { color: #AAA; font-size: 12px; height: 30px; vertical-align: middle; line-height: 30px; display: inline-block; padding-right: 10px }
+.post .actions .link.active { color: #5d68ff; }
+.post .actions .like { width: 35px; margin-right: 0px; transition: width 0.3s, margin-right 0.3s; white-space: nowrap; }
+.post .actions .like.like-zero { width: 20px; margin-right: 5px; }
+
+.post-create { transition: all 0.6s }
+.post-create .postfield {
+	font-family: Roboto; font-size: 16px; border: 1px solid white; padding: 15px 15px;
+	font-size: 16px; width: 100%; height: 51px; box-sizing: border-box; background-color: white;
+}
+.post-create .user { margin-bottom: 0px; height: auto; padding-bottom: 0px; }
+.post-create .postbuttons { height: 55px; box-sizing: border-box; transform: scale(1); overflow: hidden; transition: all 0.3s; text-align: right }
+.post-create:not(.editing) .postbuttons { opacity: 0; height: 0px; height: 0px }
+.post-create.editing .postfield { border: 1px solid #c6caff }
+.post-create.editing { box-shadow: 0px 1px 13px 1px #eaeaea }
+.post-create .select-user-container { width: 100%; text-align: center; margin-bottom: -100px; height: 100px; z-index: 1; position: relative; margin-left: -35px; }
+
+.post-list-empty { text-align: center; padding-top: 100px }

+ 2 - 0
css/Uploadable.css

@@ -0,0 +1,2 @@
+.uploadable .icon-upload { opacity: 0; transition: all 0.3s }
+.uploadable .icon-upload:hover { opacity: 0.8; transition: all 0.1s }

+ 29 - 0
css/User.css

@@ -0,0 +1,29 @@
+.users .user { padding-left: 70px; padding-bottom: 20px }
+.users .user .nameline { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.users .user .name { line-height: 26px; }
+.users .user .intro { font-weight: 100; font-size: 13px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; min-height: 18px; }
+.users .user .added { font-size: 11px; color: #999; margin-left: 6px; }
+.users .user .intro-full { margin-left: -57px; padding-top: 18px; font-weight: 100; line-height: 1.5em; }
+.users .button-follow { float: right; margin-left: 5px; }
+.users .user.followed .button-follow, .users .user.followed .button-follow:hover {
+	background-color: #2ecc71 !important; border-color: #2ecc71 !important; color: #FFF; transform: rotate(45deg)
+}
+.users .user.followed .button-follow-big {
+	background-color: #2ecc71 !important; border-color: #2ecc71 !important; color: #FFF
+}
+.users .user.followed .button-follow-big .icon-follow { transform: rotate(45deg); }
+
+.user .name { font-weight: bold; color: #5d68ff; }
+.user .address, .user .cert_user_id { font-size: 13px; color: #AAA; }
+.user .avatar { position: absolute; margin-left: -67px }
+
+.user.card { padding: 15px; padding-left: 75px }
+.user.card.profile { margin-bottom: 30px; }
+.user.card .avatar { margin-top: -4px; position: absolute; margin-left: -60px; }
+.user.card .follow-container { margin-left: -57px; text-align: center; margin-top: 30px; margin-bottom: 20px; }
+
+.users.gray .button-follow { border: 1px solid #aaa; color: #999; }
+.users.gray .button-follow:hover, .users.gray .button-follow:active { color: #2ecc71; }
+.users.gray .name { color: #333; }
+
+.user .uploadable .icon-upload { position: absolute; margin-left: -48px; z-index: 999; margin-top: 7px }

+ 81 - 0
css/ZeroMe.css

@@ -0,0 +1,81 @@
+body {
+	background-color: #F6F7F8; font-family: Roboto, Helvetica, Arial; margin: 0px; padding: 0px;
+	backface-visibility: hidden; height: 100%; position: absolute; width: 100%; overflow-x: hidden; height: 15000px
+}
+body.loaded { height: 100%; overflow: auto }
+
+p, h1, h2, h3, h4 { margin: 0px; padding-bottom: 0.6em; }
+
+input.text, textarea { border: 1px solid #EEE; padding: 15px 15px; transition: all 0.3s; width: 100%; box-sizing: border-box; font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; }
+input.text:disabled, textarea:disabled { background-color: #FAFAFA; color: #9A9A9A }
+input.big { font-size: 20px; font-weight: 100; font-family: Roboto, Helvetica, Arial }
+input.search { border-radius: 50px; padding-left: 30px; }
+input.text:focus, textarea:focus { outline: none; border: 1px solid #c6caff }
+textarea.autosize { overflow: hidden; transition: border 0.3s, background-color 0.3s, color 0.3s }
+
+a { text-decoration: none; color: #5d68ff }
+a:hover { text-decoration: underline; }
+a:active { text-decoration: none }
+a.link:active { background-color: rgba(0,0,0,0.05); outline: 4px solid rgba(0,0,0,0.05); transition: none }
+
+h1 { font-size: 34px; }
+h1, h2, h3 { font-weight: lighter }
+h2 a { font-size: 13px; margin-left: 10px; font-weight: normal; margin-top: 8px; }
+h2.sep { border-top: 1px solid #EEE; padding-top: 20px }
+
+.center { width: 960px; margin-left: auto; margin-right: auto; }
+
+/* Content */
+#Content { margin-top: 30px; margin-bottom: 50px }
+
+.content-signup { text-align: center }
+.content-signup .button-certselect { margin: 20px; display: inline-block; }
+
+/* Cols */
+.col-left, .col-center, .col-right { width: 66%; display: inline-block; vertical-align: top; box-sizing: border-box }
+.col-left, .col-right { width: 33%; padding-left: 20px; margin-top: 90px; }
+.col-left { padding-left: 0px; padding-right: 20px; margin-top: 0px }
+
+/* Card */
+.cards { margin-right: -20px }
+.card {
+	border-radius: 4px; box-shadow: 0px 1px 11px #EAEAEA; background-color: white; width: 33%; width: calc(33% - 10px);
+	box-sizing: border-box; margin-right: 10px; margin-bottom: 10px; min-width: 300px; display: inline-block;
+}
+
+/* Avatar */
+.avatar { width: 50px; height: 50px; background: #EEE; border-radius: 100px; display: inline-block; }
+.avatar.empty { vertical-align: top; font-size: 11px; line-height: 51px; text-align: center; text-decoration: none; color: #666; font-weight: bold; }
+
+/* More */
+.more { width: 100%; display: block; clear: both; text-align: center; padding: 20px; box-sizing: border-box; box-shadow: inset 0px 9px 25px -20px #5d68ff; }
+.more.small {  font-size: 14px; box-shadow: none; padding: 10px }
+
+/* Animate */
+.animate { transition: all 0.3s ease-out !important; }
+.animate-back { transition: all 1s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; }
+.animate-inout { transition: all 0.6s cubic-bezier(0.77, 0, 0.175, 1) !important; }
+.animate-inback { transition: all 0.6s cubic-bezier(0.6, -0.28, 0.735, 0.045) !important; }
+.animate-in { transition: all 0.6s cubic-bezier(0.6, 0.04, 0.98, 0.335) !important; }
+.animate-out { transition: all 0.6s ease-out !important; }
+
+@keyframes flash-in {
+	  0% { transform: scale(1.5); opacity: 0 }
+	 80% { transform: scale(1); opacity: 1 }
+	100% { transform: scale(1); opacity: 0 }
+}
+
+@keyframes flash-in-big {
+	  0% { transform: scale(1.2); opacity: 0 }
+	 80% { transform: scale(1); opacity: 1 }
+	100% { transform: scale(1); opacity: 0 }
+}
+
+@keyframes flash-out {
+	  0% { transform: scale(1); opacity: 1 }
+	100% { transform: scale(1.5); opacity: 0 }
+}
+@keyframes flash-out-big {
+	  0% { transform: scale(1); opacity: 1 }
+	100% { transform: scale(1.2); opacity: 0 }
+}

File diff suppressed because it is too large
+ 335 - 0
css/all.css


File diff suppressed because it is too large
+ 11 - 0
css/fonts.css


+ 61 - 0
css/icons.css

@@ -0,0 +1,61 @@
+.icon {
+	display: inline-block; vertical-align: text-bottom; background-repeat: no-repeat; height: 30px;
+	vertical-align: middle; line-height: 30px; color: #AAA; font-size: 12px; transition: background-color 0.3s;
+}
+.icon.icon-button:hover { background-color: #F3F3F3; outline: 0px solid #F3F3F3; transition: none; }
+.icon.loading { pointer-events: none; animation: bounce .3s infinite alternate ease-out; animation-delay: 1s; }
+/*.icon:focus { animation: clicked 1s ease-in-out forwards; }
+
+@keyframes clicked {
+	  0% { outline: 1px solid #F3F3F3; }
+	100% { outline: 15px solid rgba(250, 108, 141, 0) }
+}*/
+
+.icon-profile { font-size: 7px; top: 1px; border-radius: 0.7em 0.7em 0 0; background: #FFF; width: 1.5em; height: 0.7em; position: relative; display: inline-block; margin-right: 7px }
+.icon-profile:before { position: absolute; content: ""; top: -1em; left: 0.38em; width: 0.8em; height: 0.85em; border-radius: 50%; background: #FFF; }
+
+/*.icon-comment { width: 16px; height: 10px; border-radius: 2px; background: #B10DC9; margin-top: 0px; display: inline-block; position: relative; top: -2px; }
+.icon-comment:after { left: 9px; border: 2px solid transparent; border-top-color: #B10DC9; border-left-color: #B10DC9; background: transparent; content: ""; display: block; margin-top: 10px; width: 0px; margin-left: 7px; }
+*/
+.icon-comment {
+	padding-left: 30px; padding-right: 10px; background-position: 5px 7px;
+	background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAASBAMAAABGPIgdAAAAG1BMVEUAAACurq6urq6urq6urq6urq6urq6urq6urq4asLPtAAAACHRSTlMAxOurFsllVoVPYlAAAAAySURBVAjXY0ABEh1g0Ahid0ABmA2Rpg+7oyEJZq9ERyvcPQwcHSZwh3KoIxzN7MCACQDJKxqoZp3x4wAAAABJRU5ErkJggg==');
+}
+.icon-comment:empty { padding-right: 0px }
+
+
+.icon-edit {
+	width: 16px; height: 16px; background-repeat: no-repeat; background-position: 6px center;
+	background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAOVBMVEUAAAC9w8e9w8e9w8e9w8e/xMi9w8e9w8e+w8e9w8e9w8e9w8e9w8e9w8e9w8e+w8e/xMi9w8e9w8fvY4+KAAAAEnRSTlMASPv3WQbwOTCkt4/psX4YDMWr+RRCAAAAUUlEQVQY06XLORKAMAxDUTs7kA3d/7AYGju0UfffjIgoHkxm0vB5bZyxKHx9eX0FJw0Y4bcXKQ4/CTtS5yqp5GFFOjGpVGl00k1pNDIb3Nv9AHC7BOZC4ZjvAAAAAElFTkSuQmCC+d0ckOwyAMRVGHUOO0gUyd+P8f7WApz4Iki9wFmyOEATrXLZcFp5LrGogPOxKp6zfFf9fZ1/I/cY7YZSS3U6S3XFZJmGBwL+FuJX/F1K0wUUlZyZGlXgXESthTEs4B8fh7xoVUDPGYJnsfkCRarKAgz8cAKbpD6pqDPz3XB8K6HdUEeN9NAAAAAElFTkSuQmCC);
+}
+.icon-reply {
+	padding-left: 30px; padding-right: 10px; background-position: 5px 6px;
+	background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAIVBMVEUAAABmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmYs5FxxAAAAC3RSTlMAgBFwYExAMHgoCDJmUTYAAAA3SURBVAjXY8APGGEMQZgAjCEoKBwEEQCCAoiIh6AQVM1kMaguJhGYOSJQjexiUMbiAChDCclCAOHqBBdHpwQTAAAAAElFTkSuQmCC);
+}
+.icon-reply:empty { padding-right: 0px }
+
+.icon-share {
+	padding-left: 32px; padding-right: 10px; background-position: 7px 5px;
+	background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUBAMAAAB/pwA+AAAAKlBMVEUAAACurq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq7nAmlHAAAADXRSTlMA48a1j0sVCfTorDQhpSwkfQAAAFdJREFUCNdjIAkkJ5y8CwSiDAzsuurcvUDmRQYGrruXGWJBwgwMvnfvOgCV8d4FEncvMJDGZLl79wqQCQRXGdbeNWBgkAUyb2woulTAwBAIEp7DZkaSawHVjTFYqPfYUwAAAABJRU5ErkJggg==')
+}
+.icon-share:empty { padding-right: 0px }
+
+.icon-heart.active { color: #5d68ff }
+.icon-heart.active:before, .icon-heart.active:after { background: #5d68ff; }
+.icon-heart { position: relative; height: 30px; padding-left: 30px; padding-right: 10px; }
+.icon-heart:empty { padding-right: 0px }
+.icon-heart:before, .icon-heart:after {
+	position: absolute; content: ""; left: 15px; top: 8px; width: 8px; height: 13px; transition: all 0.6s;
+	background: #AAA; /*#FA6C8D;*/ /*border-radius: 25px 25px 0 0;*/ transform: rotate(-45deg); transform-origin: 0 100%
+}
+.icon-heart:after { left: 7px; transform: rotate(45deg); transform-origin :100% 100% }
+.icon-up { font-weight: normal !important; font-size: 15px; font-family: Tahoma; vertical-align: -4px; padding-right: 5px; display: inline; height: 1px; }
+.icon-upload {
+	width: 26px; height: 26px; background-repeat: no-repeat;
+	background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAYAAACpSkzOAAAAmklEQVR42u3VwQ2AIAwFUEZgBEZxNDZwFEdwBDaigiHKwdBCvyYm/KQnKi8KgjEdISKbaitlzRspSKA7AY49IHisgeAwAaLHOpBxbADpxxSIHAMgMiwNroSL597KpVqqkqZ+xo3sPlEQP+yEJvRXiGuMMe4cknvY+Tkon1ktrIxZNYT69N9D7Gk7dkP7C5IstjbnZuEWG4Fk4wCMv9vjGFiLRgAAAABJRU5ErkJggg==')
+}
+
+@keyframes bounce {
+	  0% { transform: translateY(0); }
+	100% { transform: translateY(-3px); }
+}

+ 101 - 0
dbschema.json

@@ -0,0 +1,101 @@
+{
+	"db_name": "ZeroMe",
+	"db_file": "merged-ZeroMe/ZeroMe.db",
+	"version": 3,
+	"maps": {
+		".+/data/userdb/.+/content.json": {
+			"to_json_table": [ "cert_auth_type", "cert_user_id" ],
+			"to_table": ["user"]
+		},
+		".+/data/userdb/users.json": {
+			"to_table": ["user"]
+		},
+		".+/data/users/.+/content.json": {
+			"to_json_table": [ "cert_auth_type", "cert_user_id" ],
+			"file_name": "data.json"
+		},
+		".+/data/users/.+/data.json": {
+			"to_table": [
+				"post",
+				"comment",
+				"follow",
+				{"node": "post_like", "table": "post_like", "key_col": "post_uri", "val_col": "date_added"}
+			],
+			"to_json_table": [ "hub", "user_name", "avatar", "intro" ]
+		}
+	},
+	"tables": {
+		"json": {
+			"cols": [
+				["json_id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
+				["site", "TEXT"],
+				["directory", "TEXT"],
+				["file_name", "TEXT"],
+				["cert_auth_type", "TEXT"],
+				["cert_user_id", "TEXT"],
+				["hub", "TEXT"],
+				["user_name", "TEXT"],
+				["intro", "TEXT"],
+				["avatar", "TEXT"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX path ON json(directory, site, file_name)"],
+			"schema_changed": 4
+		},
+		"post": {
+			"cols": [
+				["post_id", "INTEGER"],
+				["body", "TEXT"],
+				["date_added", "INTEGER"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX post_key ON post(json_id, post_id)", "CREATE INDEX post_id ON post(post_id)"],
+			"schema_changed": 2
+		},
+		"post_like": {
+			"cols": [
+				["post_uri", "TEXT"],
+				["date_added", "INTEGER"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX post_like_key ON post_like(json_id, date_added)", "CREATE INDEX post_uri ON post_like(post_uri)"],
+			"schema_changed": 1
+		},
+		"comment": {
+			"cols": [
+				["comment_id", "INTEGER"],
+				["post_uri", "TEXT"],
+				["body", "TEXT"],
+				["date_added", "INTEGER"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX comment_key ON comment(json_id, comment_id)", "CREATE INDEX comment_post_uri ON comment(post_uri)"],
+			"schema_changed": 2
+		},
+		"follow": {
+			"cols": [
+				["follow_id", "INTEGER"],
+				["user_name", "TEXT"],
+				["auth_address", "TEXT"],
+				["hub", "TEXT"],
+				["date_added", "INTEGER"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX follow_key ON follow(json_id, follow_id)"],
+			"schema_changed": 2
+		},
+		"user": {
+			"cols": [
+				["auth_address", "TEXT"],
+				["cert_user_id", "TEXT"],
+				["hub", "TEXT"],
+				["user_name", "TEXT"],
+				["avatar", "TEXT"],
+				["intro", "TEXT"],
+				["date_added", "INTEGER"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE INDEX json_id ON user(json_id)", "CREATE INDEX date_added ON user(date_added)"],
+			"schema_changed": 3
+		}
+	}
+}

BIN
img/loading-circle.gif


BIN
img/loading.gif


BIN
img/logo.png


BIN
img/unkown.png


+ 24 - 0
index.html

@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <title>ZeroMe!</title>
+ <meta charset="utf-8">
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <link rel="stylesheet" href="css/all.css" />
+ <base href="" target="_top" id="base">
+ <script>base.href = document.location.href.replace("/media", "").replace("index.html", "").replace(/[&?]wrapper=False/, "").replace(/[&?]wrapper_nonce=[A-Za-z0-9]+/, "")</script>
+</head>
+<body>
+
+<div class="head-container">
+ <div id="Head"></div>
+</div>
+
+<div class="center" id="Content">
+</div>
+
+<script type="text/javascript" src="js/all.js" asyc></script>
+
+</body>
+</html>

+ 1035 - 0
js-external/pngencoder.js

@@ -0,0 +1,1035 @@
+/** @license CanvasTool.PngEncoder 2012 - imaya [ https://github.com/imaya/CanvasTool.PngEncoder ] The MIT License */
+(function() {'use strict';var aa=this;function j(a,c,b){a=a.split(".");b=b||aa;!(a[0]in b)&&b.execScript&&b.execScript("var "+a[0]);for(var e;a.length&&(e=a.shift());)!a.length&&void 0!==c?b[e]=c:b=b[e]?b[e]:b[e]={}}Math.floor(2147483648*Math.random()).toString(36);var m="undefined"!==typeof Uint8Array&&"undefined"!==typeof Uint16Array&&"undefined"!==typeof Uint32Array;function n(a,c){this.index="number"===typeof c?c:0;this.r=0;this.buffer=a instanceof(m?Uint8Array:Array)?a:new (m?Uint8Array:Array)(32768);if(2*this.buffer.length<=this.index)throw Error("invalid index");this.buffer.length<=this.index&&this.I()}n.prototype.I=function(){var a=this.buffer,c,b=a.length,e=new (m?Uint8Array:Array)(b<<1);if(m)e.set(a);else for(c=0;c<b;++c)e[c]=a[c];return this.buffer=e};
+n.prototype.d=function(a,c,b){var e=this.buffer,d=this.index,f=this.r,g=e[d];b&&1<c&&(a=8<c?(r[a&255]<<24|r[a>>>8&255]<<16|r[a>>>16&255]<<8|r[a>>>24&255])>>32-c:r[a]>>8-c);if(8>c+f)g=g<<c|a,f+=c;else for(b=0;b<c;++b)g=g<<1|a>>c-b-1&1,8===++f&&(f=0,e[d++]=r[g],g=0,d===e.length&&(e=this.I()));e[d]=g;this.buffer=e;this.r=f;this.index=d};n.prototype.finish=function(){var a=this.buffer,c=this.index;0<this.r&&(a[c]<<=8-this.r,a[c]=r[a[c]],c++);m?a=a.subarray(0,c):a.length=c;return a};
+var ba=new (m?Uint8Array:Array)(256),w;for(w=0;256>w;++w){for(var ca=ba,da=w,x=w,z=x,ea=7,x=x>>>1;x;x>>>=1)z<<=1,z|=x&1,--ea;ca[da]=(z<<ea&255)>>>0}var r=ba;var A={ca:function(a,c,b){return A.update(a,0,c,b)},update:function(a,c,b,e){for(var d=A.aa,f="number"===typeof b?b:b=0,e="number"===typeof e?e:a.length,c=c^4294967295,f=e&7;f--;++b)c=c>>>8^d[(c^a[b])&255];for(f=e>>3;f--;b+=8)c=c>>>8^d[(c^a[b])&255],c=c>>>8^d[(c^a[b+1])&255],c=c>>>8^d[(c^a[b+2])&255],c=c>>>8^d[(c^a[b+3])&255],c=c>>>8^d[(c^a[b+4])&255],c=c>>>8^d[(c^a[b+5])&255],c=c>>>8^d[(c^a[b+6])&255],c=c>>>8^d[(c^a[b+7])&255];return(c^4294967295)>>>0}},fa,ga=[0,1996959894,3993919788,2567524794,
+124634137,1886057615,3915621685,2657392035,249268274,2044508324,3772115230,2547177864,162941995,2125561021,3887607047,2428444049,498536548,1789927666,4089016648,2227061214,450548861,1843258603,4107580753,2211677639,325883990,1684777152,4251122042,2321926636,335633487,1661365465,4195302755,2366115317,997073096,1281953886,3579855332,2724688242,1006888145,1258607687,3524101629,2768942443,901097722,1119000684,3686517206,2898065728,853044451,1172266101,3705015759,2882616665,651767980,1373503546,3369554304,
+3218104598,565507253,1454621731,3485111705,3099436303,671266974,1594198024,3322730930,2970347812,795835527,1483230225,3244367275,3060149565,1994146192,31158534,2563907772,4023717930,1907459465,112637215,2680153253,3904427059,2013776290,251722036,2517215374,3775830040,2137656763,141376813,2439277719,3865271297,1802195444,476864866,2238001368,4066508878,1812370925,453092731,2181625025,4111451223,1706088902,314042704,2344532202,4240017532,1658658271,366619977,2362670323,4224994405,1303535960,984961486,
+2747007092,3569037538,1256170817,1037604311,2765210733,3554079995,1131014506,879679996,2909243462,3663771856,1141124467,855842277,2852801631,3708648649,1342533948,654459306,3188396048,3373015174,1466479909,544179635,3110523913,3462522015,1591671054,702138776,2966460450,3352799412,1504918807,783551873,3082640443,3233442989,3988292384,2596254646,62317068,1957810842,3939845945,2647816111,81470997,1943803523,3814918930,2489596804,225274430,2053790376,3826175755,2466906013,167816743,2097651377,4027552580,
+2265490386,503444072,1762050814,4150417245,2154129355,426522225,1852507879,4275313526,2312317920,282753626,1742555852,4189708143,2394877945,397917763,1622183637,3604390888,2714866558,953729732,1340076626,3518719985,2797360999,1068828381,1219638859,3624741850,2936675148,906185462,1090812512,3747672003,2825379669,829329135,1181335161,3412177804,3160834842,628085408,1382605366,3423369109,3138078467,570562233,1426400815,3317316542,2998733608,733239954,1555261956,3268935591,3050360625,752459403,1541320221,
+2607071920,3965973030,1969922972,40735498,2617837225,3943577151,1913087877,83908371,2512341634,3803740692,2075208622,213261112,2463272603,3855990285,2094854071,198958881,2262029012,4057260610,1759359992,534414190,2176718541,4139329115,1873836001,414664567,2282248934,4279200368,1711684554,285281116,2405801727,4167216745,1634467795,376229701,2685067896,3608007406,1308918612,956543938,2808555105,3495958263,1231636301,1047427035,2932959818,3654703836,1088359270,936918E3,2847714899,3736837829,1202900863,
+817233897,3183342108,3401237130,1404277552,615818150,3134207493,3453421203,1423857449,601450431,3009837614,3294710456,1567103746,711928724,3020668471,3272380065,1510334235,755167117];fa=m?new Uint32Array(ga):ga;A.aa=fa;function B(a){this.buffer=new (m?Uint16Array:Array)(2*a);this.length=0}B.prototype.getParent=function(a){return 2*((a-2)/4|0)};B.prototype.ra=function(a){return 2*a+2};B.prototype.push=function(a,c){var b,e,d=this.buffer,f;b=this.length;d[this.length++]=c;for(d[this.length++]=a;0<b;)if(e=this.getParent(b),d[b]>d[e])f=d[b],d[b]=d[e],d[e]=f,f=d[b+1],d[b+1]=d[e+1],d[e+1]=f,b=e;else break;return this.length};
+B.prototype.pop=function(){var a,c,b=this.buffer,e,d,f;c=b[0];a=b[1];this.length-=2;b[0]=b[this.length];b[1]=b[this.length+1];for(f=0;;){d=this.ra(f);if(d>=this.length)break;d+2<this.length&&b[d+2]>b[d]&&(d+=2);if(b[d]>b[f])e=b[f],b[f]=b[d],b[d]=e,e=b[f+1],b[f+1]=b[d+1],b[d+1]=e;else break;f=d}return{index:a,value:c,length:this.length}};function C(a){var c=a.length,b=0,e=Number.POSITIVE_INFINITY,d,f,g,h,k,p,l,o,i;for(o=0;o<c;++o)a[o]>b&&(b=a[o]),a[o]<e&&(e=a[o]);d=1<<b;f=new (m?Uint32Array:Array)(d);g=1;h=0;for(k=2;g<=b;){for(o=0;o<c;++o)if(a[o]===g){p=0;l=h;for(i=0;i<g;++i)p=p<<1|l&1,l>>=1;for(i=p;i<d;i+=k)f[i]=g<<16|o;++h}++g;h<<=1;k<<=1}return[f,b,e]};function D(a,c){this.p=E;this.P=0;this.input=a;this.h=0;if(c&&(c.lazy&&(this.P=c.lazy),"number"===typeof c.compressionType&&(this.p=c.compressionType),c.outputBuffer&&(this.b=m&&c.outputBuffer instanceof Array?new Uint8Array(c.outputBuffer):c.outputBuffer),"number"===typeof c.outputIndex))this.h=c.outputIndex;this.b||(this.b=new (m?Uint8Array:Array)(32768))}var E=2,ha={NONE:0,Z:1,F:E,ib:3},F=[],G;
+for(G=0;288>G;G++)switch(!0){case 143>=G:F.push([G+48,8]);break;case 255>=G:F.push([G-144+400,9]);break;case 279>=G:F.push([G-256+0,7]);break;case 287>=G:F.push([G-280+192,8]);break;default:throw"invalid literal: "+G;}
+D.prototype.o=function(){var a,c,b,e=this.input;switch(this.p){case 0:c=0;for(b=e.length;c<b;)a=m?e.subarray(c,c+65535):e.slice(c,c+65535),c+=a.length,this.Fa(a,c===b);break;case 1:this.b=this.Ba(e,!0);this.h=this.b.length;break;case E:this.b=this.Aa(e,!0);this.h=this.b.length;break;default:throw"invalid compression type";}return this.b};
+D.prototype.Fa=function(a,c){var b,e,d=this.b,f=this.h;if(m){for(d=new Uint8Array(this.b.buffer);d.length<=f+a.length+5;)d=new Uint8Array(d.length<<1);d.set(this.b)}d[f++]=(c?1:0)|0;b=a.length;e=~b+65536&65535;d[f++]=b&255;d[f++]=b>>>8&255;d[f++]=e&255;d[f++]=e>>>8&255;if(m)d.set(a,f),f+=a.length,d=d.subarray(0,f);else{b=0;for(e=a.length;b<e;++b)d[f++]=a[b];d.length=f}this.h=f;return this.b=d};
+D.prototype.Ba=function(a,c){var b=new n(new Uint8Array(this.b.buffer),this.h);b.d(c?1:0,1,!0);b.d(1,2,!0);this.la(this.Q(a),b);return b.finish()};
+D.prototype.Aa=function(a,c){var b=new n(new Uint8Array(this.b),this.h),e,d,f,g,h=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],k,p,l,o,i,t,v=Array(19),u;e=E;b.d(c?1:0,1,!0);b.d(e,2,!0);e=this.Q(a);k=this.A(this.oa,15);p=this.z(k);l=this.A(this.na,7);o=this.z(l);for(d=286;257<d&&0===k[d-1];d--);for(f=30;1<f&&0===l[f-1];f--);i=this.va(d,k,f,l);t=this.A(i.ma,7);for(u=0;19>u;u++)v[u]=t[h[u]];for(g=19;4<g&&0===v[g-1];g--);h=this.z(t);b.d(d-257,5,!0);b.d(f-1,5,!0);b.d(g-4,4,!0);for(u=0;u<g;u++)b.d(v[u],
+3,!0);u=0;for(v=i.v.length;u<v;u++)if(d=i.v[u],b.d(h[d],t[d],!0),16<=d){u++;switch(d){case 16:d=2;break;case 17:d=3;break;case 18:d=7;break;default:throw"invalid code: "+d;}b.d(i.v[u],d,!0)}this.ea(e,[p,k],[o,l],b);return b.finish()};D.prototype.ea=function(a,c,b,e){var d,f,g,h,k;g=c[0];c=c[1];h=b[0];k=b[1];b=0;for(d=a.length;b<d;++b)if(f=a[b],e.d(g[f],c[f],!0),256<f)e.d(a[++b],a[++b],!0),f=a[++b],e.d(h[f],k[f],!0),e.d(a[++b],a[++b],!0);else if(256===f)break;return e};
+D.prototype.la=function(a,c){var b,e,d;b=0;for(e=a.length;b<e;b++)if(d=a[b],n.prototype.d.apply(c,F[d]),256<d)c.d(a[++b],a[++b],!0),c.d(a[++b],5),c.d(a[++b],a[++b],!0);else if(256===d)break;return c};function H(a,c){this.length=a;this.ba=c}
+function ia(a){switch(!0){case 3===a:return[257,a-3,0];case 4===a:return[258,a-4,0];case 5===a:return[259,a-5,0];case 6===a:return[260,a-6,0];case 7===a:return[261,a-7,0];case 8===a:return[262,a-8,0];case 9===a:return[263,a-9,0];case 10===a:return[264,a-10,0];case 12>=a:return[265,a-11,1];case 14>=a:return[266,a-13,1];case 16>=a:return[267,a-15,1];case 18>=a:return[268,a-17,1];case 22>=a:return[269,a-19,2];case 26>=a:return[270,a-23,2];case 30>=a:return[271,a-27,2];case 34>=a:return[272,a-31,2];case 42>=
+a:return[273,a-35,3];case 50>=a:return[274,a-43,3];case 58>=a:return[275,a-51,3];case 66>=a:return[276,a-59,3];case 82>=a:return[277,a-67,4];case 98>=a:return[278,a-83,4];case 114>=a:return[279,a-99,4];case 130>=a:return[280,a-115,4];case 162>=a:return[281,a-131,5];case 194>=a:return[282,a-163,5];case 226>=a:return[283,a-195,5];case 257>=a:return[284,a-227,5];case 258===a:return[285,a-258,0];default:throw"invalid length: "+a;}}var I=[],J,K;for(J=3;258>=J;J++)K=ia(J),I[J]=K[2]<<24|K[1]<<16|K[0];
+var ja=m?new Uint32Array(I):I;
+H.prototype.sa=function(a){switch(!0){case 1===a:a=[0,a-1,0];break;case 2===a:a=[1,a-2,0];break;case 3===a:a=[2,a-3,0];break;case 4===a:a=[3,a-4,0];break;case 6>=a:a=[4,a-5,1];break;case 8>=a:a=[5,a-7,1];break;case 12>=a:a=[6,a-9,2];break;case 16>=a:a=[7,a-13,2];break;case 24>=a:a=[8,a-17,3];break;case 32>=a:a=[9,a-25,3];break;case 48>=a:a=[10,a-33,4];break;case 64>=a:a=[11,a-49,4];break;case 96>=a:a=[12,a-65,5];break;case 128>=a:a=[13,a-97,5];break;case 192>=a:a=[14,a-129,6];break;case 256>=a:a=
+[15,a-193,6];break;case 384>=a:a=[16,a-257,7];break;case 512>=a:a=[17,a-385,7];break;case 768>=a:a=[18,a-513,8];break;case 1024>=a:a=[19,a-769,8];break;case 1536>=a:a=[20,a-1025,9];break;case 2048>=a:a=[21,a-1537,9];break;case 3072>=a:a=[22,a-2049,10];break;case 4096>=a:a=[23,a-3073,10];break;case 6144>=a:a=[24,a-4097,11];break;case 8192>=a:a=[25,a-6145,11];break;case 12288>=a:a=[26,a-8193,12];break;case 16384>=a:a=[27,a-12289,12];break;case 24576>=a:a=[28,a-16385,13];break;case 32768>=a:a=[29,a-
+24577,13];break;default:throw"invalid distance";}return a};H.prototype.fb=function(){var a=this.ba,c=[],b=0,e;e=ja[this.length];c[b++]=e&65535;c[b++]=e>>16&255;c[b++]=e>>24;e=this.sa(a);c[b++]=e[0];c[b++]=e[1];c[b++]=e[2];return c};
+D.prototype.Q=function(a){function c(a,b){var c=a.fb(),d,e;d=0;for(e=c.length;d<e;++d)p[l++]=c[d];i[c[0]]++;t[c[3]]++;o=a.length+b-1;k=null}var b,e,d,f,g,h={},k,p=m?new Uint16Array(2*a.length):[],l=0,o=0,i=new (m?Uint32Array:Array)(286),t=new (m?Uint32Array:Array)(30),v=this.P;if(!m){for(d=0;285>=d;)i[d++]=0;for(d=0;29>=d;)t[d++]=0}i[256]=1;b=0;for(e=a.length;b<e;++b){d=g=0;for(f=3;d<f&&b+d!==e;++d)g=g<<8|a[b+d];void 0===h[g]&&(h[g]=[]);d=h[g];if(!(0<o--)){for(;0<d.length&&32768<b-d[0];)d.shift();
+if(b+3>=e){k&&c(k,-1);d=0;for(f=e-b;d<f;++d)g=a[b+d],p[l++]=g,++i[g];break}0<d.length?(f=this.ab(a,b,d),k?k.length<f.length?(g=a[b-1],p[l++]=g,++i[g],c(f,0)):c(k,-1):f.length<v?k=f:c(f,0)):k?c(k,-1):(g=a[b],p[l++]=g,++i[g])}d.push(b)}p[l++]=256;i[256]++;this.oa=i;this.na=t;return m?p.subarray(0,l):p};
+D.prototype.ab=function(a,c,b){var e,d,f=0,g,h,k,p=a.length;h=0;k=b.length;a:for(;h<k;h++){e=b[k-h-1];g=3;if(3<f){for(g=f;3<g;g--)if(a[e+g-1]!==a[c+g-1])continue a;g=f}for(;258>g&&c+g<p&&a[e+g]===a[c+g];)++g;g>f&&(d=e,f=g);if(258===g)break}return new H(f,c-d)};
+D.prototype.va=function(a,c,b,e){var d=new (m?Uint32Array:Array)(a+b),f,g,h=new (m?Uint32Array:Array)(316),k=new (m?Uint8Array:Array)(19);for(f=g=0;f<a;f++)d[g++]=c[f];for(f=0;f<b;f++)d[g++]=e[f];if(!m){f=0;for(c=k.length;f<c;++f)k[f]=0}f=b=0;for(c=d.length;f<c;f+=g){for(g=1;f+g<c&&d[f+g]===d[f];++g);a=g;if(0===d[f])if(3>a)for(;0<a--;)h[b++]=0,k[0]++;else for(;0<a;)e=138>a?a:138,e>a-3&&e<a&&(e=a-3),10>=e?(h[b++]=17,h[b++]=e-3,k[17]++):(h[b++]=18,h[b++]=e-11,k[18]++),a-=e;else if(h[b++]=d[f],k[d[f]]++,
+a--,3>a)for(;0<a--;)h[b++]=d[f],k[d[f]]++;else for(;0<a;)e=6>a?a:6,e>a-3&&e<a&&(e=a-3),h[b++]=16,h[b++]=e-3,k[16]++,a-=e}return{v:m?h.subarray(0,b):h.slice(0,b),ma:k}};
+D.prototype.A=function(a,c){var b=a.length,e=new B(572),d=new (m?Uint8Array:Array)(b),f,g,h;if(!m)for(g=0;g<b;g++)d[g]=0;for(g=0;g<b;++g)0<a[g]&&e.push(g,a[g]);b=Array(e.length/2);f=new (m?Uint32Array:Array)(e.length/2);if(1===b.length)return d[e.pop().index]=1,d;g=0;for(h=e.length/2;g<h;++g)b[g]=e.pop(),f[g]=b[g].value;e=this.Ya(f,f.length,c);g=0;for(h=b.length;g<h;++g)d[b[g].index]=e[g];return d};
+D.prototype.Ya=function(a,c,b){function e(a){var b=k[a][p[a]];b===c?(e(a+1),e(a+1)):--g[b];++p[a]}var d=new (m?Uint16Array:Array)(b),f=new (m?Uint8Array:Array)(b),g=new (m?Uint8Array:Array)(c),h=Array(b),k=Array(b),p=Array(b),l=(1<<b)-c,o=1<<b-1,i,t;d[b-1]=c;for(i=0;i<b;++i)l<o?f[i]=0:(f[i]=1,l-=o),l<<=1,d[b-2-i]=(d[b-1-i]/2|0)+c;d[0]=f[0];h[0]=Array(d[0]);k[0]=Array(d[0]);for(i=1;i<b;++i)d[i]>2*d[i-1]+f[i]&&(d[i]=2*d[i-1]+f[i]),h[i]=Array(d[i]),k[i]=Array(d[i]);for(l=0;l<c;++l)g[l]=b;for(o=0;o<d[b-
+1];++o)h[b-1][o]=a[o],k[b-1][o]=o;for(l=0;l<b;++l)p[l]=0;1===f[b-1]&&(--g[0],++p[b-1]);for(i=b-2;0<=i;--i){b=l=0;t=p[i+1];for(o=0;o<d[i];o++)b=h[i+1][t]+h[i+1][t+1],b>a[l]?(h[i][o]=b,k[i][o]=c,t+=2):(h[i][o]=a[l],k[i][o]=l,++l);p[i]=0;1===f[i]&&e(i)}return g};
+D.prototype.z=function(a){var c=new (m?Uint16Array:Array)(a.length),b=[],e=[],d=0,f,g,h;f=0;for(g=a.length;f<g;f++)b[a[f]]=(b[a[f]]|0)+1;f=1;for(g=16;f<=g;f++){e[f]=d;d+=b[f]|0;if(d>1<<f)throw"overcommitted";d<<=1}if(65536>d)throw"undercommitted";f=0;for(g=a.length;f<g;f++){d=e[a[f]];e[a[f]]+=1;b=c[f]=0;for(h=a[f];b<h;b++)c[f]=c[f]<<1|d&1,d>>>=1}return c};function L(a,c){this.input=a;this.b=new (m?Uint8Array:Array)(32768);this.p=M.F;var b={},e;if((c||!(c={}))&&"number"===typeof c.compressionType)this.p=c.compressionType;for(e in c)b[e]=c[e];b.outputBuffer=this.b;this.U=new D(this.input,b)}var M=ha;
+L.prototype.o=function(){var a,c,b,e=0;b=this.b;a=N;switch(a){case N:c=Math.LOG2E*Math.log(32768)-8;break;default:throw Error("invalid compression method");}c=c<<4|a;b[e++]=c;switch(a){case N:switch(this.p){case M.NONE:a=0;break;case M.Z:a=1;break;case M.F:a=2;break;default:throw Error("unsupported compression type");}break;default:throw Error("invalid compression method");}a=a<<6|0;b[e++]=a|31-(256*c+a)%31;b=this.input;if("string"===typeof b){b=b.split("");c=0;for(a=b.length;c<a;c++)b[c]=(b[c].charCodeAt(0)&
+255)>>>0}c=1;a=0;for(var d=b.length,f,g=0;0<d;){f=1024<d?1024:d;d-=f;do c+=b[g++],a+=c;while(--f);c%=65521;a%=65521}c=(a<<16|c)>>>0;this.U.h=e;b=this.U.o();e=b.length;m&&(b=new Uint8Array(b.buffer),b.length<=e+4&&(this.b=new Uint8Array(b.length+4),this.b.set(b),b=this.b),b=b.subarray(0,e+4));b[e++]=c>>24&255;b[e++]=c>>16&255;b[e++]=c>>8&255;b[e++]=c&255;return b};var ka=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];m&&new Uint16Array(ka);var la=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,258,258];m&&new Uint16Array(la);var ma=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0];m&&new Uint8Array(ma);var na=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577];m&&new Uint16Array(na);
+var oa=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13];m&&new Uint8Array(oa);var O=new (m?Uint8Array:Array)(288),P,pa;P=0;for(pa=O.length;P<pa;++P)O[P]=143>=P?8:255>=P?9:279>=P?7:8;C(O);var qa=new (m?Uint8Array:Array)(30),Q,ra;Q=0;for(ra=qa.length;Q<ra;++Q)qa[Q]=5;C(qa);var sa=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];m&&new Uint16Array(sa);var ta=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,258,258];m&&new Uint16Array(ta);var ua=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0];m&&new Uint8Array(ua);var va=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577];m&&new Uint16Array(va);
+var wa=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13];m&&new Uint8Array(wa);var xa=new (m?Uint8Array:Array)(288),R,ya;R=0;for(ya=xa.length;R<ya;++R)xa[R]=143>=R?8:255>=R?9:279>=R?7:8;C(xa);var za=new (m?Uint8Array:Array)(30),S,Aa;S=0;for(Aa=za.length;S<Aa;++S)za[S]=5;C(za);var N=8;function T(a,c){var b,e,d;if(a instanceof Element)e=a.width,d=a.height,b=a.getContext("2d"),this.data=b.getImageData(0,0,e,d).data;else if("number"===typeof a.length){if("object"!==typeof c)throw Error("need opt_param object");if("number"!==typeof c.width)throw Error("width property not found");if("number"!==typeof c.height)throw Error("height property not found");e=c.width;d=c.height;this.data=a}else throw Error("invalid arguments");this.bb(e,d,c)}
+T.prototype.bb=function(a,c,b){"object"!==typeof b&&(b={});this.width=a;this.height=c;this.e="number"===typeof b.bitDepth?b.bitDepth:8;this.f="number"===typeof b.colourType?b.colourType:U;this.g="number"===typeof b.compressionMethod?b.compressionMethod:V;this.w="number"===typeof b.filterMethod?b.filterMethod:W;this.J="number"===typeof b.filterType?b.filterType:Ba;this.M="number"===typeof b.interlaceMethod?b.interlaceMethod:Ca;this.wa=!1;this.X=!0;this.t=b.deflateOption;this.q=null;this.s=[];this.B=
+[];this.hb()};var Da=X("IHDR"),Ea=X("PLTE"),Fa=X("IDAT"),Ga=X("IEND"),Ha=X("tRNS"),Ia=X("gAMA"),Ja=X("cHRM"),Ka=X("sBIT"),La=X("sRGB"),Ma=X("iCCP"),Na=X("bKGD"),Oa=X("hIST"),Pa=X("pHYs"),Qa=X("sPLT"),Ra=X("tEXt"),Sa=X("zTXt"),Ta=X("iTXt"),Ua=X("tIME"),V=0,U=6,W=0,Ba=0,Ca=0,Va=[137,80,78,71,13,10,26,10],Wa=[{i:0,k:0,j:8,l:8},{i:4,k:0,j:8,l:8},{i:0,k:4,j:4,l:8},{i:2,k:0,j:4,l:4},{i:0,k:2,j:2,l:4},{i:1,k:0,j:2,l:2},{i:0,k:1,j:1,l:2}];
+T.prototype.da=function(){for(var a=this.H(),c=[],b=0,e=a.length;b<e;b++)c[b]=String.fromCharCode(a[b]);return c.join("")};
+T.prototype.H=function(){var a=[],c;c=this.R(this.data);Y(a,Va);Y(a,this.Ea());"object"===typeof this.G&&null!==this.G&&Y(a,this.Ia(this.G));"number"===typeof this.pa&&Y(a,this.Ja(this.pa));"object"===typeof this.L&&null!==this.L&&Y(a,this.La(this.L));this.$a instanceof Array&&Y(a,this.Oa(this.$a));"number"===typeof this.cb&&Y(a,this.Qa(this.cb));switch(this.f){case 3:Y(a,this.Ga(c.u));this.C=c.u;this.m instanceof Array&&Y(a,this.Ha(this.m,this.C));this.wa&&Y(a,this.Ka(this.B));this.X&&Y(a,this.Ta(c.eb));
+break;case 0:case 2:case 4:case U:break;default:throw Error("unknown colour type");}"object"===typeof this.S&&null!==this.S&&Y(a,this.Na(this.S));"object"===typeof this.W&&null!==this.W&&Y(a,this.Pa(this.W,this.s));this.time instanceof Date&&Y(a,this.Sa(this.time));"object"===typeof this.text&&null!==this.text&&Y(a,this.Ra(this.text));"object"===typeof this.Y&&null!==this.Y&&Y(a,this.Ua(this.Y));"object"===typeof this.N&&null!==this.N&&Y(a,this.Ma(this.N));Y(a,this.Ca(c.$));Y(a,this.Da());return a};
+T.prototype.lb=function(){return this.C instanceof Array?this.C:this.R(this.data).u.map(function(a){return a.split("").map(function(a){return a.charCodeAt(0)})})};T.prototype.hb=function(){var a,c,b,e=!1;switch(this.f){case 0:a=[1,2,4,8,16];break;case 3:a=[1,2,4,8];break;case 2:case 4:case U:a=[8,16];break;default:throw Error("invalid colour type");}c=0;for(b=a.length;c<b;c++)if(this.e===a[c]){e=!0;break}if(!1===e)throw Error("invalid parameter");};
+T.prototype.Ea=function(){var a=[];Y(a,this.a(this.width,4));Y(a,this.a(this.height,4));Y(a,this.a(this.e,1));Y(a,this.a(this.f,1));Y(a,this.a(this.g,1));Y(a,this.a(this.w,1));Y(a,this.a(this.M,1));return this.c(Da,a)};
+T.prototype.R=function(a){var c=[],b=this.X,e=this.e,d=[],f=[],g={},h={},k=[],p=0,l=0,o=0,i=0,t={},v=0,u=0,s,q,y;q=0;for(y=a.length;q<y;q+=4)s=b?this.V(this.n(a,q,4)):this.D(this.n(a,q,3)),g[s]=(g[s]|0)+1,p=a[q],l=a[q+1],o=a[q+2],i=a[q+3],v=((p<<8|l)<<8|o)<<8|i,void 0===t[v]&&(u=this.s.length,this.s.push({red:p,green:l,blue:o,alpha:i,count:0}),t[v]=u),this.s[t[v]].count++;p=0<(this.f&4);switch(this.f){case 4:case 0:q=0;for(y=a.length;q<y;q+=4)s=this.Za.apply(this,this.n(a,q,3)),i=a[q+3],8>e&&(s>>>=
+8-e,i>>>=8-e),s=[s],p&&s.push(i),c.push(s);break;case 2:case U:q=0;for(y=a.length;q<y;q+=4)b=this.n(a,q,p?4:3),c.push(b);break;case 3:for(s in g)k.push(s);b&&k.sort(function(a,b){return a.charCodeAt(3)<b.charCodeAt(3)?-1:a.charCodeAt(3)>b.charCodeAt(3)?1:a.charCodeAt(0)<b.charCodeAt(0)?-1:a.charCodeAt(0)>b.charCodeAt(0)?1:a.charCodeAt(1)<b.charCodeAt(1)?-1:a.charCodeAt(1)>b.charCodeAt(1)?1:a.charCodeAt(2)<b.charCodeAt(2)?-1:a.charCodeAt(2)>b.charCodeAt(2)?1:0});q=0;for(y=k.length;q<y;q++)s=k[q],b?
+(255!==s.charCodeAt(3)&&(f[q]=s.charCodeAt(3)),h[s]=q):h[s.slice(0,3)]=q,d.push(s.charCodeAt(0)),d.push(s.charCodeAt(1)),d.push(s.charCodeAt(2));if(this.m instanceof Array){if(3!==this.m.length)throw Error("wrong background-color length");if(!(this.D(this.m)in g)){if(d.length/3===1<<this.e)throw Error("can not add background-color to palette");d.push(this.m[0]);d.push(this.m[1]);d.push(this.m[2])}}if(d.length/3>1<<this.e)throw Error("over "+(1<<this.e)+" colors: "+d.length/3);q=0;for(y=d.length/3;q<
+y;q++)this.B[q]=0;q=0;for(y=a.length;q<y;q+=4)s=b?this.V(this.n(a,q,4)):this.D(this.n(a,q,3)),this.B[h[s]]++,c.push([h[s]]);break;default:throw Error("invalid colour type");}return{u:d,eb:f,$:c}};T.prototype.Ia=function(a){var c=[];Y(c,this.a(1E4*a.rb|0,4));Y(c,this.a(1E4*a.sb|0,4));Y(c,this.a(1E4*a.ob|0,4));Y(c,this.a(1E4*a.pb|0,4));Y(c,this.a(1E4*a.mb|0,4));Y(c,this.a(1E4*a.nb|0,4));Y(c,this.a(1E4*a.jb|0,4));Y(c,this.a(1E4*a.kb|0,4));return this.c(Ja,c)};
+T.prototype.Ja=function(a){return this.c(Ia,this.a(1E5/a+0.5|0,4))};
+T.prototype.Oa=function(a){var c=[];switch(this.f){case 0:if(1!==a.length)throw Error("wrong sBIT length");Y(c,a.slice(0,1));break;case 2:if(3!==a.length)throw Error("wrong sBIT length");Y(c,a.slice(0,3));break;case 3:if(3!==a.length)throw Error("wrong sBIT length");Y(c,a.slice(0,3));break;case 4:if(2!==a.length)throw Error("wrong sBIT length");Y(c,a.slice(0,2));break;case U:if(4!==a.length)throw Error("wrong sBIT length");Y(c,a.slice(0,4));break;default:throw Error("unknown colour type");}return this.c(Ka,
+c)};T.prototype.Qa=function(a){return this.c(La,[a])};T.prototype.La=function(a){var c=[],b,e,d;b=X(a.name);d=b.length;if(79<d)throw Error("ICCP Profile name is over 79 characters");for(e=0;e<d;e++)if(32>b[e]||126<b[e]&&161>b[e]||255<b[e])throw Error("wrong iccp profile name.");Y(c,b);c.push(0);c.push(a.g);switch(a.g){case V:Y(c,(new L(a.profile,this.t)).o());break;default:throw Error("unknown ICC Profile compression method");}return this.c(Ma,c)};
+T.prototype.Ha=function(a,c){var b=[],e=null,d,f;switch(this.f){case 0:case 4:if(1!==a.length)throw Error("wrong background-color length");Y(b,this.a(a[0],2));break;case 2:case U:if(3!==a.length)throw Error("wrong background-color length");Y(b,this.a(a[0],2));Y(b,this.a(a[1],2));Y(b,this.a(a[2],2));break;case 3:if(3!==a.length)throw Error("wrong background-color length");d=0;for(f=c.length;d<f;d+=3)c[d+0]===a[0]&&(c[d+1]===a[1]&&c[d+2]===a[2])&&(e=d/3);if(null===e)return[];b.push(e);break;default:throw Error("unknown colour type");
+}return this.c(Na,b)};T.prototype.Ka=function(a){for(var c=[],b,e=b=0,d=a.length;e<d;e++)b=b<a[e]||0===e?a[e]:b;for(var d=e=0,f=a.length;d<f;d++)e=a[d],e=0===e?0:65534*(e/b)+1.5|0,Y(c,this.a(e,2));return this.c(Oa,c)};
+T.prototype.Pa=function(a,c){var b=[],e,d=0,f=0,g=0>a.Va?c.length:a.Va,h=0;if(0===g)return[];Y(b,X(a.name));b.push(0);switch(this.e){case 16:b.push(16);break;case 8:case 4:case 2:case 1:b.push(8);break;default:throw Error("invalid bit depth");}e=c.sort(function(a,b){return a.count<b.count?1:a.count>b.count?-1:0});for(d=e[0].count;f<g;f++){c=e[f];switch(this.e){case 16:Y(b,this.a(c.red<<8|c.red,2));Y(b,this.a(c.green<<8|c.green,2));Y(b,this.a(c.blue<<8|c.blue,2));Y(b,this.a(c.alpha<<8|c.alpha,2));
+break;case 8:case 4:case 2:case 1:b.push(c.red);b.push(c.green);b.push(c.blue);b.push(c.alpha);break;default:throw Error("invalid bit depth");}h=65535*(c.count/d)+0.5|0;Y(b,this.a(h,2))}return this.c(Qa,b)};T.prototype.Ga=function(a){if(256<a.length/3)throw Error("over 256 colors: "+a.length/3);return this.c(Ea,a)};T.prototype.Na=function(a){var c=[];Y(c,this.a(a.x,4));Y(c,this.a(a.y,4));c.push(a.qb);return this.c(Pa,c)};
+T.prototype.Ra=function(a){var c=[];Y(c,X(a.O));c.push(0);Y(c,X(a.text));return this.c(Ra,c)};T.prototype.Ua=function(a){var c=[];Y(c,X(a.O));c.push(0);c.push(a.g);switch(a.g){case V:Y(c,(new L(X(a.text),this.t)).o());break;default:throw Error("unknown compression method");}return this.c(Sa,c)};
+T.prototype.Ma=function(a){var c=[],b;Y(c,X(a.O));c.push(0);if("number"===typeof a.g)switch(c.push(1),c.push(a.g),a.g){case V:b=(new L(X(unescape(encodeURIComponent(a.text))),this.t)).o();break;default:throw Error("unknown compression method");}else c.push(0),c.push(0),b=X(unescape(encodeURIComponent(a.text)));Y(c,X(a.lang));c.push(0);"string"===typeof a.gb&&Y(c,X(unescape(encodeURIComponent(a.gb))));c.push(0);Y(c,b);return this.c(Ta,c)};
+T.prototype.Sa=function(a){var c=[];Y(c,this.a(a.getUTCFullYear(),2));c.push(a.getUTCMonth()+1);c.push(a.getUTCDate());c.push(a.getUTCHours());c.push(a.getUTCMinutes());c.push(a.getUTCSeconds());return this.c(Ua,c)};
+T.prototype.Ca=function(a){var c=[],b=this.w,e=this.J,d,f,g,h,k,p,l,o;this.za=this.ua();this.ka=this.ta();k=this.qa();p=this.za(a);l=0;for(o=p.length;l<o;l++)if(g=p[l],a=g.T,0!==a.length){d=g.width;this.q=null;f=0;for(g=g.height;f<g;f++){h=this.n(a,f*d,d);h=this.Xa(h);switch(b){case W:c.push(e);Y(c,this.ka(h,k));break;default:throw Error("unknown filter method");}this.q=h}}switch(this.g){case V:c=(new L(c,this.t)).o();break;default:throw Error("unknown compression method");}return this.c(Fa,c)};
+T.prototype.Da=function(){return this.c(Ga,[])};T.prototype.Ta=function(a){var c=[];switch(this.f){case 0:Y(c,this.a(a[0],2));break;case 2:Y(c,this.a(a[0],2));Y(c,this.a(a[1],2));Y(c,this.a(a[2],2));break;case 3:c=a;break;default:throw Error("invalid colour type");}return this.c(Ha,c)};
+T.prototype.qa=function(){var a,c=0<(this.f&4);switch(this.f){case 3:a=1;break;case 0:case 4:a=1;c&&(a+=1);16===this.e&&(a*=2);break;case 2:case U:a=3;c&&(a+=1);16===this.e&&(a*=2);break;default:throw Error("unknown colour type");}return a};T.prototype.ua=function(){var a;switch(this.M){case Ca:a=this.ya;break;case 1:a=this.xa;break;default:throw Error("unknown interlace method");}return a};function Z(a,c,b){this.width=a;this.height=c;this.T=b}
+T.prototype.ya=function(a){return[new Z(this.width,this.height,a)]};T.prototype.xa=function(a){var c=this.height,b=a.length/c,e,d,f,g,h,k,p,l,o,i,t,v;t=[new Z(0,0,[]),new Z(0,0,[]),new Z(0,0,[]),new Z(0,0,[]),new Z(0,0,[]),new Z(0,0,[]),new Z(0,0,[])];l=0;for(o=Wa.length;l<o;l++){v=t[l];i=Wa[l];for(d=h=k=0;d<c;d+=8)for(g=i.k;8>g;g+=i.l)for(e=0;e<b;e+=8)for(f=i.i;8>f;f+=i.j)if(p=a[e+f+(d+g)*b])h=(e+f-i.i)/i.j,k=(d+g-i.k)/i.l,v.T.push(p);v.width=h+1;v.height=k+1}return t};
+T.prototype.Xa=function(a){var c=[],b,e,d,f,g,h,k=this.e,p,l;p=8/k;d=0;for(f=a.length;d<f;d++)if(b=a[d],8>k)0===d%p&&(l=d/p,c[l]=0),c[l]|=b[0]<<(p-d%p-1)*k;else{g=0;for(h=b.length;g<h;g++)e=b[g],c.push(e),16===k&&c.push(e)}return c};
+T.prototype.ta=function(){var a;switch(this.w){case W:switch(this.J){case Ba:a=this.ga;break;case 1:a=this.ia;break;case 2:a=this.ja;break;case 3:a=this.fa;break;case 4:a=this.ha;break;default:throw Error("unknown filter type");}break;default:throw Error("unknown filter method");}return a};T.prototype.ga=function(a){return a};T.prototype.ia=function(a,c){var b=[],e=0,d,f;d=0;for(f=a.length;d<f;d++)e=a[d-c]||0,b.push(a[d]-e+256&255);return b};
+T.prototype.ja=function(a){var c=[],b,e=this.q,d,f;d=0;for(f=a.length;d<f;d++)b=e&&e[d]?e[d]:0,c.push(a[d]-b+256&255);return c};T.prototype.fa=function(a,c){var b=[],e,d,f=this.q,g,h;g=0;for(h=a.length;g<h;g++)e=a[g-c]||0,d=f&&f[g]||0,e=e+d>>>1,b.push(a[g]+256-e&255);return b};T.prototype.ha=function(a,c){var b=[],e,d,f,g=this.q,h,k;h=0;for(k=a.length;h<k;h++)e=a[h-c]||0,d=g&&g[h]||0,f=g&&g[h-c]||0,e=this.Wa(e,d,f),b.push(a[h]-e+256&255);return b};
+T.prototype.Wa=function(a,c,b){var e,d,f;e=a+c-b;d=Math.abs(e-a);f=Math.abs(e-c);e=Math.abs(e-b);return d<=f&&d<=e?a:f<=e?c:b};T.prototype.n=function(a,c,b){return"function"===typeof a.slice?a.slice(c,c+b):Array.prototype.slice.call(a,c,c+b)};T.prototype.c=function(a,c){var b=[],e=[];Y(b,this.a(c.length,4));Y(b,a);Y(b,c);Y(e,a);Y(e,c);Y(b,this.a(A.ca(e),4));return b};T.prototype.a=function(a,c){var b=[],e;do e=a&255,b.push(e),a>>>=8;while(0<a);if("number"===typeof c)for(;b.length<c;)b.push(0);return b.reverse()};
+T.prototype.Za=function(a,c,b){a=0.29891*a+0.58661*c+0.11448*b+1.0E-4;return(255<a?255:a)|0};T.prototype.D=function(a){return a.slice(0,3).map(this.K).join("")};T.prototype.V=function(a){return a.map(this.K).join("")};T.prototype.K=function(a){return String.fromCharCode(a).charAt(0)};function Y(a,c){var b=0,e=c.length,d=c.length;if(a.push)for(;b<d;b++)a.push(c[b]);else for(;b<d;b++)a[e+b]=c[b];return a.length}
+function X(a){var a=a.split(""),c=[],b,e;b=0;for(e=a.length;b<e;b++)c[b]=a[b].charCodeAt(0);return c}function $(a,c){for(var b in c){var e=[a,b].join(".");j(e,c[b],void 0)}}j("CanvasTool.PngEncoder",T,void 0);$("CanvasTool.PngEncoder.CompressionMethod",{DEFLATE:V});$("CanvasTool.PngEncoder.ColourType",{GRAYSCALE:0,TRUECOLOR:2,INDEXED_COLOR:3,GRAYSCALE_WITH_ALPHA:4,TRUECOLOR_WITH_ALPHA:U});$("CanvasTool.PngEncoder.FilterMethod",{BASIC:W});
+$("CanvasTool.PngEncoder.BasicFilterType",{NONE:Ba,SUB:1,UP:2,AVERAGE:3,PAETH:4});$("CanvasTool.PngEncoder.InterlaceMethod",{NONE:Ca,ADAM7:1});j("CanvasTool.PngEncoder.prototype.convert",T.prototype.da,void 0);j("CanvasTool.PngEncoder.prototype.convertToArray",T.prototype.H,void 0);}).call(this);
+
+/*
+* Copyright (c) 2015, Leon Sorokin
+* All rights reserved. (MIT Licensed)
+*
+* RgbQuant.js - an image quantization lib
+*/
+
+(function(){
+	function RgbQuant(opts) {
+		opts = opts || {};
+
+		// 1 = by global population, 2 = subregion population threshold
+		this.method = opts.method || 2;
+		// desired final palette size
+		this.colors = opts.colors || 256;
+		// # of highest-frequency colors to start with for palette reduction
+		this.initColors = opts.initColors || 4096;
+		// color-distance threshold for initial reduction pass
+		this.initDist = opts.initDist || 0.01;
+		// subsequent passes threshold
+		this.distIncr = opts.distIncr || 0.005;
+		// palette grouping
+		this.hueGroups = opts.hueGroups || 10;
+		this.satGroups = opts.satGroups || 10;
+		this.lumGroups = opts.lumGroups || 10;
+		// if > 0, enables hues stats and min-color retention per group
+		this.minHueCols = opts.minHueCols || 0;
+		// HueStats instance
+		this.hueStats = this.minHueCols ? new HueStats(this.hueGroups, this.minHueCols) : null;
+
+		// subregion partitioning box size
+		this.boxSize = opts.boxSize || [64,64];
+		// number of same pixels required within box for histogram inclusion
+		this.boxPxls = opts.boxPxls || 2;
+		// palette locked indicator
+		this.palLocked = false;
+		// palette sort order
+//		this.sortPal = ['hue-','lum-','sat-'];
+
+		// dithering/error diffusion kernel name
+		this.dithKern = opts.dithKern || null;
+		// dither serpentine pattern
+		this.dithSerp = opts.dithSerp || false;
+		// minimum color difference (0-1) needed to dither
+		this.dithDelta = opts.dithDelta || 0;
+
+		// accumulated histogram
+		this.histogram = {};
+		// palette - rgb triplets
+		this.idxrgb = opts.palette ? opts.palette.slice(0) : [];
+		// palette - int32 vals
+		this.idxi32 = [];
+		// reverse lookup {i32:idx}
+		this.i32idx = {};
+		// {i32:rgb}
+		this.i32rgb = {};
+		// enable color caching (also incurs overhead of cache misses and cache building)
+		this.useCache = opts.useCache !== false;
+		// min color occurance count needed to qualify for caching
+		this.cacheFreq = opts.cacheFreq || 10;
+		// allows pre-defined palettes to be re-indexed (enabling palette compacting and sorting)
+		this.reIndex = opts.reIndex || this.idxrgb.length == 0;
+		// selection of color-distance equation
+		this.colorDist = opts.colorDist == "manhattan" ? distManhattan : distEuclidean;
+
+		// if pre-defined palette, build lookups
+		if (this.idxrgb.length > 0) {
+			var self = this;
+			this.idxrgb.forEach(function(rgb, i) {
+				var i32 = (
+					(255    << 24) |	// alpha
+					(rgb[2] << 16) |	// blue
+					(rgb[1] <<  8) |	// green
+					 rgb[0]				// red
+				) >>> 0;
+
+				self.idxi32[i]		= i32;
+				self.i32idx[i32]	= i;
+				self.i32rgb[i32]	= rgb;
+			});
+		}
+	}
+
+	// gathers histogram info
+	RgbQuant.prototype.sample = function sample(img, width) {
+		if (this.palLocked)
+			throw "Cannot sample additional images, palette already assembled.";
+
+		var data = getImageData(img, width);
+
+		switch (this.method) {
+			case 1: this.colorStats1D(data.buf32); break;
+			case 2: this.colorStats2D(data.buf32, data.width); break;
+		}
+	};
+
+	// image quantizer
+	// todo: memoize colors here also
+	// @retType: 1 - Uint8Array (default), 2 - Indexed array, 3 - Match @img type (unimplemented, todo)
+	RgbQuant.prototype.reduce = function reduce(img, retType, dithKern, dithSerp) {
+		if (!this.palLocked)
+			this.buildPal();
+
+		dithKern = dithKern || this.dithKern;
+		dithSerp = typeof dithSerp != "undefined" ? dithSerp : this.dithSerp;
+
+		retType = retType || 1;
+
+		// reduce w/dither
+		if (dithKern)
+			var out32 = this.dither(img, dithKern, dithSerp);
+		else {
+			var data = getImageData(img),
+				buf32 = data.buf32,
+				len = buf32.length,
+				out32 = new Uint32Array(len);
+
+			for (var i = 0; i < len; i++) {
+				var i32 = buf32[i];
+				out32[i] = this.nearestColor(i32);
+			}
+		}
+
+		if (retType == 1)
+			return new Uint8Array(out32.buffer);
+
+		if (retType == 2) {
+			var out = [],
+				len = out32.length;
+
+			for (var i = 0; i < len; i++) {
+				var i32 = out32[i];
+				out[i] = this.i32idx[i32];
+			}
+
+			return out;
+		}
+	};
+
+	// adapted from http://jsbin.com/iXofIji/2/edit by PAEz
+	RgbQuant.prototype.dither = function(img, kernel, serpentine) {
+		// http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/
+		var kernels = {
+			FloydSteinberg: [
+				[7 / 16, 1, 0],
+				[3 / 16, -1, 1],
+				[5 / 16, 0, 1],
+				[1 / 16, 1, 1]
+			],
+			FalseFloydSteinberg: [
+				[3 / 8, 1, 0],
+				[3 / 8, 0, 1],
+				[2 / 8, 1, 1]
+			],
+			Stucki: [
+				[8 / 42, 1, 0],
+				[4 / 42, 2, 0],
+				[2 / 42, -2, 1],
+				[4 / 42, -1, 1],
+				[8 / 42, 0, 1],
+				[4 / 42, 1, 1],
+				[2 / 42, 2, 1],
+				[1 / 42, -2, 2],
+				[2 / 42, -1, 2],
+				[4 / 42, 0, 2],
+				[2 / 42, 1, 2],
+				[1 / 42, 2, 2]
+			],
+			Atkinson: [
+				[1 / 8, 1, 0],
+				[1 / 8, 2, 0],
+				[1 / 8, -1, 1],
+				[1 / 8, 0, 1],
+				[1 / 8, 1, 1],
+				[1 / 8, 0, 2]
+			],
+			Jarvis: [			// Jarvis, Judice, and Ninke / JJN?
+				[7 / 48, 1, 0],
+				[5 / 48, 2, 0],
+				[3 / 48, -2, 1],
+				[5 / 48, -1, 1],
+				[7 / 48, 0, 1],
+				[5 / 48, 1, 1],
+				[3 / 48, 2, 1],
+				[1 / 48, -2, 2],
+				[3 / 48, -1, 2],
+				[5 / 48, 0, 2],
+				[3 / 48, 1, 2],
+				[1 / 48, 2, 2]
+			],
+			Burkes: [
+				[8 / 32, 1, 0],
+				[4 / 32, 2, 0],
+				[2 / 32, -2, 1],
+				[4 / 32, -1, 1],
+				[8 / 32, 0, 1],
+				[4 / 32, 1, 1],
+				[2 / 32, 2, 1],
+			],
+			Sierra: [
+				[5 / 32, 1, 0],
+				[3 / 32, 2, 0],
+				[2 / 32, -2, 1],
+				[4 / 32, -1, 1],
+				[5 / 32, 0, 1],
+				[4 / 32, 1, 1],
+				[2 / 32, 2, 1],
+				[2 / 32, -1, 2],
+				[3 / 32, 0, 2],
+				[2 / 32, 1, 2],
+			],
+			TwoSierra: [
+				[4 / 16, 1, 0],
+				[3 / 16, 2, 0],
+				[1 / 16, -2, 1],
+				[2 / 16, -1, 1],
+				[3 / 16, 0, 1],
+				[2 / 16, 1, 1],
+				[1 / 16, 2, 1],
+			],
+			SierraLite: [
+				[2 / 4, 1, 0],
+				[1 / 4, -1, 1],
+				[1 / 4, 0, 1],
+			],
+		};
+
+		if (!kernel || !kernels[kernel]) {
+			throw 'Unknown dithering kernel: ' + kernel;
+		}
+
+		var ds = kernels[kernel];
+
+		var data = getImageData(img),
+//			buf8 = data.buf8,
+			buf32 = data.buf32,
+			width = data.width,
+			height = data.height,
+			len = buf32.length;
+
+		var dir = serpentine ? -1 : 1;
+
+		for (var y = 0; y < height; y++) {
+			if (serpentine)
+				dir = dir * -1;
+
+			var lni = y * width;
+
+			for (var x = (dir == 1 ? 0 : width - 1), xend = (dir == 1 ? width : 0); x !== xend; x += dir) {
+				// Image pixel
+				var idx = lni + x,
+					i32 = buf32[idx],
+					r1 = (i32 & 0xff),
+					g1 = (i32 & 0xff00) >> 8,
+					b1 = (i32 & 0xff0000) >> 16;
+
+				// Reduced pixel
+				var i32x = this.nearestColor(i32),
+					r2 = (i32x & 0xff),
+					g2 = (i32x & 0xff00) >> 8,
+					b2 = (i32x & 0xff0000) >> 16;
+
+				buf32[idx] =
+					(255 << 24)	|	// alpha
+					(b2  << 16)	|	// blue
+					(g2  <<  8)	|	// green
+					 r2;
+
+				// dithering strength
+				if (this.dithDelta) {
+					var dist = this.colorDist([r1, g1, b1], [r2, g2, b2]);
+					if (dist < this.dithDelta)
+						continue;
+				}
+
+				// Component distance
+				var er = r1 - r2,
+					eg = g1 - g2,
+					eb = b1 - b2;
+
+				for (var i = (dir == 1 ? 0 : ds.length - 1), end = (dir == 1 ? ds.length : 0); i !== end; i += dir) {
+					var x1 = ds[i][1] * dir,
+						y1 = ds[i][2];
+
+					var lni2 = y1 * width;
+
+					if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) {
+						var d = ds[i][0];
+						var idx2 = idx + (lni2 + x1);
+
+						var r3 = (buf32[idx2] & 0xff),
+							g3 = (buf32[idx2] & 0xff00) >> 8,
+							b3 = (buf32[idx2] & 0xff0000) >> 16;
+
+						var r4 = Math.max(0, Math.min(255, r3 + er * d)),
+							g4 = Math.max(0, Math.min(255, g3 + eg * d)),
+							b4 = Math.max(0, Math.min(255, b3 + eb * d));
+
+						buf32[idx2] =
+							(255 << 24)	|	// alpha
+							(b4  << 16)	|	// blue
+							(g4  <<  8)	|	// green
+							 r4;			// red
+					}
+				}
+			}
+		}
+
+		return buf32;
+	};
+
+	// reduces histogram to palette, remaps & memoizes reduced colors
+	RgbQuant.prototype.buildPal = function buildPal(noSort) {
+		if (this.palLocked || this.idxrgb.length > 0 && this.idxrgb.length <= this.colors) return;
+
+		var histG  = this.histogram,
+			sorted = sortedHashKeys(histG, true);
+
+		if (sorted.length == 0)
+			throw "Nothing has been sampled, palette cannot be built.";
+
+		switch (this.method) {
+			case 1:
+				var cols = this.initColors,
+					last = sorted[cols - 1],
+					freq = histG[last];
+
+				var idxi32 = sorted.slice(0, cols);
+
+				// add any cut off colors with same freq as last
+				var pos = cols, len = sorted.length;
+				while (pos < len && histG[sorted[pos]] == freq)
+					idxi32.push(sorted[pos++]);
+
+				// inject min huegroup colors
+				if (this.hueStats)
+					this.hueStats.inject(idxi32);
+
+				break;
+			case 2:
+				var idxi32 = sorted;
+				break;
+		}
+
+		// int32-ify values
+		idxi32 = idxi32.map(function(v){return +v;});
+
+		this.reducePal(idxi32);
+
+		if (!noSort && this.reIndex)
+			this.sortPal();
+
+		// build cache of top histogram colors
+		if (this.useCache)
+			this.cacheHistogram(idxi32);
+
+		this.palLocked = true;
+	};
+
+	RgbQuant.prototype.palette = function palette(tuples, noSort) {
+		this.buildPal(noSort);
+		return tuples ? this.idxrgb : new Uint8Array((new Uint32Array(this.idxi32)).buffer);
+	};
+
+	RgbQuant.prototype.prunePal = function prunePal(keep) {
+		var i32;
+
+		for (var j = 0; j < this.idxrgb.length; j++) {
+			if (!keep[j]) {
+				i32 = this.idxi32[j];
+				this.idxrgb[j] = null;
+				this.idxi32[j] = null;
+				delete this.i32idx[i32];
+			}
+		}
+
+		// compact
+		if (this.reIndex) {
+			var idxrgb = [],
+				idxi32 = [],
+				i32idx = {};
+
+			for (var j = 0, i = 0; j < this.idxrgb.length; j++) {
+				if (this.idxrgb[j]) {
+					i32 = this.idxi32[j];
+					idxrgb[i] = this.idxrgb[j];
+					i32idx[i32] = i;
+					idxi32[i] = i32;
+					i++;
+				}
+			}
+
+			this.idxrgb = idxrgb;
+			this.idxi32 = idxi32;
+			this.i32idx = i32idx;
+		}
+	};
+
+	// reduces similar colors from an importance-sorted Uint32 rgba array
+	RgbQuant.prototype.reducePal = function reducePal(idxi32) {
+		// if pre-defined palette's length exceeds target
+		if (this.idxrgb.length > this.colors) {
+			// quantize histogram to existing palette
+			var len = idxi32.length, keep = {}, uniques = 0, idx, pruned = false;
+
+			for (var i = 0; i < len; i++) {
+				// palette length reached, unset all remaining colors (sparse palette)
+				if (uniques == this.colors && !pruned) {
+					this.prunePal(keep);
+					pruned = true;
+				}
+
+				idx = this.nearestIndex(idxi32[i]);
+
+				if (uniques < this.colors && !keep[idx]) {
+					keep[idx] = true;
+					uniques++;
+				}
+			}
+
+			if (!pruned) {
+				this.prunePal(keep);
+				pruned = true;
+			}
+		}
+		// reduce histogram to create initial palette
+		else {
+			// build full rgb palette
+			var idxrgb = idxi32.map(function(i32) {
+				return [
+					(i32 & 0xff),
+					(i32 & 0xff00) >> 8,
+					(i32 & 0xff0000) >> 16,
+				];
+			});
+
+			var len = idxrgb.length,
+				palLen = len,
+				thold = this.initDist;
+
+			// palette already at or below desired length
+			if (palLen > this.colors) {
+				while (palLen > this.colors) {
+					var memDist = [];
+
+					// iterate palette
+					for (var i = 0; i < len; i++) {
+						var pxi = idxrgb[i], i32i = idxi32[i];
+						if (!pxi) continue;
+
+						for (var j = i + 1; j < len; j++) {
+							var pxj = idxrgb[j], i32j = idxi32[j];
+							if (!pxj) continue;
+
+							var dist = this.colorDist(pxi, pxj);
+
+							if (dist < thold) {
+								// store index,rgb,dist
+								memDist.push([j, pxj, i32j, dist]);
+
+								// kill squashed value
+								delete(idxrgb[j]);
+								palLen--;
+							}
+						}
+					}
+
+					// palette reduction pass
+					// console.log("palette length: " + palLen);
+
+					// if palette is still much larger than target, increment by larger initDist
+					thold += (palLen > this.colors * 3) ? this.initDist : this.distIncr;
+				}
+
+				// if palette is over-reduced, re-add removed colors with largest distances from last round
+				if (palLen < this.colors) {
+					// sort descending
+					sort.call(memDist, function(a,b) {
+						return b[3] - a[3];
+					});
+
+					var k = 0;
+					while (palLen < this.colors) {
+						// re-inject rgb into final palette
+						idxrgb[memDist[k][0]] = memDist[k][1];
+
+						palLen++;
+						k++;
+					}
+				}
+			}
+
+			var len = idxrgb.length;
+			for (var i = 0; i < len; i++) {
+				if (!idxrgb[i]) continue;
+
+				this.idxrgb.push(idxrgb[i]);
+				this.idxi32.push(idxi32[i]);
+
+				this.i32idx[idxi32[i]] = this.idxi32.length - 1;
+				this.i32rgb[idxi32[i]] = idxrgb[i];
+			}
+		}
+	};
+
+	// global top-population
+	RgbQuant.prototype.colorStats1D = function colorStats1D(buf32) {
+		var histG = this.histogram,
+			num = 0, col,
+			len = buf32.length;
+
+		for (var i = 0; i < len; i++) {
+			col = buf32[i];
+
+			// skip transparent
+			if ((col & 0xff000000) >> 24 == 0) continue;
+
+			// collect hue stats
+			if (this.hueStats)
+				this.hueStats.check(col);
+
+			if (col in histG)
+				histG[col]++;
+			else
+				histG[col] = 1;
+		}
+	};
+
+	// population threshold within subregions
+	// FIXME: this can over-reduce (few/no colors same?), need a way to keep
+	// important colors that dont ever reach local thresholds (gradients?)
+	RgbQuant.prototype.colorStats2D = function colorStats2D(buf32, width) {
+		var boxW = this.boxSize[0],
+			boxH = this.boxSize[1],
+			area = boxW * boxH,
+			boxes = makeBoxes(width, buf32.length / width, boxW, boxH),
+			histG = this.histogram,
+			self = this;
+
+		boxes.forEach(function(box) {
+			var effc = Math.max(Math.round((box.w * box.h) / area) * self.boxPxls, 2),
+				histL = {}, col;
+
+			iterBox(box, width, function(i) {
+				col = buf32[i];
+
+				// skip transparent
+				if ((col & 0xff000000) >> 24 == 0) return;
+
+				// collect hue stats
+				if (self.hueStats)
+					self.hueStats.check(col);
+
+				if (col in histG)
+					histG[col]++;
+				else if (col in histL) {
+					if (++histL[col] >= effc)
+						histG[col] = histL[col];
+				}
+				else
+					histL[col] = 1;
+			});
+		});
+
+		if (this.hueStats)
+			this.hueStats.inject(histG);
+	};
+
+	// TODO: group very low lum and very high lum colors
+	// TODO: pass custom sort order
+	RgbQuant.prototype.sortPal = function sortPal() {
+		var self = this;
+
+		this.idxi32.sort(function(a,b) {
+			var idxA = self.i32idx[a],
+				idxB = self.i32idx[b],
+				rgbA = self.idxrgb[idxA],
+				rgbB = self.idxrgb[idxB];
+
+			var hslA = rgb2hsl(rgbA[0],rgbA[1],rgbA[2]),
+				hslB = rgb2hsl(rgbB[0],rgbB[1],rgbB[2]);
+
+			// sort all grays + whites together
+			var hueA = (rgbA[0] == rgbA[1] && rgbA[1] == rgbA[2]) ? -1 : hueGroup(hslA.h, self.hueGroups);
+			var hueB = (rgbB[0] == rgbB[1] && rgbB[1] == rgbB[2]) ? -1 : hueGroup(hslB.h, self.hueGroups);
+
+			var hueDiff = hueB - hueA;
+			if (hueDiff) return -hueDiff;
+
+			var lumDiff = lumGroup(+hslB.l.toFixed(2)) - lumGroup(+hslA.l.toFixed(2));
+			if (lumDiff) return -lumDiff;
+
+			var satDiff = satGroup(+hslB.s.toFixed(2)) - satGroup(+hslA.s.toFixed(2));
+			if (satDiff) return -satDiff;
+		});
+
+		// sync idxrgb & i32idx
+		this.idxi32.forEach(function(i32, i) {
+			self.idxrgb[i] = self.i32rgb[i32];
+			self.i32idx[i32] = i;
+		});
+	};
+
+	// TOTRY: use HUSL - http://boronine.com/husl/
+	RgbQuant.prototype.nearestColor = function nearestColor(i32) {
+		var idx = this.nearestIndex(i32);
+		return idx === null ? 0 : this.idxi32[idx];
+	};
+
+	// TOTRY: use HUSL - http://boronine.com/husl/
+	RgbQuant.prototype.nearestIndex = function nearestIndex(i32) {
+		// alpha 0 returns null index
+		if ((i32 & 0xff000000) >> 24 == 0)
+			return null;
+
+		if (this.useCache && (""+i32) in this.i32idx)
+			return this.i32idx[i32];
+
+		var min = 1000,
+			idx,
+			rgb = [
+				(i32 & 0xff),
+				(i32 & 0xff00) >> 8,
+				(i32 & 0xff0000) >> 16,
+			],
+			len = this.idxrgb.length;
+
+		for (var i = 0; i < len; i++) {
+			if (!this.idxrgb[i]) continue;		// sparse palettes
+
+			var dist = this.colorDist(rgb, this.idxrgb[i]);
+
+			if (dist < min) {
+				min = dist;
+				idx = i;
+			}
+		}
+
+		return idx;
+	};
+
+	RgbQuant.prototype.cacheHistogram = function cacheHistogram(idxi32) {
+		for (var i = 0, i32 = idxi32[i]; i < idxi32.length && this.histogram[i32] >= this.cacheFreq; i32 = idxi32[i++])
+			this.i32idx[i32] = this.nearestIndex(i32);
+	};
+
+	function HueStats(numGroups, minCols) {
+		this.numGroups = numGroups;
+		this.minCols = minCols;
+		this.stats = {};
+
+		for (var i = -1; i < numGroups; i++)
+			this.stats[i] = {num: 0, cols: []};
+
+		this.groupsFull = 0;
+	}
+
+	HueStats.prototype.check = function checkHue(i32) {
+		if (this.groupsFull == this.numGroups + 1)
+			this.check = function() {return;};
+
+		var r = (i32 & 0xff),
+			g = (i32 & 0xff00) >> 8,
+			b = (i32 & 0xff0000) >> 16,
+			hg = (r == g && g == b) ? -1 : hueGroup(rgb2hsl(r,g,b).h, this.numGroups),
+			gr = this.stats[hg],
+			min = this.minCols;
+
+		gr.num++;
+
+		if (gr.num > min)
+			return;
+		if (gr.num == min)
+			this.groupsFull++;
+
+		if (gr.num <= min)
+			this.stats[hg].cols.push(i32);
+	};
+
+	HueStats.prototype.inject = function injectHues(histG) {
+		for (var i = -1; i < this.numGroups; i++) {
+			if (this.stats[i].num <= this.minCols) {
+				switch (typeOf(histG)) {
+					case "Array":
+						this.stats[i].cols.forEach(function(col){
+							if (histG.indexOf(col) == -1)
+								histG.push(col);
+						});
+						break;
+					case "Object":
+						this.stats[i].cols.forEach(function(col){
+							if (!histG[col])
+								histG[col] = 1;
+							else
+								histG[col]++;
+						});
+						break;
+				}
+			}
+		}
+	};
+
+	// Rec. 709 (sRGB) luma coef
+	var Pr = .2126,
+		Pg = .7152,
+		Pb = .0722;
+
+	// http://alienryderflex.com/hsp.html
+	function rgb2lum(r,g,b) {
+		return Math.sqrt(
+			Pr * r*r +
+			Pg * g*g +
+			Pb * b*b
+		);
+	}
+
+	var rd = 255,
+		gd = 255,
+		bd = 255;
+
+	var euclMax = Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd);
+	// perceptual Euclidean color distance
+	function distEuclidean(rgb0, rgb1) {
+		var rd = rgb1[0]-rgb0[0],
+			gd = rgb1[1]-rgb0[1],
+			bd = rgb1[2]-rgb0[2];
+
+		return Math.sqrt(Pr*rd*rd + Pg*gd*gd + Pb*bd*bd) / euclMax;
+	}
+
+	var manhMax = Pr*rd + Pg*gd + Pb*bd;
+	// perceptual Manhattan color distance
+	function distManhattan(rgb0, rgb1) {
+		var rd = Math.abs(rgb1[0]-rgb0[0]),
+			gd = Math.abs(rgb1[1]-rgb0[1]),
+			bd = Math.abs(rgb1[2]-rgb0[2]);
+
+		return (Pr*rd + Pg*gd + Pb*bd) / manhMax;
+	}
+
+	// http://rgb2hsl.nichabi.com/javascript-function.php
+	function rgb2hsl(r, g, b) {
+		var max, min, h, s, l, d;
+		r /= 255;
+		g /= 255;
+		b /= 255;
+		max = Math.max(r, g, b);
+		min = Math.min(r, g, b);
+		l = (max + min) / 2;
+		if (max == min) {
+			h = s = 0;
+		} else {
+			d = max - min;
+			s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+			switch (max) {
+				case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+				case g:	h = (b - r) / d + 2; break;
+				case b:	h = (r - g) / d + 4; break
+			}
+			h /= 6;
+		}
+//		h = Math.floor(h * 360)
+//		s = Math.floor(s * 100)
+//		l = Math.floor(l * 100)
+		return {
+			h: h,
+			s: s,
+			l: rgb2lum(r,g,b),
+		};
+	}
+
+	function hueGroup(hue, segs) {
+		var seg = 1/segs,
+			haf = seg/2;
+
+		if (hue >= 1 - haf || hue <= haf)
+			return 0;
+
+		for (var i = 1; i < segs; i++) {
+			var mid = i*seg;
+			if (hue >= mid - haf && hue <= mid + haf)
+				return i;
+		}
+	}
+
+	function satGroup(sat) {
+		return sat;
+	}
+
+	function lumGroup(lum) {
+		return lum;
+	}
+
+	function typeOf(val) {
+		return Object.prototype.toString.call(val).slice(8,-1);
+	}
+
+	var sort = isArrSortStable() ? Array.prototype.sort : stableSort;
+
+	// must be used via stableSort.call(arr, fn)
+	function stableSort(fn) {
+		var type = typeOf(this[0]);
+
+		if (type == "Number" || type == "String") {
+			var ord = {}, len = this.length, val;
+
+			for (var i = 0; i < len; i++) {
+				val = this[i];
+				if (ord[val] || ord[val] === 0) continue;
+				ord[val] = i;
+			}
+
+			return this.sort(function(a,b) {
+				return fn(a,b) || ord[a] - ord[b];
+			});
+		}
+		else {
+			var ord = this.map(function(v){return v});
+
+			return this.sort(function(a,b) {
+				return fn(a,b) || ord.indexOf(a) - ord.indexOf(b);
+			});
+		}
+	}
+
+	// test if js engine's Array#sort implementation is stable
+	function isArrSortStable() {
+		var str = "abcdefghijklmnopqrstuvwxyz";
+
+		return "xyzvwtursopqmnklhijfgdeabc" == str.split("").sort(function(a,b) {
+			return ~~(str.indexOf(b)/2.3) - ~~(str.indexOf(a)/2.3);
+		}).join("");
+	}
+
+	// returns uniform pixel data from various img
+	// TODO?: if array is passed, createimagedata, createlement canvas? take a pxlen?
+	function getImageData(img, width) {
+		var can, ctx, imgd, buf8, buf32, height;
+
+		switch (typeOf(img)) {
+			case "HTMLImageElement":
+				can = document.createElement("canvas");
+				can.width = img.naturalWidth;
+				can.height = img.naturalHeight;
+				ctx = can.getContext("2d");
+				ctx.drawImage(img,0,0);
+			case "Canvas":
+			case "HTMLCanvasElement":
+				can = can || img;
+				ctx = ctx || can.getContext("2d");
+			case "CanvasRenderingContext2D":
+				ctx = ctx || img;
+				can = can || ctx.canvas;
+				imgd = ctx.getImageData(0, 0, can.width, can.height);
+			case "ImageData":
+				imgd = imgd || img;
+				width = imgd.width;
+				if (typeOf(imgd.data) == "CanvasPixelArray")
+					buf8 = new Uint8Array(imgd.data);
+				else
+					buf8 = imgd.data;
+			case "Array":
+			case "CanvasPixelArray":
+				buf8 = buf8 || new Uint8Array(img);
+			case "Uint8Array":
+			case "Uint8ClampedArray":
+				buf8 = buf8 || img;
+				buf32 = new Uint32Array(buf8.buffer);
+			case "Uint32Array":
+				buf32 = buf32 || img;
+				buf8 = buf8 || new Uint8Array(buf32.buffer);
+				width = width || buf32.length;
+				height = buf32.length / width;
+		}
+
+		return {
+			can: can,
+			ctx: ctx,
+			imgd: imgd,
+			buf8: buf8,
+			buf32: buf32,
+			width: width,
+			height: height,
+		};
+	}
+
+	// partitions a rect of wid x hgt into
+	// array of bboxes of w0 x h0 (or less)
+	function makeBoxes(wid, hgt, w0, h0) {
+		var wnum = ~~(wid/w0), wrem = wid%w0,
+			hnum = ~~(hgt/h0), hrem = hgt%h0,
+			xend = wid-wrem, yend = hgt-hrem;
+
+		var bxs = [];
+		for (var y = 0; y < hgt; y += h0)
+			for (var x = 0; x < wid; x += w0)
+				bxs.push({x:x, y:y, w:(x==xend?wrem:w0), h:(y==yend?hrem:h0)});
+
+		return bxs;
+	}
+
+	// iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent
+	function iterBox(bbox, wid, fn) {
+		var b = bbox,
+			i0 = b.y * wid + b.x,
+			i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1),
+			cnt = 0, incr = wid - b.w + 1, i = i0;
+
+		do {
+			fn.call(this, i);
+			i += (++cnt % b.w == 0) ? incr : 1;
+		} while (i <= i1);
+	}
+
+	// returns array of hash keys sorted by their values
+	function sortedHashKeys(obj, desc) {
+		var keys = [];
+
+		for (var key in obj)
+			keys.push(key);
+
+		return sort.call(keys, function(a,b) {
+			return desc ? obj[b] - obj[a] : obj[a] - obj[b];
+		});
+	}
+
+	// expose
+	this.RgbQuant = RgbQuant;
+
+	// expose to commonJS
+	if (typeof module !== 'undefined' && module.exports) {
+		module.exports = RgbQuant;
+	}
+
+}).call(this);
+
+function drawPixels(idxi8, width0, width1) {
+	var idxi32 = new Uint32Array(idxi8.buffer);
+
+	width1 = width1 || width0;
+
+	var can = document.createElement("canvas"),
+		can2 = document.createElement("canvas"),
+		ctx = can.getContext("2d"),
+		ctx2 = can2.getContext("2d");
+
+	can.width = width0;
+	can.height = Math.ceil(idxi32.length / width0);
+	can2.width = width1;
+	can2.height = Math.ceil(can.height * width1 / width0);
+
+	ctx.imageSmoothingEnabled = ctx.mozImageSmoothingEnabled = ctx.msImageSmoothingEnabled = false;
+	ctx2.imageSmoothingEnabled = ctx2.mozImageSmoothingEnabled = ctx2.msImageSmoothingEnabled = false;
+
+	var imgd = ctx.createImageData(can.width, can.height);
+
+	var buf32 = new Uint32Array(imgd.data.buffer);
+	buf32.set(idxi32);
+
+	ctx.putImageData(imgd, 0, 0);
+
+	ctx2.drawImage(can, 0, 0, can2.width, can2.height);
+
+	return can2;
+}

+ 136 - 0
js/ActivityList.coffee

@@ -0,0 +1,136 @@
+class ActivityList extends Class
+	constructor: ->
+		@activities = null
+		@directories = []
+		@need_update = true
+		@limit = 10
+		@loading = true
+
+	queryActivities: (cb) ->
+		directories_sql = ("'#{directory}'" for directory in @directories).join(",")
+		query = """
+			SELECT
+			 'comment' AS type, json.*,
+			 json.site || "/" || post_uri AS subject, body, date_added,
+			 NULL AS subject_auth_address, NULL AS subject_hub, NULL AS subject_user_name
+			FROM
+			 json
+			LEFT JOIN comment USING (json_id)
+			WHERE
+			 json.directory IN (#{directories_sql})
+
+			UNION ALL
+
+			SELECT
+			 'post_like' AS type, json.*,
+			 json.site || "/" || post_uri AS subject, '' AS body, date_added,
+			 NULL AS subject_auth_address, NULL AS subject_hub, NULL AS subject_user_name
+			FROM
+			 json
+			LEFT JOIN post_like USING (json_id)
+			WHERE
+			 json.directory IN (#{directories_sql})
+
+			UNION ALL
+
+			SELECT
+			 'follow' AS type, json.*,
+			 follow.hub || "/" || follow.auth_address AS subject, '' AS body, date_added,
+			 follow.auth_address AS subject_auth_address, follow.hub AS subject_hub, follow.user_name AS subject_user_name
+			FROM
+			 json
+			LEFT JOIN follow USING (json_id)
+			WHERE
+			 json.directory IN (#{directories_sql})
+			ORDER BY date_added DESC
+			LIMIT #{@limit+1}
+		"""
+		Page.cmd "dbQuery", [query, {directories: @directories}], (rows) =>
+			# Resolve subject's name
+			directories = []
+			rows = (row for row in rows when row.subject)  # Remove deleted users activities
+			for row in rows
+				row.auth_address = row.directory.replace("data/users/", "")
+				subject_address = row.subject.replace(/_.*/, "").replace(/.*\//, "")  # Only keep user's address
+				row.subject_address = subject_address
+				directory = "data/users/#{subject_address}"
+				if directory not in directories
+					directories.push directory
+
+			Page.cmd "dbQuery", ["SELECT * FROM json WHERE ?", {directory: directories}], (subject_rows) =>
+				# Add subject node to rows
+				subject_db = {}
+				for subject_row in subject_rows
+					subject_row.auth_address = subject_row.directory.replace("data/users/", "")
+					subject_db[subject_row.auth_address] = subject_row
+				for row in rows
+					row.subject = subject_db[row.subject_address]
+					row.subject ?= {}
+					row.subject.auth_address ?= row.subject_auth_address
+					row.subject.hub ?= row.subject_hub
+					row.subject.user_name ?= row.subject_user_name
+				cb(rows)
+
+
+	update: =>
+		@need_update = false
+		@loading = true
+		@queryActivities (res) =>
+			@activities = res
+			@loading = false
+			Page.projector.scheduleRender()
+
+	handleMoreClick: =>
+		@limit += 20
+		@update()
+		return false
+
+	renderActivity: (activity) ->
+		if not activity.subject.user_name
+			return
+		activity_user_link = "?Profile/#{activity.hub}/#{activity.auth_address}/#{activity.cert_user_id}"
+		subject_user_link = "?Profile/#{activity.subject.hub}/#{activity.subject.auth_address}/#{activity.subject.cert_user_id}"
+		if activity.type == "post_like"
+			body = [
+				h("a", {href: activity_user_link, onclick: @Page.handleLinkClick}, activity.user_name), " liked ",
+				h("a", {href: subject_user_link, onclick: @Page.handleLinkClick}, activity.subject.user_name), "'s ",
+				h("a", {href: subject_user_link, onclick: @Page.handleLinkClick}, "post")
+			]
+		else if activity.type == "comment"
+			body = [
+				h("a", {href: activity_user_link, onclick: @Page.handleLinkClick}, activity.user_name), " commented on ",
+				h("a", {href: subject_user_link, onclick: @Page.handleLinkClick}, activity.subject.user_name), "'s ",
+				h("a", {href: subject_user_link, onclick: @Page.handleLinkClick}, "post"), ": #{activity.body}"
+			]
+		else if activity.type == "follow"
+			body = [
+				h("a", {href: activity_user_link, onclick: @Page.handleLinkClick}, activity.user_name), " started following ",
+				h("a", {href: subject_user_link, onclick: @Page.handleLinkClick}, activity.subject.user_name)
+			]
+		else
+			body = activity.body
+		h("div.activity", {key: "#{activity.cert_user_id}_#{activity.date_added}", title: Time.since(activity.date_added), enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+			h("div.circle"),
+			h("div.body", body)
+		])
+
+	render: =>
+		if @need_update then @update()
+		if @activities == null # Not loaded yet
+			return null
+
+		h("div.activity-list", [
+			if @activities.length > 0
+				h("h2", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, "Activity feed")
+			h("div.items", [
+				h("div.bg-line"),
+				@activities[0..@limit-1].map(@renderActivity)
+			]),
+			if @activities.length > @limit
+				h("a.more.small", {href: "#More", onclick: @handleMoreClick, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, "Show more...")
+			# if @loading
+			# 	h("span.more.small", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, "Loading...", )
+
+		])
+
+window.ActivityList = ActivityList

+ 40 - 0
js/AnonUser.coffee

@@ -0,0 +1,40 @@
+class AnonUser extends Class
+	constructor: ->
+		@auth_address = null
+		@hub = null
+		@followed_users = {}
+		@likes = {}
+
+	updateInfo: (cb=null) =>
+		Page.on_local_storage.then =>
+			@followed_users = Page.local_storage.followed_users
+			cb?(true)
+
+	like: (site, post_uri, cb=null) ->
+		Page.cmd "wrapperNotification", ["info", "You need a profile for this feature"]
+		cb(true)
+
+	dislike: (site, post_uri, cb=null) ->
+		Page.cmd "wrapperNotification", ["info", "You need a profile for this feature"]
+		cb(true)
+
+	followUser: (hub, auth_address, user_name, cb=null) ->
+		@followed_users[hub+"/"+auth_address] = true
+		@save cb
+		Page.needSite hub  # Download followed user's site if necessary
+		Page.content.update()
+
+	unfollowUser: (hub, auth_address, cb=null) ->
+		delete @followed_users[hub+"/"+auth_address]
+		@save cb
+		Page.content.update()
+
+	comment: (site, post_uri, body, cb=null) ->
+		Page.cmd "wrapperNotification", ["info", "You need a profile for this feature"]
+		cb?(false)
+
+	save: (cb=null) =>
+		Page.saveLocalStorage cb
+
+
+window.AnonUser = AnonUser

+ 142 - 0
js/ContentCreateProfile.coffee

@@ -0,0 +1,142 @@
+class ContentCreateProfile extends Class
+	constructor: ->
+		@loaded = true
+		@hubs = []
+		@default_hubs = []
+		@need_update = true
+		@creation_status = []
+		@downloading = {}
+
+	handleDownloadClick: (e) =>
+		hub = e.target.attributes.address.value
+		@downloading[hub] = true
+		Page.needSite hub, =>
+			@update()
+		return false
+
+
+	handleJoinClick: (e) =>
+		hub = e.target.attributes.address.value
+		user = new User({hub: hub, auth_address: Page.site_info.auth_address})
+		@creation_status.push "Checking user on selected hub..."
+		Page.cmd "fileGet", {"inner_path": user.getPath()+"/data.json", "required": false}, (found) =>
+			if found
+				Page.cmd "wrapperNotification", ["error", "User #{Page.site_info.cert_user_id} already exists on this hub"]
+				@creation_status = []
+				return
+
+			# Create new profile
+			user_name = Page.site_info.cert_user_id.replace(/@.*/, "")
+			data = user.getDefaultData()
+			data.avatar = "generate"
+			data.user_name = user_name.charAt(0).toUpperCase()+user_name.slice(1)
+			data.hub = hub
+			@creation_status.push "Creating new profile..."
+			user.save data, hub, =>
+				@creation_status = []
+				Page.checkUser()
+				Page.setUrl("?Home")
+
+		return false
+
+
+	updateHubs: =>
+		Page.cmd "mergerSiteList", true, (sites) =>
+			# Get userlist
+			Page.cmd "dbQuery", "SELECT * FROM json WHERE avatar IN ('jpg', 'png')", (users) =>
+				site_users = {}
+				for user in users
+					site_users[user.hub] ?= []
+					site_users[user.hub].push(user)
+				hubs = []
+				for address, site of sites
+					if address == Page.userdb
+						continue
+					site["users"] = site_users[site.address] or []
+					hubs.push(site)
+				@hubs = hubs
+				Page.projector.scheduleRender()
+
+			@default_hubs = []
+			for address, content of Page.site_info.content.settings.default_hubs
+				if not sites[address] and not @downloading[address]
+					@default_hubs.push {
+						users: [],
+						address: address,
+						content: content,
+						type: "available"
+					}
+
+
+	renderHub: (hub) =>
+		h("div.hub.card", {key: hub.address+hub.type, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+			if hub.type == "available"
+				h("a.button.button-join", {href: "#Download:#{hub.address}", address: hub.address, onclick: @handleDownloadClick}, "Download")
+			else
+				h("a.button.button-join", {href: "#Join:#{hub.address}", address: hub.address, onclick: @handleJoinClick}, "Join!")
+			h("div.avatars", [
+				hub.users.map (user) =>
+					avatar = "merged-ZeroMe/#{hub.address}/#{user.directory}/avatar.#{user.avatar}"
+					h("a.avatar", {title: user.user_name, href: "#", style: "background-image: url('#{avatar}')"})
+				if hub.users.length > 4
+					h("a.avatar.empty", {href: "#"}, "+#{hub.users.length-4}")
+			])
+			h("div.name", hub.content.title),
+			h("div.intro", hub.content.description)
+		])
+
+
+	renderSeededHubs: =>
+		h("div.hubs.hubs-seeded", @hubs.map(@renderHub))
+
+	renderDefaultHubs: =>
+		h("div.hubs.hubs-default", @default_hubs.map(@renderHub))
+
+
+	handleSelectUserClick: ->
+		Page.cmd "certSelect", {"accepted_domains": ["zeroid.bit"], "accept_any": true}
+		return false
+
+
+	render: =>
+		if @loaded and not Page.on_loaded.resolved then Page.on_loaded.resolve()
+		if @need_update
+			@updateHubs()
+			@need_update = false
+
+		h("div#Content.center.content-signup", [
+			h("h1", "Create new profile"),
+			h("a.button.button-submit.button-certselect.certselect", {href: "#Select+user", onclick: @handleSelectUserClick}, [
+				h("div.icon.icon-profile"),
+				if Page.site_info?.cert_user_id
+					"As: #{Page.site_info.cert_user_id}"
+				else
+					"Select ID..."
+			])
+			if @creation_status.length > 0
+				h("div.creation-status", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+					@creation_status.map (creation_status) =>
+						h("h3", {key: creation_status, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, creation_status)
+				])
+			else if Page.site_info.cert_user_id
+				h("div.hubs", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+					if @hubs.length
+						h("div.hubselect.seeded", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+							h("h2", "Seeded HUBs")
+							@renderSeededHubs()
+						])
+					if @default_hubs.length
+						h("div.hubselect.default", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+							h("h2", "Available HUBs")
+							@renderDefaultHubs()
+						])
+				])
+		])
+
+	update: =>
+		@need_update = true
+		Page.projector.scheduleRender()
+
+
+
+window.ContentCreateProfile = ContentCreateProfile

+ 50 - 0
js/ContentFeed.coffee

@@ -0,0 +1,50 @@
+class ContentFeed extends Class
+	constructor: ->
+		@post_create = new PostCreate()
+		@post_list = new PostList()
+		@activity_list = new ActivityList()
+		@user_list = new UserList()
+		@need_update = true
+		@update()
+
+	render: =>
+		if @post_list.loaded and not Page.on_loaded.resolved then Page.on_loaded.resolve()
+
+		if @need_update
+			@log "Updating"
+			@need_update = false
+
+			@user_list.need_update = true
+
+			# Post list
+			@post_list.directories = ("data/users/#{key.split('/')[1]}" for key, followed of Page.user.followed_users)
+			if Page.user.hub  # Also show my posts
+				@post_list.directories.push("data/users/"+Page.user.auth_address)
+			@post_list.need_update = true
+
+			# Activity list
+			@activity_list.directories = ("data/users/#{key.split('/')[1]}" for key, followed of Page.user.followed_users)
+			@activity_list.need_update = true
+
+
+		h("div#Content.center", [
+			h("div.col-center", [
+				@post_create.render(),
+				@post_list.render()
+			]),
+			h("div.col-right", [
+				@activity_list.render(),
+				if @user_list.users.length > 0
+					h("h2.sep", [
+						"New users",
+						h("a.link", {href: "?Users", onclick: Page.handleLinkClick}, "Browse all \u203A")
+					])
+				@user_list.render(".gray"),
+			])
+		])
+
+	update: =>
+		@need_update = true
+		Page.projector.scheduleRender()
+
+window.ContentFeed = ContentFeed

+ 171 - 0
js/ContentProfile.coffee

@@ -0,0 +1,171 @@
+class ContentProfile extends Class
+	constructor: ->
+		@post_list = null
+		@activity_list = null
+		@user_list = null
+		@auth_address = null
+		@user = new User()
+		@owned = false
+		@need_update = true
+		@loaded = false
+
+	renderNotSeeded: =>
+		return h("div#Content.center.#{@auth_address}", [
+			h("div.col-left", [
+				h("div.users", [
+					h("div.user.card.profile", [
+						@user.renderAvatar()
+						h("a.name.link",
+							{href: @user.getLink(), style: "color: #{Text.toColor(@user.row.auth_address)}", onclick: Page.handleLinkClick},
+							@user.row.user_name
+						),
+						h("div.cert_user_id", @user.row.cert_user_id)
+						h("div.intro-full",
+							@user.row.intro
+						),
+						h("div.follow-container", [
+							h("a.button.button-follow-big", {href: "#", onclick: @user.handleFollowClick, classes: {loading: @user.submitting_follow}},
+								h("span.icon-follow", "+"),
+								if @user.isFollowed() then "Unfollow" else "Follow"
+							)
+						])
+					])
+				])
+			]),
+			h("div.col-center", {style: "padding-top: 30px; text-align: center"}, [
+				h("h1", "Download profile site"),
+				h("h2", "User's profile site not loaded to your client yet."),
+				h("a.button.submit", {href: "#Add+site", onclick: @user.handleDownloadClick}, "Download user's site")
+			])
+		])
+
+
+	render: =>
+		if @need_update
+			@log "Updating"
+			@need_update = false
+
+			# Update components
+			@post_list?.need_update = true
+			@user_list?.need_update = true
+
+			# Update profile details
+			@user.get @hub, @auth_address, =>
+				@owned = @user.auth_address == Page.user?.auth_address
+				if @owned and not @editable_intro
+					@editable_intro = new Editable("div", @handleIntroSave)
+					@editable_intro.render_function = Text.renderMarked
+					@editable_user_name = new Editable("span", @handleUserNameSave)
+					@uploadable_avatar = new Uploadable(@handleAvatarUpload)
+				Page.projector.scheduleRender()
+				@loaded = true
+
+			if not Page.merged_sites[@hub]
+				# Not seeded user, get details from userdb
+				Page.queryUserdb @auth_address, (row) =>
+					@user.setRow(row)
+					Page.projector.scheduleRender()
+					@loaded = true
+
+		if not @user?.row
+			return h("div#Content.center.#{@auth_address}", [])
+
+		if not Page.merged_sites[@hub]
+			return @renderNotSeeded()
+
+		if @post_list.loaded and not Page.on_loaded.resolved then Page.on_loaded.resolve()
+
+		h("div#Content.center.#{@auth_address}", [
+			h("div.col-left", [
+				h("div.users", [
+					h("div.user.card.profile", {classes: {followed: @user.isFollowed()}}, [
+						if @owned then @uploadable_avatar.render(@user.renderAvatar) else @user.renderAvatar()
+						h("a.name.link",
+							{href: @user.getLink(), style: "color: #{Text.toColor(@user.row.auth_address)}", onclick: Page.handleLinkClick},
+							if @owned then @editable_user_name.render(@user.row.user_name) else @user.row.user_name
+						),
+						h("div.cert_user_id", @user.row.cert_user_id)
+						h("div.intro-full",
+							if @owned then @editable_intro.render(@user.row.intro) else @user.row.intro
+						),
+						h("div.follow-container", [
+							h("a.button.button-follow-big", {href: "#", onclick: @user.handleFollowClick, classes: {loading: @user.submitting_follow}},
+								h("span.icon-follow", "+"),
+								if @user.isFollowed() then "Unfollow" else "Follow"
+							)
+						])
+					])
+				]),
+				if @user_list.users.length > 0
+					h("h2.sep", {afterCreate: Animation.show}, [
+						"Following",
+					])
+				@user_list.render(".gray"),
+			]),
+			h("div.col-center", [
+				@post_list.render()
+			])
+		])
+
+	setUser: (@hub, @auth_address, @cert_user_id) =>
+		@log "setUser", @cert_user_id
+		if not @post_list or @post_list.directories[0] != "data/users/"+@auth_address
+			# Changed user, create clean status objects
+			# @post_create = new PostCreate()
+			@post_list = new PostList()
+			@activity_list = new ActivityList()
+			@user_list = new UserList()
+			@user = new User()
+			@post_list.directories = ["data/users/"+@auth_address]
+			@user_list.followed_by = @user
+			@user_list.limit = 50
+			@need_update = true
+
+	handleIntroSave: (intro, cb) =>
+		@user.row.intro = intro
+		@user.getData @user.hub, (data) =>
+			data.intro = intro
+			@user.save data, @user.hub, (res) =>
+				cb(res)
+				@update()
+
+	handleUserNameSave: (user_name, cb) =>
+		@user.row.user_name = user_name
+		@user.getData @user.hub, (data) =>
+			data.user_name = user_name
+			@user.save data, @user.hub, (res) =>
+				cb(res)
+				@update()
+
+	handleAvatarUpload: (image_base64uri) =>
+		# Cleanup previous avatars
+		Page.cmd "fileDelete", @user.getPath()+"/avatar.jpg"
+		Page.cmd "fileDelete", @user.getPath()+"/avatar.png"
+
+		if not image_base64uri
+			# Delete image
+			@user.getData @user.hub, (data) =>
+				data.avatar = "generate"
+				@user.save data, @user.hub, (res) =>
+					Page.cmd "wrapperReload"  # Reload the page
+			return false
+
+		# Handle upload
+		image_base64 = image_base64uri?.replace(/.*?,/, "")
+		ext = image_base64uri.match("image/([a-z]+)")[1]
+		if ext == "jpeg" then ext = "jpg"
+
+
+		Page.cmd "fileWrite", [@user.getPath()+"/avatar."+ext, image_base64], (res) =>
+			@user.getData @user.hub, (data) =>
+				data.avatar = ext
+				@user.save data, @user.hub, (res) =>
+					Page.cmd "wrapperReload"  # Reload the page
+
+	update: =>
+		if not @auth_address
+			return
+		@need_update = true
+		Page.projector.scheduleRender()
+
+window.ContentProfile = ContentProfile

+ 46 - 0
js/ContentSignup.coffee

@@ -0,0 +1,46 @@
+class ContentSignup
+	constructor: ->
+		@loaded = true
+
+	render: =>
+		if @loaded and not Page.on_loaded.resolved then Page.on_loaded.resolve()
+
+		h("div#Content.center.content-signup", [
+			h("h1", "Create new profile"),
+			h("a.button.button-submit.button-certselect.certselect", {href: "#Select+ID"}, [
+				h("div.icon.icon-profile"),
+				"Select ID..."
+			])
+			h("div.hubselect", [
+				h("h2", "Join HUB")
+				h("div.hubs", [
+					h("div.hub.card", [
+						h("a.button.button-join", {href: "#"}, "Join!"),
+						h("div.avatars", [
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar.empty", {href: "#"}, "+120")
+						]),
+						h("div.name", "ZeroHub #1"),
+						h("div.intro", "Welcome to ZeroMe! Runner: Nofish"),
+					]),
+					h("div.hub.card", [
+						h("a.button.button-join", {href: "#"}, "Join!"),
+						h("div.avatars", [
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+							h("a.avatar.empty", {href: "#"}, "+120")
+						]),
+						h("div.name", "ZeroHub #1"),
+						h("div.intro", "Welcome to ZeroMe! Runner: Nofish"),
+					])
+				])
+			])
+
+		])
+
+window.ContentSignup = ContentSignup

+ 42 - 0
js/ContentUsers.coffee

@@ -0,0 +1,42 @@
+class ContentUsers extends Class
+	constructor: ->
+		@user_list_recent = new UserList("recent")
+		@user_list_recent.limit = 1000
+		@loaded = true
+		@need_update = false
+
+	render: =>
+		if @loaded and not Page.on_loaded.resolved then Page.on_loaded.resolve()
+		if @need_update
+			@log "Updating"
+			@need_update = false
+
+			# Update components
+			@user_list_recent?.need_update = true
+
+		h("div#Content.center", [
+			#h("input.text.big.search", {placeholder: "Search in users..."}),
+			h("h2", "New users in ZeroMe")
+			h("div.users.cards", [
+				@user_list_recent.render("card")
+			])
+		])
+		###
+			h("a.more", {href: "#"}, "Show more..."),
+			h("h2", "Followed users"),
+			h("div.users.cards", [
+				h("div.user.card", [
+					h("a.button.button-follow", {href: "#"}, "+"),
+					h("a.avatar", {href: "#", style: "background-image: url('img/1.png')"}),
+					h("a.name.link", {href: "#"}, "Nofish"),
+					h("div.intro", "ZeroNet developer")
+				])
+			]),
+		])
+		###
+
+	update: =>
+		@need_update = true
+		Page.projector.scheduleRender()
+
+window.ContentUsers = ContentUsers

+ 49 - 0
js/Head.coffee

@@ -0,0 +1,49 @@
+class Head extends Class
+	handleSelectUserClick: ->
+		if "Merger:ZeroMe" not in Page.site_info.settings.permissions
+			Page.cmd "wrapperPermissionAdd", "Merger:ZeroMe", =>
+				Page.updateSiteInfo =>
+					Page.content.update()
+		else
+			Page.cmd "certSelect", {"accepted_domains": ["zeroid.bit"], "accept_any": true}
+		return false
+
+	renderSettings: =>
+		# h("a.settings", {href: "?Signup", onclick: Page.handleLinkClick}, "\u22EE")
+		return ""
+
+	render: =>
+		h("div.head.center", [
+			h("a.logo", {href: "?Home", onclick: Page.handleLinkClick}, h("img", {src: "img/logo.png"})),
+			if Page.user?.hub
+				# Registered user
+				h("div.right.authenticated", [
+					h("div.user",
+						h("a.name.link", {href: Page.user.getLink(), onclick: Page.handleLinkClick}, Page.user.row.user_name),
+						h("a.address", {href: "#Select+user", onclick: @handleSelectUserClick}, Page.site_info.cert_user_id)
+					),
+					@renderSettings()
+				])
+			else if not Page.user?.hub and Page.site_info?.cert_user_id
+				# Cert selected, but not registered
+				h("div.right.selected", [
+					h("div.user",
+						h("a.name.link", {href: "?Create+profile", onclick: Page.handleLinkClick}, "Create profile"),
+						h("a.address", {href: "#Select+user", onclick: @handleSelectUserClick}, Page.site_info.cert_user_id)
+					),
+					@renderSettings()
+				])
+			else if not Page.user?.hub and Page.site_info
+				# No cert selected
+				h("div.right.unknown", [
+					h("div.user",
+						h("a.name.link", {href: "#Select+user", onclick: @handleSelectUserClick}, "Visitor"),
+						h("a.address", {href: "#Select+user", onclick: @handleSelectUserClick}, "Select your account")
+					),
+					@renderSettings()
+				])
+			else
+				h("div.right.unknown")
+		])
+
+window.Head = Head

+ 159 - 0
js/Post.coffee

@@ -0,0 +1,159 @@
+class Post extends Class
+	constructor: (row, @item_list) ->
+		@liked = false
+		@commenting = false
+		@submitting_like = false
+		@owned = false
+		@editable_comments = {}
+		@field_comment = new Autosize({placeholder: "Add your comment", onsubmit: @handleCommentSubmit})
+		@comment_limit = 3
+		@setRow(row)
+
+	setRow: (row) ->
+		@row = row
+		if Page.user
+			@liked = Page.user.likes[@row.key]
+		@user = new User({hub: row.site, auth_address: row.directory.replace("data/users/", "")})
+		@user.row = row
+		@owned = @user.auth_address == Page.user?.auth_address
+		if @owned
+			@editable_body = new Editable("div.body", @handlePostSave, @handlePostDelete)
+			@editable_body.render_function = Text.renderMarked
+
+	handlePostSave: (body, cb) =>
+		Page.user.getData Page.user.hub, (data) =>
+			post_index = i for post, i in data.post when post.post_id == @row.post_id
+			data.post[post_index].body = body
+			Page.user.save data, Page.user.hub, (res) =>
+				cb(res)
+
+	handlePostDelete: (cb) =>
+		Page.user.getData Page.user.hub, (data) =>
+			post_index = i for post, i in data.post when post.post_id == @row.post_id
+			data.post.splice(post_index, 1)
+			Page.user.save data, Page.user.hub, (res) =>
+				cb(res)
+
+	handleLikeClick: (e) =>
+		@submitting_like = true
+		[site, post_uri] = @row.key.split("-")
+		if Page.user.likes[post_uri]
+			Animation.flashOut(e.currentTarget.firstChild)
+			Page.user.dislike site, post_uri, =>
+				@submitting_like = false
+		else
+			Animation.flashIn(e.currentTarget.firstChild)
+			Page.user.like site, post_uri, =>
+				@submitting_like = false
+		return false
+
+	handleCommentClick: =>
+		if @field_comment.node
+			@field_comment.node.focus()
+		else
+			@commenting = true
+			setTimeout ( =>
+				@field_comment.node.focus()
+			), 600
+		return false
+
+	handleCommentSubmit: =>
+		timer_loading = setTimeout ( => @field_comment.loading = true ), 100  # Only add loading message if takes more than 100ms
+		[site, post_uri] = @row.key.split("-")
+		Page.user.comment site, post_uri, @field_comment.attrs.value, (res) =>
+			clearInterval(timer_loading)
+			@field_comment.loading = false
+			if res
+				@field_comment.setValue("")
+
+	handleCommentSave: (comment_id, body, cb) =>
+		Page.user.getData Page.user.hub, (data) =>
+			comment_index = i for comment, i in data.comment when comment.comment_id == comment_id
+			data.comment[comment_index].body = body
+			Page.user.save data, Page.user.hub, (res) =>
+				cb(res)
+
+	handleCommentDelete: (comment_id, cb) =>
+		Page.user.getData Page.user.hub, (data) =>
+			comment_index = i for comment, i in data.comment when comment.comment_id == comment_id
+			data.comment.splice(comment_index, 1)
+			Page.user.save data, Page.user.hub, (res) =>
+				cb(res)
+
+	handleMoreCommentsClick: =>
+		@comment_limit += 10
+		return false
+
+	getEditableComment: (comment_uri) ->
+		if not @editable_comments[comment_uri]
+			[user_address, comment_id] = comment_uri.split("_")
+
+			handleCommentSave = (body, cb) =>
+				@handleCommentSave(parseInt(comment_id), body, cb)
+
+			handleCommentDelete = (cb) =>
+				@handleCommentDelete(parseInt(comment_id), cb)
+
+			@editable_comments[comment_uri] = new Editable("div.body", handleCommentSave, handleCommentDelete)
+
+		return @editable_comments[comment_uri]
+
+
+	renderComments: =>
+		if not @row.comments and not @commenting
+			return []
+		h("div.comment-list", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp, animate_noscale: true}, [
+			if @commenting then h("div.comment-create", {enterAnimation: Animation.slideDown},
+				@field_comment.render()
+			),
+			@row.comments?[0..@comment_limit-1].map (comment) =>
+				user_address = comment.directory.replace("data/users/", "")
+				comment_uri = user_address+"_"+comment.comment_id
+				owned = user_address == Page.user?.auth_address
+				user_link = "?Profile/"+comment.hub+"/"+user_address+"/"+comment.cert_user_id
+				h("div.comment", {id: comment_uri, key: comment_uri, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+					h("div.user", [
+						h("a.name.link", {href: user_link, style: "color: #{Text.toColor(user_address)}", onclick: Page.handleLinkClick}, comment.user_name),
+						h("span.sep", " \u00B7 "),
+						h("span.address", {title: user_address}, comment.cert_user_id),
+						h("span.sep", " \u2015 "),
+						h("a.added.link", {href: "#", title: Time.date(comment.date_added, "long")}, Time.since(comment.date_added)),
+					])
+					if owned
+						@getEditableComment(comment_uri).render(comment.body)
+					else
+						h("div.body", comment.body)
+				])
+			if @row.comments?.length > @comment_limit
+				h("a.more", {href: "#More", onclick: @handleMoreCommentsClick, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, "Show more comments...")
+		])
+
+	render: =>
+		[site, post_uri] = @row.key.split("-")
+		h("div.post", {key: @row.key, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+			h("div.user", [
+				@user.renderAvatar({href: @user.getLink(), onclick: Page.handleLinkClick}),
+				h("a.name.link", {href: @user.getLink(), onclick: Page.handleLinkClick, style: "color: #{Text.toColor(@user.auth_address)}"},
+					@row.user_name
+				),
+				h("span.sep", " \u00B7 "),
+				h("span.address", {title: @user.auth_address}, @row.cert_user_id),
+				h("span.sep", " \u2015 "),
+				h("a.added.link", {href: "?Post:#{@row.key}", title: Time.date(@row.date_added, "long")}, Time.since(@row.date_added)),
+			])
+			if @owned
+				@editable_body.render(@row.body)
+			else
+				h("div.body", {innerHTML: Text.renderMarked(@row.body)})
+			h("div.actions", [
+				h("a.icon.icon-comment.link", {href: "#Comment", onclick: @handleCommentClick}, "Comment"),
+				h("a.like.link", {classes: {active: Page.user?.likes[post_uri], loading: @submitting_like, "like-zero": @row.likes == 0}, href: "#Like", onclick: @handleLikeClick},
+					h("div.icon.icon-heart", {classes: {active: Page.user?.likes[post_uri]}}),
+					if @row.likes then @row.likes
+				)
+				# h("a.icon.icon-share.link", {href: "#Share"}, "Share"),
+			]),
+			@renderComments()
+		])
+
+window.Post = Post

+ 63 - 0
js/PostCreate.coffee

@@ -0,0 +1,63 @@
+class PostCreate
+	constructor: ->
+		@field_post = new Autosize({
+			placeholder: "Write something...",
+			class: "postfield",
+			onfocus: Page.projector.scheduleRender,
+			onblur: Page.projector.scheduleRender
+		})
+
+	isEditing: ->
+		return @field_post.attrs.value?.length or document.activeElement?.parentElement == @field_post.node?.parentElement
+
+	handlePostSubmit: =>
+		@field_post.loading = true
+		Page.user.post @field_post.attrs.value, (res) =>
+			@field_post.loading = false
+			if res
+				@field_post.setValue("")
+				document.activeElement.blur()  # Clear the focus
+			setTimeout ( ->
+				Page.content.update()
+			), 100
+		return false
+
+	render: =>
+		user = Page.user
+		if user == false
+			h("div.post-create.post.empty")
+		else if user?.hub
+			# Registered user
+			h("div.post-create.post", {classes: {editing: @isEditing()}},
+				h("div.user", user.renderAvatar()),
+				@field_post.render(),
+				h("div.postbuttons",
+					h("a.button.button-submit", {href: "#Submit", onclick: @handlePostSubmit}, "Submit new post"),
+				),
+				h("div", {style: "clear: both"})
+			)
+		else if Page.site_info.cert_user_id
+			# Selected cert, but no registered user
+			h("div.post-create.post.empty.noprofile",
+				h("div.user", h("a.avatar", href: "#", style: "background-image: url('img/unkown.png')")),
+				h("div.select-user-container",
+					h("a.button.button-submit.select-user", {href: "?Create+profile", onclick: Page.handleLinkClick}, [
+						"Create new profile"
+					])
+				),
+				h("textarea", {disabled: true})
+			)
+		else
+			# No cert selected
+			h("div.post-create.post.empty.nocert",
+				h("div.user", h("a.avatar", href: "#", style: "background-image: url('img/unkown.png')")),
+				h("div.select-user-container",
+					h("a.button.button-submit.select-user", {href: "#Select+user", onclick: Page.head.handleSelectUserClick}, [
+						h("div.icon.icon-profile"),
+						"Select user to post new content"
+					])
+				),
+				h("textarea", {disabled: true})
+			)
+
+window.PostCreate = PostCreate

+ 77 - 0
js/PostList.coffee

@@ -0,0 +1,77 @@
+class PostList extends Class
+	constructor: ->
+		@item_list = new ItemList(Post, "key")
+		@posts = @item_list.items
+		@need_update = true
+		@directories = []
+		@loaded = false
+
+	queryComments: (post_uris, cb) =>
+		query = "
+			SELECT
+			 post_uri, comment.body, comment.date_added, comment.comment_id, json.cert_auth_type, json.cert_user_id, json.user_name, json.hub, json.directory
+			FROM
+			 comment
+			LEFT JOIN json USING (json_id)
+			WHERE
+			 ?
+			ORDER BY date_added DESC
+		"
+		return Page.cmd "dbQuery", [query, {post_uri: post_uris}], cb
+
+	update: =>
+		@log "Updating"
+		@need_update = false
+		query = "
+			SELECT
+			 (SELECT COUNT(*) FROM post_like WHERE 'data/users/' || post_uri =  directory || '_' || post_id) AS likes,
+			 *
+			FROM
+			 json
+			LEFT JOIN post ON (post.json_id = json.json_id)
+			WHERE ? AND post_id IS NOT NULL
+			ORDER BY date_added DESC
+			LIMIT 30
+		"
+
+		Page.cmd "dbQuery", [query, {"directory": @directories}], (rows) =>
+			items = []
+			post_uris = []
+			for row in rows
+				row["key"] = row["site"]+"-"+row["directory"].replace("data/users/", "")+"_"+row["post_id"]
+				row["post_uri"] = row["directory"].replace("data/users/", "") + "_" + row["post_id"]
+				post_uris.push(row["post_uri"])
+
+			# Get comments for latest posts
+			@queryComments post_uris, (comment_rows) =>
+				comment_db = {}  # {Post id: posts}
+				for comment_row in comment_rows
+					comment_db[comment_row.post_uri] ?= []
+					comment_db[comment_row.post_uri].push(comment_row)
+				for row in rows
+					row["comments"] = comment_db[row.post_uri]
+				@item_list.sync(rows)
+				@loaded = true
+				Page.projector.scheduleRender()
+
+	render: =>
+		if @need_update then @update()
+		if not @posts.length
+			if not @loaded
+				return null
+			else
+				return h("div.post-list", [
+					h("div.post-list-empty", {enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+						h("h2", "No posts yet"),
+						h("a", {href: "?Users", onclick: Page.handleLinkClick}, "Let's follow some users!")
+					])
+				])
+		h("div.post-list", @posts.map (post) =>
+			try
+				post.render()
+			catch err
+				h("div.error", ["Post render error:", err.message])
+				Debug.formatException(err)
+		)
+
+window.PostList = PostList

+ 251 - 0
js/User.coffee

@@ -0,0 +1,251 @@
+class User extends Class
+	constructor: (row, @item_list) ->
+		if row
+			@setRow(row)
+		@likes = {}
+		@followed_users = {}
+		@submitting_follow = false
+
+	setRow: (row) ->
+		@row = row
+		@hub = row.hub
+		@auth_address = row.auth_address
+
+	get: (hub, auth_address, cb=null) ->
+		params = { hub: hub, directory: "data/users/"+auth_address }
+		Page.cmd "dbQuery", ["SELECT * FROM json WHERE hub = :hub AND directory = :directory LIMIT 1", params], (res) =>
+			row = res[0]
+			if row
+				row.auth_address = row.directory.replace("data/users/", "")
+				@setRow(row)
+				cb?(row)
+			else
+				cb(false)
+
+	updateInfo: (cb=null) =>
+		@logStart "Info loaded"
+		p_likes = new Promise()
+		p_followed_users = new Promise()
+
+		# Load followed users
+		Page.cmd "dbQuery", ["SELECT * FROM follow WHERE json_id = #{@row.json_id}"], (res) =>
+			@followed_users = {}
+			for row in res
+				@followed_users[row.hub+"/"+row.auth_address] = row
+			p_followed_users.resolve()
+
+		# Load likes
+		Page.cmd "dbQuery", ["SELECT * FROM post_like WHERE json_id = #{@row.json_id}"], (res) =>
+			@likes = {}
+			for row in res
+				@likes[row.post_uri] = true
+			p_likes.resolve()
+
+		Promise.join(p_followed_users, p_likes).then (res1, res2) =>
+			@logEnd "Info loaded"
+			cb?(true)
+
+	isFollowed: ->
+		return Page.user.followed_users[@hub+"/"+@auth_address]
+
+	isSeeding: ->
+		return Page.merged_sites[@hub]
+
+	getPath: (site=@hub) ->
+		if site == Page.userdb
+			return "merged-ZeroMe/#{site}/data/userdb/#{@auth_address}"
+		else
+			return "merged-ZeroMe/#{site}/data/users/#{@auth_address}"
+
+	getLink: ->
+		return "?Profile/#{@hub}/#{@auth_address}/#{@row.cert_user_id}"
+
+	getAvatarLink: ->
+		cache_invalidation = ""
+		# Cache invalidation for local user
+		if @auth_address == Page.user?.auth_address
+			cache_invalidation = "?"+Page.cache_time
+		return "merged-ZeroMe/#{@hub}/data/users/#{@auth_address}/avatar.#{@row.avatar}#{cache_invalidation}"
+
+	getDefaultData: ->
+		return {
+			"next_post_id": 2,
+			"next_comment_id": 1,
+			"next_follow_id": 1,
+			"avatar": "generate",
+			"user_name": @row?.user_name,
+			"hub": @hub,
+			"intro": "Random ZeroNet user",
+			"post": [{
+				"post_id": 1,
+				"date_added": Time.timestamp(),
+				"body": "Hello ZeroMe!"
+			}],
+			"post_like": {},
+			"comment": [],
+			"follow": []
+		}
+
+	getData: (site, cb) ->
+		Page.cmd "fileGet", [@getPath(site)+"/data.json", false], (data) =>
+			data = JSON.parse(data)
+			data ?= {
+				"next_comment_id": 1,
+				"user_name": @row?.user_name,
+				"hub": @hub,
+				"post_like": {},
+				"comment": []
+			}
+			cb(data)
+
+	renderAvatar: (attrs={}) =>
+		if @isSeeding() and (@row.avatar == "png" or @row.avatar == "jpg")
+			attrs.style = "background-image: url('#{@getAvatarLink()}')"
+		else
+			attrs.style = "background: linear-gradient("+Text.toColor(@auth_address)+","+Text.toColor(@auth_address.slice(-5))+")"
+		h("a.avatar", attrs)
+
+	saveUserdb: (data, cb=null) ->
+		Page.cmd "fileWrite", [@getPath(Page.userdb)+"/content.json", Text.fileEncode(data)], (res_write) =>
+			Page.cmd "sitePublish", {"inner_path": @getPath(Page.userdb)+"/content.json"}, (res_sign) =>
+				@log "Userdb save result", res_write, res_sign
+				cb?(res_sign)
+
+	save: (data, site=@hub, cb=null) ->
+		Page.cmd "fileWrite", [@getPath(site)+"/data.json", Text.fileEncode(data)], (res_write) =>
+			cb?(res_write)
+			Page.cmd "sitePublish", {"inner_path": @getPath(site)+"/data.json"}, (res_sign) =>
+				@log "Save result", res_write, res_sign
+
+		# Update userdb
+		if site == @hub
+			Page.cmd "fileGet", [@getPath(Page.userdb)+"/content.json", false], (userdb_data) =>
+				userdb_data = JSON.parse(userdb_data)
+				changed = false
+				if not userdb_data?.user
+					userdb_data = {
+						user: [{date_added: Time.timestamp()}]
+					}
+					changed = true
+				for field in ["avatar", "hub", "intro", "user_name"]
+					if userdb_data.user[0][field] != data[field]
+						changed = true
+						@log "Changed in profile:", field
+					userdb_data.user[0][field] = data[field]
+
+				if changed
+					@saveUserdb(userdb_data)
+
+
+	like: (site, post_uri, cb=null) ->
+		@log "Like", site, post_uri
+		@likes[post_uri] = true
+
+		@getData site, (data) =>
+			data.post_like[post_uri] = Time.timestamp()
+			@save data, site, (res) =>
+				if cb then cb(res)
+
+	dislike: (site, post_uri, cb=null) ->
+		@log "Dislike", site, post_uri
+		delete @likes[post_uri]
+
+		@getData site, (data) =>
+			delete data.post_like[post_uri]
+			@save data, site, (res) =>
+				if cb then cb(res)
+
+	comment: (site, post_uri, body, cb=null) ->
+		@getData site, (data) =>
+			data.comment.push {
+				"comment_id": data.next_comment_id,
+				"body": body,
+				"post_uri": post_uri,
+				"date_added": Time.timestamp()
+			}
+			data.next_comment_id += 1
+			@save data, site, (res) =>
+				if cb then cb(res)
+
+	post: (body, cb=null) ->
+		@getData @hub, (data) =>
+			data.post.push {
+				"post_id": data.next_post_id,
+				"body": body,
+				"date_added": Time.timestamp()
+			}
+			data.next_post_id += 1
+			@save data, @hub, (res) =>
+				if cb then cb(res)
+
+	followUser: (hub, auth_address, user_name, cb=null) ->
+		@log "Following", hub, auth_address
+		@download()
+
+		@getData @hub, (data) =>
+			follow_row = {
+				"follow_id": data.next_follow_id,
+				"hub": hub,
+				"auth_address": auth_address,
+				"user_name": user_name,
+				"date_added": Time.timestamp()
+			}
+			data.follow.push follow_row
+			@followed_users[hub+"/"+auth_address] = true
+			data.next_follow_id += 1
+			@save data, @hub, (res) =>
+				if cb then cb(res)
+
+			Page.needSite hub  # Download followed user's site if necessary
+
+	unfollowUser: (hub, auth_address, cb=null) ->
+		@log "UnFollowing", hub, auth_address
+		delete @followed_users[hub+"/"+auth_address]
+
+		@getData @hub, (data) =>
+			follow_index = i for follow, i in data.follow when follow.hub == hub and follow.auth_address == auth_address
+			data.follow.splice(follow_index, 1)
+			@save data, @hub, (res) =>
+				if cb then cb(res)
+
+	handleFollowClick: (e) =>
+		@submitting_follow = true
+		if not @isFollowed()
+			Animation.flashIn(e.target)
+			Page.user.followUser @hub, @auth_address, @row.user_name, (res) =>
+				@submitting_follow = false
+				Page.projector.scheduleRender()
+		else
+			Animation.flashOut(e.target)
+			Page.user.unfollowUser @hub, @auth_address, (res) =>
+				@submitting_follow = false
+				Page.projector.scheduleRender()
+		return false
+
+	download: =>
+		if not Page.merged_sites[@hub]
+			Page.cmd "mergerSiteAdd", @hub, =>
+				Page.updateSiteInfo()
+
+	handleDownloadClick: (e) =>
+		@download()
+		return false
+
+	renderList: (type="normal") =>
+		classname = ""
+		if type == "card" then classname = ".card"
+		link = @getLink()
+		followed = @isFollowed()
+		if followed then title = "Unfollow" else title = "Follow"
+		h("div.user"+classname, {key: @auth_address, classes: {followed: followed}, enterAnimation: Animation.slideDown, exitAnimation: Animation.slideUp}, [
+			h("a.button.button-follow", {href: link, onclick: @handleFollowClick, title: title, classes: {loading: @submitting_follow}}, "+"),
+			h("a", {href: link, onclick: Page.handleLinkClick}, @renderAvatar()),
+			h("div.nameline", [
+				h("a.name.link", {href: link, onclick: Page.handleLinkClick}, @row.user_name),
+				if type == "card" then h("span.added", Time.since(@row.date_added))
+			])
+			h("div.intro", @row.intro)
+		])
+
+
+window.User = User

+ 72 - 0
js/UserList.coffee

@@ -0,0 +1,72 @@
+class UserList extends Class
+	constructor: (@type="recent") ->
+		@item_list = new ItemList(User, "key")
+		@users = @item_list.items
+		@need_update = true
+		@limit = 5
+		@followed_by = null
+
+	update: ->
+		if @followed_by
+			query = """
+				SELECT user.user_name, follow.*, user.*
+				FROM follow
+				LEFT JOIN user USING (auth_address, hub)
+				WHERE
+				 follow.json_id = #{@followed_by.row.json_id}  AND user.json_id IS NOT NULL
+
+				UNION
+
+				SELECT user.user_name, follow.*, user.*
+				FROM follow
+				LEFT JOIN json ON (json.directory = 'data/userdb/' || follow.auth_address)
+				LEFT JOIN user ON (user.json_id = json.json_id)
+				WHERE
+				 follow.json_id = #{@followed_by.row.json_id}  AND user.json_id IS NOT NULL
+
+				ORDER BY date_added DESC
+				LIMIT #{@limit}
+			"""
+		else
+			query = """
+				SELECT
+				 user.*,
+				 json.site AS json_site,
+				 json.directory AS json_directory,
+				 json.file_name AS json_file_name,
+				 json.cert_user_id AS json_cert_user_id,
+				 json.hub AS json_hub,
+				 json.user_name AS json_user_name,
+				 json.avatar AS json_avatar
+				FROM
+				 user LEFT JOIN json USING (json_id)
+				ORDER BY date_added DESC
+				LIMIT #{@limit}
+			"""
+		Page.cmd "dbQuery", query, (rows) =>
+			rows_by_user = {}  # Deduplicating
+			for row in rows
+				if row.json_cert_user_id  # File in user directory
+					row.cert_user_id = row.json_cert_user_id
+					row.auth_address = row.json_directory.replace("data/userdb/", "")
+				if not row.auth_address  # Just created user, no content.json yet
+					continue
+				row.key = row.hub+"/"+row.auth_address
+				if not rows_by_user[row.hub+row.auth_address]
+					rows_by_user[row.hub+row.auth_address] = row
+			user_rows = (val for key, val of rows_by_user)
+			@item_list.sync(user_rows)
+			Page.projector.scheduleRender()
+
+	render: (type="normal") =>
+		if @need_update
+			@need_update = false
+			setTimeout ( => @update() ), 100  # Low prioriy
+		if not @users.length
+			return null
+
+		h("div.UserList.users"+type, {afterCreate: Animation.show}, @users.map (user) =>
+			user.renderList(type)
+		)
+
+window.UserList = UserList

+ 296 - 0
js/ZeroMe.coffee

@@ -0,0 +1,296 @@
+window.h = maquette.h
+
+class ZeroMe extends ZeroFrame
+	init: ->
+		@params = {}
+		@merged_sites = {}
+		@site_info = null
+		@server_info = null
+		@address = null
+		@user = false
+		@user_loaded = false
+		@userdb = "1UDbADib99KE9d3qZ87NqJF2QLTHmMkoV"
+		@cache_time = Time.timestamp()  # Image cache invalidation
+
+		@on_site_info = new Promise()
+		@on_local_storage = new Promise()
+		@on_user_info = new Promise()
+		@on_loaded = new Promise()
+		@local_storage = null
+
+		@on_site_info.then =>
+			# Load user data
+			@checkUser =>
+				@on_user_info.resolve()
+
+			# Check merger permissions
+			if "Merger:ZeroMe" not in @site_info.settings.permissions
+				@cmd "wrapperPermissionAdd", "Merger:ZeroMe", =>
+					@updateSiteInfo =>
+						@content.update()
+
+
+	createProjector: ->
+		@projector = maquette.createProjector()
+		@head = new Head()
+		@content_feed = new ContentFeed()
+		@content_users = new ContentUsers()
+		@content_profile = new ContentProfile()
+		@content_create_profile = new ContentCreateProfile()
+
+		if base.href.indexOf("?") == -1
+			@route("")
+		else
+			@route(base.href.replace(/.*?\?/, ""))
+
+		# Remove fake long body
+		@on_loaded.then =>
+			@log "onloaded"
+			document.body.className = "loaded"
+
+		@projector.replace($("#Head"), @head.render)
+		@loadLocalStorage()
+
+		# Update every minute to keep time since fields up-to date
+		setInterval ( ->
+			Page.projector.scheduleRender()
+		), 60*1000
+
+	renderContent: =>
+		if @site_info
+			return h("div#Content", @content.render())
+		else
+			return h("div#Content")
+
+	# Route site urls
+	route: (query) ->
+		@params = Text.queryParse(query)
+		@log "Route", @params
+
+		if @content
+			@projector.detach(@content.render)
+
+		if @params.urls[0] == "Create+profile"
+			@content = @content_create_profile
+		else if @params.urls[0] == "Users" and
+			@content = @content_users
+		else if @params.urls[0] == "Profile"
+			@content = @content_profile
+			@content_profile.setUser(@params.urls[1], @params.urls[2], @params.urls[3])
+		else
+			@content = @content_feed
+		setTimeout ( => @content.update() ), 100
+		@on_user_info.then =>
+			@projector.replace($("#Content"), @content.render)
+
+
+	setUrl: (url) ->
+		url = url.replace(/.*?\?/, "")
+		@log "setUrl", @history_state["url"], "->", url
+		if @history_state["url"] == url
+			@content.update()
+			return false
+		@history_state["url"] = url
+		@cmd "wrapperPushState", [@history_state, "", url]
+		@route url
+		return false
+
+
+	handleLinkClick: (e) =>
+		if e.which == 2
+			# Middle click dont do anything
+			return true
+		else
+			@log "save scrollTop", window.pageYOffset
+			@history_state["scrollTop"] = window.pageYOffset
+			@cmd "wrapperReplaceState", [@history_state, null]
+
+			window.scroll(window.pageXOffset, 0)
+			@history_state["scrollTop"] = 0
+
+			@on_loaded.resolved = false
+			document.body.className = ""
+
+			@setUrl e.currentTarget.search
+
+
+	# Add/remove/change parameter to current site url
+	createUrl: (key, val) ->
+		params = JSON.parse(JSON.stringify(@params))  # Clone
+		if typeof key == "Object"
+			vals = key
+			for key, val of keys
+				params[key] = val
+		else
+			params[key] = val
+		return "?"+Text.queryEncode(params)
+
+
+	loadLocalStorage: ->
+		@on_site_info.then =>
+			@logStart "Loaded localstorage"
+			@cmd "wrapperGetLocalStorage", [], (@local_storage) =>
+				@logEnd "Loaded localstorage"
+				@local_storage ?= {}
+				@local_storage.followed_users ?= {}
+				@on_local_storage.resolve(@local_storage)
+
+
+	saveLocalStorage: (cb=null) ->
+		@logStart "Saved localstorage"
+		if @local_storage
+			@cmd "wrapperSetLocalStorage", @local_storage, (res) =>
+				@logEnd "Saved localstorage"
+				cb?(res)
+
+
+	onOpenWebsocket: (e) =>
+		@updateSiteInfo()
+		@updateServerInfo()
+
+
+	updateSiteInfo: (cb=null) =>
+		on_site_info = new Promise()
+		@cmd "mergerSiteList", {}, (merged_sites) =>
+			@merged_sites = merged_sites
+			# Add userdb if not seeded yet
+			on_site_info.then =>
+				if "Merger:ZeroMe" in @site_info.settings.permissions and not @merged_sites[@userdb]
+					@cmd "mergerSiteAdd", @userdb
+				cb?(true)
+		@cmd "siteInfo", {}, (site_info) =>
+			@address = site_info.address
+			@setSiteInfo(site_info)
+			on_site_info.resolve()
+
+
+	updateServerInfo: =>
+		@cmd "serverInfo", {}, (server_info) =>
+			@setServerInfo(server_info)
+
+	needSite: (address, cb) =>
+		if @merged_sites[address]
+			cb?(true)
+		else
+			Page.cmd "mergerSiteAdd", address, cb
+
+	checkUser: (cb=null) =>
+		@log "Find hub for user", @site_info.cert_user_id
+		if not @site_info.cert_user_id
+			@user = new AnonUser()
+			@user.updateInfo(cb)
+			return false
+
+		Page.cmd "dbQuery", ["SELECT * FROM json WHERE cert_user_id = :cert_user_id AND user_name IS NOT NULL AND file_name = 'data.json'", {cert_user_id: @site_info.cert_user_id}], (res) =>
+			if res?.length > 0
+				@log "Found row for user", res[0]
+				@user = new User({hub: res[0]["site"], auth_address: @site_info.auth_address})
+				@user.row = res[0]
+				@user.updateInfo(cb)
+			else
+				# No currently seeded user with that cert_user_id
+				@user = new AnonUser()
+				@user.updateInfo()
+				# Check in the userdb and start add the user's hub if necessary
+				@queryUserdb @site_info.auth_address, (user) =>
+					if user
+						if not @merged_sites[user.hub]
+							@log "Profile not seeded, but found in the userdb", user
+							Page.cmd "mergerSiteAdd", user.hub, =>  # Start download user's hub
+								cb?(true)
+						else
+							cb?(true)
+					else
+						# User selected, but no profile yet
+						cb?(false)
+
+
+
+			Page.projector.scheduleRender()
+
+	# Look for user in the userdb
+	queryUserdb: (auth_address, cb) =>
+		query = """
+			SELECT
+			 CASE WHEN user.auth_address IS NULL THEN REPLACE(json.directory, "data/userdb/", "") ELSE user.auth_address END AS auth_address,
+			 CASE WHEN user.cert_user_id IS NULL THEN json.cert_user_id ELSE user.cert_user_id END AS cert_user_id,
+			 *
+			FROM user
+			LEFT JOIN json USING (json_id)
+			WHERE
+			 user.auth_address = :auth_address OR
+			 json.directory = :directory
+			LIMIT 1
+		"""
+		Page.cmd "dbQuery", [query, {auth_address: auth_address, directory: "data/userdb/"+auth_address}], (res) =>
+			if res?.length > 0
+				cb?(res[0])
+			else
+				cb?(false)
+
+
+	# Parse incoming requests from UiWebsocket server
+	onRequest: (cmd, params) ->
+		if cmd == "setSiteInfo" # Site updated
+			@setSiteInfo(params)
+		else if cmd == "wrapperPopState" # Site updated
+			if params.state
+				@on_loaded.resolved = false
+				document.body.className = ""
+				window.scroll(window.pageXOffset, params.state.scrollTop or 0)
+				@route(params.state.url or "")
+		else
+			@log "Unknown command", cmd, params
+
+
+	setSiteInfo: (site_info) ->
+		# First update
+		if site_info.address == @address
+			if not @site_info  # First site info
+				@site_info = site_info
+				@on_site_info.resolve()
+			@site_info = site_info
+			if site_info.event?[0] == "cert_changed"
+				@checkUser (found) =>
+					if Page.site_info.cert_user_id and not found
+						@setUrl "?Create+profile"
+					@content.update()
+
+		if site_info.event?[0] == "file_done"
+			file_name = site_info.event[1]
+			if file_name.indexOf(site_info.auth_address) != -1 and Page.user?.auth_address != site_info.auth_address
+				# User's data arrived and not autheticated yet
+				@checkUser =>
+					@content.update()
+			else if not @merged_sites[site_info.address] and site_info.address != @address
+				# New site added
+				@log "New site added:", site_info.address
+				@updateSiteInfo =>
+					@content.update()
+			else if file_name.indexOf(site_info.auth_address) != -1
+				# User's data changed, update immedietly
+				@content.update()
+			else
+				# Rate limit other changes
+				if site_info.tasks > 100
+					RateLimit 3000, @content.update
+				else if site_info.tasks > 20
+					RateLimit 1000, @content.update
+				else
+					RateLimit 500, @content.update
+
+
+	setServerInfo: (server_info) ->
+		@server_info = server_info
+		if @server_info.rev < 1400
+			@cmd "wrapperNotification", ["error", "This site requries ZeroNet 0.4.0+<br>Please delete the site from your current client, update it, then add again!"]
+		@projector.scheduleRender()
+
+
+	# Simple return false to avoid link clicks
+	returnFalse: ->
+		return false
+
+
+window.Page = new ZeroMe()
+window.Page.createProjector()

File diff suppressed because it is too large
+ 1152 - 0
js/all.js


+ 23 - 0
js/lib/Class.coffee

@@ -0,0 +1,23 @@
+class Class
+	trace: true
+
+	log: (args...) ->
+		return unless @trace
+		return if typeof console is 'undefined'
+		args.unshift("[#{@.constructor.name}]")
+		console.log(args...)
+		@
+		
+	logStart: (name, args...) ->
+		return unless @trace
+		@logtimers or= {}
+		@logtimers[name] = +(new Date)
+		@log "#{name}", args..., "(started)" if args.length > 0
+		@
+		
+	logEnd: (name, args...) ->
+		ms = +(new Date)-@logtimers[name]
+		@log "#{name}", args..., "(Done in #{ms}ms)"
+		@ 
+
+window.Class = Class

+ 3 - 0
js/lib/Dollar.coffee

@@ -0,0 +1,3 @@
+window.$ = (selector) ->
+	if selector.startsWith("#")
+		return document.getElementById(selector.replace("#", ""))

+ 90 - 0
js/lib/Promise.coffee

@@ -0,0 +1,90 @@
+class Promise
+	@join: (tasks...) ->
+		num_uncompleted = tasks.length
+		args = new Array(num_uncompleted)
+		promise = new Promise()
+
+		for task, task_id in tasks
+			((task_id) ->
+				task.then(() ->
+					args[task_id] = Array.prototype.slice.call(arguments)
+					num_uncompleted--
+					if num_uncompleted == 0
+						for callback in promise.callbacks
+							callback.apply(promise, args)
+				)
+			)(task_id)
+
+		return promise
+
+	constructor: ->
+		@resolved = false
+		@end_promise = null
+		@result = null
+		@callbacks = []
+
+	resolve: ->
+		if @resolved
+			return false
+		@resolved = true
+		@data = arguments
+		if not arguments.length
+			@data = [true]
+		@result = @data[0]
+		for callback in @callbacks
+			back = callback.apply callback, @data
+		if @end_promise and back and back.then
+			back.then (back_res) =>
+				@end_promise.resolve(back_res)
+
+	fail: ->
+		@resolve(false)
+
+	then: (callback) ->
+		if @resolved == true
+			return callback.apply callback, @data
+
+		@callbacks.push callback
+
+		@end_promise = new Promise()
+		return @end_promise
+
+window.Promise = Promise
+
+###
+s = Date.now()
+log = (text) ->
+	console.log Date.now()-s, Array.prototype.slice.call(arguments).join(", ")
+
+log "Started"
+
+cmd = (query) ->
+	p = new Promise()
+	setTimeout ( ->
+		p.resolve query+" Result"
+	), 100
+	return p
+
+
+back = cmd("SELECT * FROM message").then (res) ->
+	log res
+	p = new Promise()
+	setTimeout ( ->
+		p.resolve("DONE parsing SELECT")
+	), 100
+	return p
+.then (res) ->
+	log "Back of messages", res
+	return cmd("SELECT * FROM users")
+.then (res) ->
+	log "End result", res
+
+log "Query started", back
+
+
+q1 = cmd("SELECT * FROM anything")
+q2 = cmd("SELECT * FROM something")
+
+Promise.join(q1, q2).then (res1, res2) ->
+  log res1, res2
+###

+ 2 - 0
js/lib/Property.coffee

@@ -0,0 +1,2 @@
+Function::property = (prop, desc) ->
+	Object.defineProperty @prototype, prop, desc

+ 8 - 0
js/lib/Prototypes.coffee

@@ -0,0 +1,8 @@
+String::startsWith = (s) -> @[...s.length] is s
+String::endsWith = (s) -> s is '' or @[-s.length..] is s
+String::repeat = (count) -> new Array( count + 1 ).join(@)
+
+window.isEmpty = (obj) ->
+	for key of obj
+		return false
+	return true

+ 54 - 0
js/lib/RateLimitCb.coffee

@@ -0,0 +1,54 @@
+last_time = {}
+calling = {}
+call_after_interval = {}
+
+# Rate limit function call and don't allow to run in parallel (until callback is called)
+window.RateLimitCb = (interval, fn, args=[]) ->
+    cb = ->  # Callback when function finished
+        left = interval - (Date.now() - last_time[fn])  # Time life until next call
+        # console.log "CB, left", left, "Calling:", calling[fn]
+        if left <= 0  # No time left from rate limit interval
+            delete last_time[fn]
+            if calling[fn]  # Function called within interval
+                RateLimitCb(interval, fn, calling[fn])
+            delete calling[fn]
+        else  # Time left from rate limit interval
+            setTimeout (->
+                delete last_time[fn]
+                if calling[fn]  # Function called within interval
+                    RateLimitCb(interval, fn, calling[fn])
+                delete calling[fn]
+            ), left
+    if last_time[fn]  # Function called within interval
+        calling[fn] = args  # Schedule call and update arguments
+    else  # Not called within interval, call instantly
+        last_time[fn] = Date.now()
+        fn.apply(this, [cb, args...])
+
+window.RateLimit = (interval, fn) ->
+    if not calling[fn]
+        call_after_interval[fn] = false
+        fn() # First call is not delayed
+        calling[fn] = setTimeout (->
+            if call_after_interval[fn]
+                fn()
+            delete calling[fn]
+            delete call_after_interval[fn]
+        ), interval
+    else # Called within iterval, delay the call
+        call_after_interval[fn] = true
+
+###
+window.s = Date.now()
+window.load = (done, num) ->
+  console.log "Loading #{num}...", Date.now()-window.s
+  setTimeout (-> done()), 1000
+
+RateLimit 500, window.load, [0] # Called instantly
+RateLimit 500, window.load, [1]
+setTimeout (-> RateLimit 500, window.load, [300]), 300
+setTimeout (-> RateLimit 500, window.load, [600]), 600 # Called after 1000ms
+setTimeout (-> RateLimit 500, window.load, [1000]), 1000
+setTimeout (-> RateLimit 500, window.load, [1200]), 1200  # Called after 2000ms
+setTimeout (-> RateLimit 500, window.load, [3000]), 3000  # Called after 3000ms
+###

+ 27 - 0
js/lib/anime.min.js

@@ -0,0 +1,27 @@
+/*
+ * Anime v1.0.0
+ * http://anime-js.com
+ * JavaScript animation engine
+ * Copyright (c) 2016 Julian Garnier
+ * http://juliangarnier.com
+ * Released under the MIT license
+ */
+(function(r,n){"function"===typeof define&&define.amd?define([],n):"object"===typeof module&&module.exports?module.exports=n():r.anime=n()})(this,function(){var r={duration:1E3,delay:0,loop:!1,autoplay:!0,direction:"normal",easing:"easeOutElastic",elasticity:400,round:!1,begin:void 0,update:void 0,complete:void 0},n="translateX translateY translateZ rotate rotateX rotateY rotateZ scale scaleX scaleY scaleZ skewX skewY".split(" "),e=function(){return{array:function(a){return Array.isArray(a)},object:function(a){return-1<
+Object.prototype.toString.call(a).indexOf("Object")},html:function(a){return a instanceof NodeList||a instanceof HTMLCollection},node:function(a){return a.nodeType},svg:function(a){return a instanceof SVGElement},number:function(a){return!isNaN(parseInt(a))},string:function(a){return"string"===typeof a},func:function(a){return"function"===typeof a},undef:function(a){return"undefined"===typeof a},"null":function(a){return"null"===typeof a},hex:function(a){return/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(a)},
+rgb:function(a){return/^rgb/.test(a)},rgba:function(a){return/^rgba/.test(a)},hsl:function(a){return/^hsl/.test(a)},color:function(a){return e.hex(a)||e.rgb(a)||e.rgba(a)||e.hsl(a)}}}(),z=function(){var a={},b={Sine:function(a){return 1-Math.cos(a*Math.PI/2)},Circ:function(a){return 1-Math.sqrt(1-a*a)},Elastic:function(a,b){if(0===a||1===a)return a;var f=1-Math.min(b,998)/1E3,h=a/1-1;return-(Math.pow(2,10*h)*Math.sin(2*(h-f/(2*Math.PI)*Math.asin(1))*Math.PI/f))},Back:function(a){return a*a*(3*a-2)},
+Bounce:function(a){for(var b,f=4;a<((b=Math.pow(2,--f))-1)/11;);return 1/Math.pow(4,3-f)-7.5625*Math.pow((3*b-2)/22-a,2)}};["Quad","Cubic","Quart","Quint","Expo"].forEach(function(a,d){b[a]=function(a){return Math.pow(a,d+2)}});Object.keys(b).forEach(function(c){var d=b[c];a["easeIn"+c]=d;a["easeOut"+c]=function(a,b){return 1-d(1-a,b)};a["easeInOut"+c]=function(a,b){return.5>a?d(2*a,b)/2:1-d(-2*a+2,b)/2}});a.linear=function(a){return a};return a}(),u=function(a){return e.string(a)?a:a+""},A=function(a){return a.replace(/([a-z])([A-Z])/g,
+"$1-$2").toLowerCase()},B=function(a){if(e.color(a))return!1;try{return document.querySelectorAll(a)}catch(b){return!1}},v=function(a){return a.reduce(function(a,c){return a.concat(e.array(c)?v(c):c)},[])},p=function(a){if(e.array(a))return a;e.string(a)&&(a=B(a)||a);return e.html(a)?[].slice.call(a):[a]},C=function(a,b){return a.some(function(a){return a===b})},N=function(a,b){var c={};a.forEach(function(a){var f=JSON.stringify(b.map(function(b){return a[b]}));c[f]=c[f]||[];c[f].push(a)});return Object.keys(c).map(function(a){return c[a]})},
+D=function(a){return a.filter(function(a,c,d){return d.indexOf(a)===c})},w=function(a){var b={},c;for(c in a)b[c]=a[c];return b},t=function(a,b){for(var c in b)a[c]=e.undef(a[c])?b[c]:a[c];return a},O=function(a){a=a.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i,function(a,b,c,e){return b+b+c+c+e+e});var b=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(a);a=parseInt(b[1],16);var c=parseInt(b[2],16),b=parseInt(b[3],16);return"rgb("+a+","+c+","+b+")"},P=function(a){a=/hsl\((\d+),\s*([\d.]+)%,\s*([\d.]+)%\)/g.exec(a);
+var b=parseInt(a[1])/360,c=parseInt(a[2])/100,d=parseInt(a[3])/100;a=function(a,b,c){0>c&&(c+=1);1<c&&--c;return c<1/6?a+6*(b-a)*c:.5>c?b:c<2/3?a+(b-a)*(2/3-c)*6:a};if(0==c)c=d=b=d;else var f=.5>d?d*(1+c):d+c-d*c,h=2*d-f,c=a(h,f,b+1/3),d=a(h,f,b),b=a(h,f,b-1/3);return"rgb("+255*c+","+255*d+","+255*b+")"},k=function(a){return/([\+\-]?[0-9|auto\.]+)(%|px|pt|em|rem|in|cm|mm|ex|pc|vw|vh|deg)?/.exec(a)[2]},E=function(a,b,c){return k(b)?b:-1<a.indexOf("translate")?k(c)?b+k(c):b+"px":-1<a.indexOf("rotate")||
+-1<a.indexOf("skew")?b+"deg":b},F=function(a,b){if((e.node(a)||e.svg(a))&&C(n,b))return"transform";if((e.node(a)||e.svg(a))&&"transform"!==b&&x(a,b))return"css";if((e.node(a)||e.svg(a))&&(a.getAttribute(b)||e.svg(a)&&a[b]))return"attribute";if(!e["null"](a[b])&&!e.undef(a[b]))return"object"},x=function(a,b){if(b in a.style)return getComputedStyle(a).getPropertyValue(A(b))||"0"},Q=function(a,b){var c=-1<b.indexOf("scale")?1:0,d=a.style.transform;if(!d)return c;for(var f=/(\w+)\((.+?)\)/g,h=[],e=[],
+q=[];h=f.exec(d);)e.push(h[1]),q.push(h[2]);d=q.filter(function(a,c){return e[c]===b});return d.length?d[0]:c},G=function(a,b){switch(F(a,b)){case "transform":return Q(a,b);case "css":return x(a,b);case "attribute":return a.getAttribute(b)}return a[b]||0},H=function(a,b,c){if(e.color(b))return b=e.rgb(b)||e.rgba(b)?b:e.hex(b)?O(b):e.hsl(b)?P(b):void 0,b;if(k(b))return b;a=k(a.to)?k(a.to):k(a.from);!a&&c&&(a=k(c));return a?b+a:b},I=function(a){var b=/-?\d*\.?\d+/g;return{original:a,numbers:u(a).match(b)?
+u(a).match(b).map(Number):[0],strings:u(a).split(b)}},R=function(a,b,c){return b.reduce(function(b,f,e){f=f?f:c[e-1];return b+a[e-1]+f})},S=function(a){a=a?v(e.array(a)?a.map(p):p(a)):[];return a.map(function(a,c){return{target:a,id:c}})},T=function(a,b){var c=[],d;for(d in a)if(!r.hasOwnProperty(d)&&"targets"!==d){var f=e.object(a[d])?w(a[d]):{value:a[d]};f.name=d;c.push(t(f,b))}return c},J=function(a,b,c,d){"transform"===c?(c=a+"("+E(a,b.from,b.to)+")",b=a+"("+E(a,b.to)+")"):(a="css"===c?x(d,a):
+void 0,c=H(b,b.from,a),b=H(b,b.to,a));return{from:I(c),to:I(b)}},U=function(a,b){var c=[];a.forEach(function(d,f){var h=d.target;return b.forEach(function(b){var q=F(h,b.name);if(q){var k;k=b.name;var g=b.value,g=p(e.func(g)?g(h,f):g);k={from:1<g.length?g[0]:G(h,k),to:1<g.length?g[1]:g[0]};g=w(b);g.animatables=d;g.type=q;g.from=J(b.name,k,g.type,h).from;g.to=J(b.name,k,g.type,h).to;g.round=e.color(k.from)||g.round?1:0;g.delay=(e.func(g.delay)?g.delay(h,f,a.length):g.delay)/l.speed;g.duration=(e.func(g.duration)?
+g.duration(h,f,a.length):g.duration)/l.speed;c.push(g)}})});return c},V=function(a,b){var c=U(a,b);return N(c,["name","from","to","delay","duration"]).map(function(a){var b=w(a[0]);b.animatables=a.map(function(a){return a.animatables});b.totalDuration=b.delay+b.duration;return b})},y=function(a,b){a.tweens.forEach(function(c){var d=c.from,f=a.duration-(c.delay+c.duration);c.from=c.to;c.to=d;b&&(c.delay=f)});a.reversed=a.reversed?!1:!0},K=function(a){var b=[],c=[];a.tweens.forEach(function(a){if("css"===
+a.type||"transform"===a.type)b.push("css"===a.type?A(a.name):"transform"),a.animatables.forEach(function(a){c.push(a.target)})});return{properties:D(b).join(", "),elements:D(c)}},W=function(a){var b=K(a);b.elements.forEach(function(a){a.style.willChange=b.properties})},X=function(a){K(a).elements.forEach(function(a){a.style.removeProperty("will-change")})},Y=function(a,b){var c=a.path,d=a.value*b,f=function(f){f=f||0;return c.getPointAtLength(1<b?a.value+f:d+f)},e=f(),k=f(-1),f=f(1);switch(a.name){case "translateX":return e.x;
+case "translateY":return e.y;case "rotate":return 180*Math.atan2(f.y-k.y,f.x-k.x)/Math.PI}},Z=function(a,b){var c=Math.min(Math.max(b-a.delay,0),a.duration)/a.duration,d=a.to.numbers.map(function(b,d){var e=a.from.numbers[d],k=z[a.easing](c,a.elasticity),e=a.path?Y(a,k):e+k*(b-e);return e=a.round?Math.round(e*a.round)/a.round:e});return R(d,a.to.strings,a.from.strings)},L=function(a,b){var c=void 0;a.time=Math.min(b,a.duration);a.progress=a.time/a.duration*100;a.tweens.forEach(function(a){a.currentValue=
+Z(a,b);var d=a.currentValue;a.animatables.forEach(function(b){var e=b.id;switch(a.type){case "css":b.target.style[a.name]=d;break;case "attribute":b.target.setAttribute(a.name,d);break;case "object":b.target[a.name]=d;break;case "transform":c||(c={}),c[e]||(c[e]=[]),c[e].push(d)}})});if(c)for(var d in c)a.animatables[d].target.style.transform=c[d].join(" ");a.settings.update&&a.settings.update(a)},M=function(a){var b={};b.animatables=S(a.targets);b.settings=t(a,r);b.properties=T(a,b.settings);b.tweens=
+V(b.animatables,b.properties);b.duration=b.tweens.length?Math.max.apply(Math,b.tweens.map(function(a){return a.totalDuration})):a.duration/l.speed;b.time=0;b.progress=0;b.running=!1;b.ended=!1;return b},m=[],l=function(a){var b=M(a),c={tick:function(){if(b.running){b.ended=!1;c.now=+new Date;c.current=c.last+c.now-c.start;L(b,c.current);var a=b.settings;a.begin&&c.current>=a.delay&&(a.begin(b),a.begin=void 0);c.current>=b.duration?(a.loop?(c.start=+new Date,"alternate"===a.direction&&y(b,!0),e.number(a.loop)&&
+a.loop--,c.raf=requestAnimationFrame(c.tick)):(b.ended=!0,a.complete&&a.complete(b),b.pause()),c.last=0):c.raf=requestAnimationFrame(c.tick)}}};b.seek=function(a){L(b,a/100*b.duration)};b.pause=function(){b.running=!1;cancelAnimationFrame(c.raf);X(b);var a=m.indexOf(b);-1<a&&m.splice(a,1)};b.play=function(a){a&&(b=t(M(t(a,b.settings)),b));b.pause();b.running=!0;c.start=+new Date;c.last=b.ended?0:b.time;a=b.settings;"reverse"===a.direction&&y(b);"alternate"!==a.direction||a.loop||(a.loop=1);W(b);m.push(b);
+c.raf=requestAnimationFrame(c.tick)};b.restart=function(){b.reversed&&y(b);b.pause();b.seek(0);b.play()};b.settings.autoplay&&b.play();return b};l.speed=1;l.list=m;l.remove=function(a){a=v(e.array(a)?a.map(p):p(a));for(var b=m.length-1;0<=b;b--)for(var c=m[b],d=c.tweens.length-1;0<=d;d--)for(var f=c.tweens[d],h=f.animatables.length-1;0<=h;h--)C(a,f.animatables[h].target)&&(f.animatables.splice(h,1),f.animatables.length||c.tweens.splice(d,1),c.tweens.length||c.pause())};l.easings=z;l.getValue=G;l.path=
+function(a){a=e.string(a)?B(a)[0]:a;return{path:a,value:a.getTotalLength()}};l.random=function(a,b){return Math.floor(Math.random()*(b-a+1))+a};return l});

+ 8 - 0
js/lib/clone.js

@@ -0,0 +1,8 @@
+function clone(obj) {
+    if (null == obj || "object" != typeof obj) return obj;
+    var copy = obj.constructor();
+    for (var attr in obj) {
+        if (obj.hasOwnProperty(attr)) copy[attr] = clone(obj[attr]);
+    }
+    return copy;
+}

+ 773 - 0
js/lib/maquette.js

@@ -0,0 +1,773 @@
+(function (root, factory) {
+    if (typeof define === 'function' && define.amd) {
+        // AMD. Register as an anonymous module.
+        define(['exports'], factory);
+    } else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
+        // CommonJS
+        factory(exports);
+    } else {
+        // Browser globals
+        factory(root.maquette = {});
+    }
+}(this, function (exports) {
+    'use strict';
+    ;
+    ;
+    ;
+    ;
+    var NAMESPACE_W3 = 'http://www.w3.org/';
+    var NAMESPACE_SVG = NAMESPACE_W3 + '2000/svg';
+    var NAMESPACE_XLINK = NAMESPACE_W3 + '1999/xlink';
+    // Utilities
+    var emptyArray = [];
+    var extend = function (base, overrides) {
+        var result = {};
+        Object.keys(base).forEach(function (key) {
+            result[key] = base[key];
+        });
+        if (overrides) {
+            Object.keys(overrides).forEach(function (key) {
+                result[key] = overrides[key];
+            });
+        }
+        return result;
+    };
+    // Hyperscript helper functions
+    var same = function (vnode1, vnode2) {
+        if (vnode1.vnodeSelector !== vnode2.vnodeSelector) {
+            return false;
+        }
+        if (vnode1.properties && vnode2.properties) {
+            if (vnode1.properties.key !== vnode2.properties.key) {
+                return false;
+            }
+            return vnode1.properties.bind === vnode2.properties.bind;
+        }
+        return !vnode1.properties && !vnode2.properties;
+    };
+    var toTextVNode = function (data) {
+        return {
+            vnodeSelector: '',
+            properties: undefined,
+            children: undefined,
+            text: data.toString(),
+            domNode: null
+        };
+    };
+    var appendChildren = function (parentSelector, insertions, main) {
+        for (var i = 0; i < insertions.length; i++) {
+            var item = insertions[i];
+            if (Array.isArray(item)) {
+                appendChildren(parentSelector, item, main);
+            } else {
+                if (item !== null && item !== undefined) {
+                    if (!item.hasOwnProperty('vnodeSelector')) {
+                        item = toTextVNode(item);
+                    }
+                    main.push(item);
+                }
+            }
+        }
+    };
+    // Render helper functions
+    var missingTransition = function () {
+        throw new Error('Provide a transitions object to the projectionOptions to do animations');
+    };
+    var DEFAULT_PROJECTION_OPTIONS = {
+        namespace: undefined,
+        eventHandlerInterceptor: undefined,
+        styleApplyer: function (domNode, styleName, value) {
+            // Provides a hook to add vendor prefixes for browsers that still need it.
+            domNode.style[styleName] = value;
+        },
+        transitions: {
+            enter: missingTransition,
+            exit: missingTransition
+        }
+    };
+    var applyDefaultProjectionOptions = function (projectorOptions) {
+        return extend(DEFAULT_PROJECTION_OPTIONS, projectorOptions);
+    };
+    var checkStyleValue = function (styleValue) {
+        if (typeof styleValue !== 'string') {
+            throw new Error('Style values must be strings');
+        }
+    };
+    var setProperties = function (domNode, properties, projectionOptions) {
+        if (!properties) {
+            return;
+        }
+        var eventHandlerInterceptor = projectionOptions.eventHandlerInterceptor;
+        var propNames = Object.keys(properties);
+        var propCount = propNames.length;
+        for (var i = 0; i < propCount; i++) {
+            var propName = propNames[i];
+            /* tslint:disable:no-var-keyword: edge case */
+            var propValue = properties[propName];
+            /* tslint:enable:no-var-keyword */
+            if (propName === 'className') {
+                throw new Error('Property "className" is not supported, use "class".');
+            } else if (propName === 'class') {
+                if (domNode.className) {
+                    // May happen if classes is specified before class
+                    domNode.className += ' ' + propValue;
+                } else {
+                    domNode.className = propValue;
+                }
+            } else if (propName === 'classes') {
+                // object with string keys and boolean values
+                var classNames = Object.keys(propValue);
+                var classNameCount = classNames.length;
+                for (var j = 0; j < classNameCount; j++) {
+                    var className = classNames[j];
+                    if (propValue[className]) {
+                        domNode.classList.add(className);
+                    }
+                }
+            } else if (propName === 'styles') {
+                // object with string keys and string (!) values
+                var styleNames = Object.keys(propValue);
+                var styleCount = styleNames.length;
+                for (var j = 0; j < styleCount; j++) {
+                    var styleName = styleNames[j];
+                    var styleValue = propValue[styleName];
+                    if (styleValue) {
+                        checkStyleValue(styleValue);
+                        projectionOptions.styleApplyer(domNode, styleName, styleValue);
+                    }
+                }
+            } else if (propName === 'key') {
+                continue;
+            } else if (propValue === null || propValue === undefined) {
+                continue;
+            } else {
+                var type = typeof propValue;
+                if (type === 'function') {
+                    if (propName.lastIndexOf('on', 0) === 0) {
+                        if (eventHandlerInterceptor) {
+                            propValue = eventHandlerInterceptor(propName, propValue, domNode, properties);    // intercept eventhandlers
+                        }
+                        if (propName === 'oninput') {
+                            (function () {
+                                // record the evt.target.value, because IE and Edge sometimes do a requestAnimationFrame between changing value and running oninput
+                                var oldPropValue = propValue;
+                                propValue = function (evt) {
+                                    evt.target['oninput-value'] = evt.target.value;
+                                    // may be HTMLTextAreaElement as well
+                                    oldPropValue.apply(this, [evt]);
+                                };
+                            }());
+                        }
+                        domNode[propName] = propValue;
+                    }
+                } else if (type === 'string' && propName !== 'value' && propName !== 'innerHTML') {
+                    if (projectionOptions.namespace === NAMESPACE_SVG && propName === 'href') {
+                        domNode.setAttributeNS(NAMESPACE_XLINK, propName, propValue);
+                    } else {
+                        domNode.setAttribute(propName, propValue);
+                    }
+                } else {
+                    domNode[propName] = propValue;
+                }
+            }
+        }
+    };
+    var updateProperties = function (domNode, previousProperties, properties, projectionOptions) {
+        if (!properties) {
+            return;
+        }
+        var propertiesUpdated = false;
+        var propNames = Object.keys(properties);
+        var propCount = propNames.length;
+        for (var i = 0; i < propCount; i++) {
+            var propName = propNames[i];
+            // assuming that properties will be nullified instead of missing is by design
+            var propValue = properties[propName];
+            var previousValue = previousProperties[propName];
+            if (propName === 'class') {
+                if (previousValue !== propValue) {
+                    throw new Error('"class" property may not be updated. Use the "classes" property for conditional css classes.');
+                }
+            } else if (propName === 'classes') {
+                var classList = domNode.classList;
+                var classNames = Object.keys(propValue);
+                var classNameCount = classNames.length;
+                for (var j = 0; j < classNameCount; j++) {
+                    var className = classNames[j];
+                    var on = !!propValue[className];
+                    var previousOn = !!previousValue[className];
+                    if (on === previousOn) {
+                        continue;
+                    }
+                    propertiesUpdated = true;
+                    if (on) {
+                        classList.add(className);
+                    } else {
+                        classList.remove(className);
+                    }
+                }
+            } else if (propName === 'styles') {
+                var styleNames = Object.keys(propValue);
+                var styleCount = styleNames.length;
+                for (var j = 0; j < styleCount; j++) {
+                    var styleName = styleNames[j];
+                    var newStyleValue = propValue[styleName];
+                    var oldStyleValue = previousValue[styleName];
+                    if (newStyleValue === oldStyleValue) {
+                        continue;
+                    }
+                    propertiesUpdated = true;
+                    if (newStyleValue) {
+                        checkStyleValue(newStyleValue);
+                        projectionOptions.styleApplyer(domNode, styleName, newStyleValue);
+                    } else {
+                        projectionOptions.styleApplyer(domNode, styleName, '');
+                    }
+                }
+            } else {
+                if (!propValue && typeof previousValue === 'string') {
+                    propValue = '';
+                }
+                if (propName === 'value') {
+                    if (domNode[propName] !== propValue && domNode['oninput-value'] !== propValue) {
+                        domNode[propName] = propValue;
+                        // Reset the value, even if the virtual DOM did not change
+                        domNode['oninput-value'] = undefined;
+                    }
+                    // else do not update the domNode, otherwise the cursor position would be changed
+                    if (propValue !== previousValue) {
+                        propertiesUpdated = true;
+                    }
+                } else if (propValue !== previousValue) {
+                    var type = typeof propValue;
+                    if (type === 'function') {
+                        throw new Error('Functions may not be updated on subsequent renders (property: ' + propName + '). Hint: declare event handler functions outside the render() function.');
+                    }
+                    if (type === 'string' && propName !== 'innerHTML') {
+                        if (projectionOptions.namespace === NAMESPACE_SVG && propName === 'href') {
+                            domNode.setAttributeNS(NAMESPACE_XLINK, propName, propValue);
+                        } else {
+                            domNode.setAttribute(propName, propValue);
+                        }
+                    } else {
+                        if (domNode[propName] !== propValue) {
+                            domNode[propName] = propValue;
+                        }
+                    }
+                    propertiesUpdated = true;
+                }
+            }
+        }
+        return propertiesUpdated;
+    };
+    var findIndexOfChild = function (children, sameAs, start) {
+        if (sameAs.vnodeSelector !== '') {
+            // Never scan for text-nodes
+            for (var i = start; i < children.length; i++) {
+                if (same(children[i], sameAs)) {
+                    return i;
+                }
+            }
+        }
+        return -1;
+    };
+    var nodeAdded = function (vNode, transitions) {
+        if (vNode.properties) {
+            var enterAnimation = vNode.properties.enterAnimation;
+            if (enterAnimation) {
+                if (typeof enterAnimation === 'function') {
+                    enterAnimation(vNode.domNode, vNode.properties);
+                } else {
+                    transitions.enter(vNode.domNode, vNode.properties, enterAnimation);
+                }
+            }
+        }
+    };
+    var nodeToRemove = function (vNode, transitions) {
+        var domNode = vNode.domNode;
+        if (vNode.properties) {
+            var exitAnimation = vNode.properties.exitAnimation;
+            if (exitAnimation) {
+                domNode.style.pointerEvents = 'none';
+                var removeDomNode = function () {
+                    if (domNode.parentNode) {
+                        domNode.parentNode.removeChild(domNode);
+                    }
+                };
+                if (typeof exitAnimation === 'function') {
+                    exitAnimation(domNode, removeDomNode, vNode.properties);
+                    return;
+                } else {
+                    transitions.exit(vNode.domNode, vNode.properties, exitAnimation, removeDomNode);
+                    return;
+                }
+            }
+        }
+        if (domNode.parentNode) {
+            domNode.parentNode.removeChild(domNode);
+        }
+    };
+    var checkDistinguishable = function (childNodes, indexToCheck, parentVNode, operation) {
+        var childNode = childNodes[indexToCheck];
+        if (childNode.vnodeSelector === '') {
+            return;    // Text nodes need not be distinguishable
+        }
+        var properties = childNode.properties;
+        var key = properties ? properties.key === undefined ? properties.bind : properties.key : undefined;
+        if (!key) {
+            for (var i = 0; i < childNodes.length; i++) {
+                if (i !== indexToCheck) {
+                    var node = childNodes[i];
+                    if (same(node, childNode)) {
+                        if (operation === 'added') {
+                            throw new Error(parentVNode.vnodeSelector + ' had a ' + childNode.vnodeSelector + ' child ' + 'added, but there is now more than one. You must add unique key properties to make them distinguishable.');
+                        } else {
+                            throw new Error(parentVNode.vnodeSelector + ' had a ' + childNode.vnodeSelector + ' child ' + 'removed, but there were more than one. You must add unique key properties to make them distinguishable.');
+                        }
+                    }
+                }
+            }
+        }
+    };
+    var createDom;
+    var updateDom;
+    var updateChildren = function (vnode, domNode, oldChildren, newChildren, projectionOptions) {
+        if (oldChildren === newChildren) {
+            return false;
+        }
+        oldChildren = oldChildren || emptyArray;
+        newChildren = newChildren || emptyArray;
+        var oldChildrenLength = oldChildren.length;
+        var newChildrenLength = newChildren.length;
+        var transitions = projectionOptions.transitions;
+        var oldIndex = 0;
+        var newIndex = 0;
+        var i;
+        var textUpdated = false;
+        while (newIndex < newChildrenLength) {
+            var oldChild = oldIndex < oldChildrenLength ? oldChildren[oldIndex] : undefined;
+            var newChild = newChildren[newIndex];
+            if (oldChild !== undefined && same(oldChild, newChild)) {
+                textUpdated = updateDom(oldChild, newChild, projectionOptions) || textUpdated;
+                oldIndex++;
+            } else {
+                var findOldIndex = findIndexOfChild(oldChildren, newChild, oldIndex + 1);
+                if (findOldIndex >= 0) {
+                    // Remove preceding missing children
+                    for (i = oldIndex; i < findOldIndex; i++) {
+                        nodeToRemove(oldChildren[i], transitions);
+                        checkDistinguishable(oldChildren, i, vnode, 'removed');
+                    }
+                    textUpdated = updateDom(oldChildren[findOldIndex], newChild, projectionOptions) || textUpdated;
+                    oldIndex = findOldIndex + 1;
+                } else {
+                    // New child
+                    createDom(newChild, domNode, oldIndex < oldChildrenLength ? oldChildren[oldIndex].domNode : undefined, projectionOptions);
+                    nodeAdded(newChild, transitions);
+                    checkDistinguishable(newChildren, newIndex, vnode, 'added');
+                }
+            }
+            newIndex++;
+        }
+        if (oldChildrenLength > oldIndex) {
+            // Remove child fragments
+            for (i = oldIndex; i < oldChildrenLength; i++) {
+                nodeToRemove(oldChildren[i], transitions);
+                checkDistinguishable(oldChildren, i, vnode, 'removed');
+            }
+        }
+        return textUpdated;
+    };
+    var addChildren = function (domNode, children, projectionOptions) {
+        if (!children) {
+            return;
+        }
+        for (var i = 0; i < children.length; i++) {
+            createDom(children[i], domNode, undefined, projectionOptions);
+        }
+    };
+    var initPropertiesAndChildren = function (domNode, vnode, projectionOptions) {
+        addChildren(domNode, vnode.children, projectionOptions);
+        // children before properties, needed for value property of <select>.
+        if (vnode.text) {
+            domNode.textContent = vnode.text;
+        }
+        setProperties(domNode, vnode.properties, projectionOptions);
+        if (vnode.properties && vnode.properties.afterCreate) {
+            vnode.properties.afterCreate(domNode, projectionOptions, vnode.vnodeSelector, vnode.properties, vnode.children);
+        }
+    };
+    createDom = function (vnode, parentNode, insertBefore, projectionOptions) {
+        var domNode, i, c, start = 0, type, found;
+        var vnodeSelector = vnode.vnodeSelector;
+        if (vnodeSelector === '') {
+            domNode = vnode.domNode = document.createTextNode(vnode.text);
+            if (insertBefore !== undefined) {
+                parentNode.insertBefore(domNode, insertBefore);
+            } else {
+                parentNode.appendChild(domNode);
+            }
+        } else {
+            for (i = 0; i <= vnodeSelector.length; ++i) {
+                c = vnodeSelector.charAt(i);
+                if (i === vnodeSelector.length || c === '.' || c === '#') {
+                    type = vnodeSelector.charAt(start - 1);
+                    found = vnodeSelector.slice(start, i);
+                    if (type === '.') {
+                        domNode.classList.add(found);
+                    } else if (type === '#') {
+                        domNode.id = found;
+                    } else {
+                        if (found === 'svg') {
+                            projectionOptions = extend(projectionOptions, { namespace: NAMESPACE_SVG });
+                        }
+                        if (projectionOptions.namespace !== undefined) {
+                            domNode = vnode.domNode = document.createElementNS(projectionOptions.namespace, found);
+                        } else {
+                            domNode = vnode.domNode = document.createElement(found);
+                        }
+                        if (insertBefore !== undefined) {
+                            parentNode.insertBefore(domNode, insertBefore);
+                        } else {
+                            parentNode.appendChild(domNode);
+                        }
+                    }
+                    start = i + 1;
+                }
+            }
+            initPropertiesAndChildren(domNode, vnode, projectionOptions);
+        }
+    };
+    updateDom = function (previous, vnode, projectionOptions) {
+        var domNode = previous.domNode;
+        var textUpdated = false;
+        if (previous === vnode) {
+            return false;    // By contract, VNode objects may not be modified anymore after passing them to maquette
+        }
+        var updated = false;
+        if (vnode.vnodeSelector === '') {
+            if (vnode.text !== previous.text) {
+                var newVNode = document.createTextNode(vnode.text);
+                domNode.parentNode.replaceChild(newVNode, domNode);
+                vnode.domNode = newVNode;
+                textUpdated = true;
+                return textUpdated;
+            }
+        } else {
+            if (vnode.vnodeSelector.lastIndexOf('svg', 0) === 0) {
+                projectionOptions = extend(projectionOptions, { namespace: NAMESPACE_SVG });
+            }
+            if (previous.text !== vnode.text) {
+                updated = true;
+                if (vnode.text === undefined) {
+                    domNode.removeChild(domNode.firstChild);    // the only textnode presumably
+                } else {
+                    domNode.textContent = vnode.text;
+                }
+            }
+            updated = updateChildren(vnode, domNode, previous.children, vnode.children, projectionOptions) || updated;
+            updated = updateProperties(domNode, previous.properties, vnode.properties, projectionOptions) || updated;
+            if (vnode.properties && vnode.properties.afterUpdate) {
+                vnode.properties.afterUpdate(domNode, projectionOptions, vnode.vnodeSelector, vnode.properties, vnode.children);
+            }
+        }
+        if (updated && vnode.properties && vnode.properties.updateAnimation) {
+            vnode.properties.updateAnimation(domNode, vnode.properties, previous.properties);
+        }
+        vnode.domNode = previous.domNode;
+        return textUpdated;
+    };
+    var createProjection = function (vnode, projectionOptions) {
+        return {
+            update: function (updatedVnode) {
+                if (vnode.vnodeSelector !== updatedVnode.vnodeSelector) {
+                    throw new Error('The selector for the root VNode may not be changed. (consider using dom.merge and add one extra level to the virtual DOM)');
+                }
+                updateDom(vnode, updatedVnode, projectionOptions);
+                vnode = updatedVnode;
+            },
+            domNode: vnode.domNode
+        };
+    };
+    ;
+    // The other two parameters are not added here, because the Typescript compiler creates surrogate code for desctructuring 'children'.
+    exports.h = function (selector) {
+        var properties = arguments[1];
+        if (typeof selector !== 'string') {
+            throw new Error();
+        }
+        var childIndex = 1;
+        if (properties && !properties.hasOwnProperty('vnodeSelector') && !Array.isArray(properties) && typeof properties === 'object') {
+            childIndex = 2;
+        } else {
+            // Optional properties argument was omitted
+            properties = undefined;
+        }
+        var text = undefined;
+        var children = undefined;
+        var argsLength = arguments.length;
+        // Recognize a common special case where there is only a single text node
+        if (argsLength === childIndex + 1) {
+            var onlyChild = arguments[childIndex];
+            if (typeof onlyChild === 'string') {
+                text = onlyChild;
+            } else if (onlyChild !== undefined && onlyChild !== null && onlyChild.length === 1 && typeof onlyChild[0] === 'string') {
+                text = onlyChild[0];
+            }
+        }
+        if (text === undefined) {
+            children = [];
+            for (; childIndex < arguments.length; childIndex++) {
+                var child = arguments[childIndex];
+                if (child === null || child === undefined) {
+                    continue;
+                } else if (Array.isArray(child)) {
+                    appendChildren(selector, child, children);
+                } else if (child.hasOwnProperty('vnodeSelector')) {
+                    children.push(child);
+                } else {
+                    children.push(toTextVNode(child));
+                }
+            }
+        }
+        return {
+            vnodeSelector: selector,
+            properties: properties,
+            children: children,
+            text: text === '' ? undefined : text,
+            domNode: null
+        };
+    };
+    /**
+ * Contains simple low-level utility functions to manipulate the real DOM.
+ */
+    exports.dom = {
+        /**
+     * Creates a real DOM tree from `vnode`. The [[Projection]] object returned will contain the resulting DOM Node in
+     * its [[Projection.domNode|domNode]] property.
+     * This is a low-level method. Users wil typically use a [[Projector]] instead.
+     * @param vnode - The root of the virtual DOM tree that was created using the [[h]] function. NOTE: [[VNode]]
+     * objects may only be rendered once.
+     * @param projectionOptions - Options to be used to create and update the projection.
+     * @returns The [[Projection]] which also contains the DOM Node that was created.
+     */
+        create: function (vnode, projectionOptions) {
+            projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+            createDom(vnode, document.createElement('div'), undefined, projectionOptions);
+            return createProjection(vnode, projectionOptions);
+        },
+        /**
+     * Appends a new childnode to the DOM which is generated from a [[VNode]].
+     * This is a low-level method. Users wil typically use a [[Projector]] instead.
+     * @param parentNode - The parent node for the new childNode.
+     * @param vnode - The root of the virtual DOM tree that was created using the [[h]] function. NOTE: [[VNode]]
+     * objects may only be rendered once.
+     * @param projectionOptions - Options to be used to create and update the [[Projection]].
+     * @returns The [[Projection]] that was created.
+     */
+        append: function (parentNode, vnode, projectionOptions) {
+            projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+            createDom(vnode, parentNode, undefined, projectionOptions);
+            return createProjection(vnode, projectionOptions);
+        },
+        /**
+     * Inserts a new DOM node which is generated from a [[VNode]].
+     * This is a low-level method. Users wil typically use a [[Projector]] instead.
+     * @param beforeNode - The node that the DOM Node is inserted before.
+     * @param vnode - The root of the virtual DOM tree that was created using the [[h]] function.
+     * NOTE: [[VNode]] objects may only be rendered once.
+     * @param projectionOptions - Options to be used to create and update the projection, see [[createProjector]].
+     * @returns The [[Projection]] that was created.
+     */
+        insertBefore: function (beforeNode, vnode, projectionOptions) {
+            projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+            createDom(vnode, beforeNode.parentNode, beforeNode, projectionOptions);
+            return createProjection(vnode, projectionOptions);
+        },
+        /**
+     * Merges a new DOM node which is generated from a [[VNode]] with an existing DOM Node.
+     * This means that the virtual DOM and the real DOM will have one overlapping element.
+     * Therefore the selector for the root [[VNode]] will be ignored, but its properties and children will be applied to the Element provided.
+     * This is a low-level method. Users wil typically use a [[Projector]] instead.
+     * @param domNode - The existing element to adopt as the root of the new virtual DOM. Existing attributes and childnodes are preserved.
+     * @param vnode - The root of the virtual DOM tree that was created using the [[h]] function. NOTE: [[VNode]] objects
+     * may only be rendered once.
+     * @param projectionOptions - Options to be used to create and update the projection, see [[createProjector]].
+     * @returns The [[Projection]] that was created.
+     */
+        merge: function (element, vnode, projectionOptions) {
+            projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+            vnode.domNode = element;
+            initPropertiesAndChildren(element, vnode, projectionOptions);
+            return createProjection(vnode, projectionOptions);
+        }
+    };
+    /**
+ * Creates a [[CalculationCache]] object, useful for caching [[VNode]] trees.
+ * In practice, caching of [[VNode]] trees is not needed, because achieving 60 frames per second is almost never a problem.
+ * For more information, see [[CalculationCache]].
+ *
+ * @param <Result> The type of the value that is cached.
+ */
+    exports.createCache = function () {
+        var cachedInputs = undefined;
+        var cachedOutcome = undefined;
+        var result = {
+            invalidate: function () {
+                cachedOutcome = undefined;
+                cachedInputs = undefined;
+            },
+            result: function (inputs, calculation) {
+                if (cachedInputs) {
+                    for (var i = 0; i < inputs.length; i++) {
+                        if (cachedInputs[i] !== inputs[i]) {
+                            cachedOutcome = undefined;
+                        }
+                    }
+                }
+                if (!cachedOutcome) {
+                    cachedOutcome = calculation();
+                    cachedInputs = inputs;
+                }
+                return cachedOutcome;
+            }
+        };
+        return result;
+    };
+    /**
+ * Creates a {@link Mapping} instance that keeps an array of result objects synchronized with an array of source objects.
+ * See {@link http://maquettejs.org/docs/arrays.html|Working with arrays}.
+ *
+ * @param <Source>       The type of source items. A database-record for instance.
+ * @param <Target>       The type of target items. A [[Component]] for instance.
+ * @param getSourceKey   `function(source)` that must return a key to identify each source object. The result must either be a string or a number.
+ * @param createResult   `function(source, index)` that must create a new result object from a given source. This function is identical
+ *                       to the `callback` argument in `Array.map(callback)`.
+ * @param updateResult   `function(source, target, index)` that updates a result to an updated source.
+ */
+    exports.createMapping = function (getSourceKey, createResult, updateResult) {
+        var keys = [];
+        var results = [];
+        return {
+            results: results,
+            map: function (newSources) {
+                var newKeys = newSources.map(getSourceKey);
+                var oldTargets = results.slice();
+                var oldIndex = 0;
+                for (var i = 0; i < newSources.length; i++) {
+                    var source = newSources[i];
+                    var sourceKey = newKeys[i];
+                    if (sourceKey === keys[oldIndex]) {
+                        results[i] = oldTargets[oldIndex];
+                        updateResult(source, oldTargets[oldIndex], i);
+                        oldIndex++;
+                    } else {
+                        var found = false;
+                        for (var j = 1; j < keys.length; j++) {
+                            var searchIndex = (oldIndex + j) % keys.length;
+                            if (keys[searchIndex] === sourceKey) {
+                                results[i] = oldTargets[searchIndex];
+                                updateResult(newSources[i], oldTargets[searchIndex], i);
+                                oldIndex = searchIndex + 1;
+                                found = true;
+                                break;
+                            }
+                        }
+                        if (!found) {
+                            results[i] = createResult(source, i);
+                        }
+                    }
+                }
+                results.length = newSources.length;
+                keys = newKeys;
+            }
+        };
+    };
+    /**
+ * Creates a [[Projector]] instance using the provided projectionOptions.
+ *
+ * For more information, see [[Projector]].
+ *
+ * @param projectionOptions   Options that influence how the DOM is rendered and updated.
+ */
+    exports.createProjector = function (projectorOptions) {
+        var projector;
+        var projectionOptions = applyDefaultProjectionOptions(projectorOptions);
+        projectionOptions.eventHandlerInterceptor = function (propertyName, eventHandler, domNode, properties) {
+            return function () {
+                // intercept function calls (event handlers) to do a render afterwards.
+                projector.scheduleRender();
+                return eventHandler.apply(properties.bind || this, arguments);
+            };
+        };
+        var renderCompleted = true;
+        var scheduled;
+        var stopped = false;
+        var projections = [];
+        var renderFunctions = [];
+        // matches the projections array
+        var doRender = function () {
+            scheduled = undefined;
+            if (!renderCompleted) {
+                return;    // The last render threw an error, it should be logged in the browser console.
+            }
+            var s = Date.now()
+            renderCompleted = false;
+            for (var i = 0; i < projections.length; i++) {
+                var updatedVnode = renderFunctions[i]();
+                projections[i].update(updatedVnode);
+            }
+            if (Date.now()-s > 15)
+                console.log("Render time:", Date.now()-s, "ms")
+            renderCompleted = true;
+        };
+        projector = {
+            scheduleRender: function () {
+                if (!scheduled && !stopped) {
+                    scheduled = requestAnimationFrame(doRender);
+                }
+            },
+            stop: function () {
+                if (scheduled) {
+                    cancelAnimationFrame(scheduled);
+                    scheduled = undefined;
+                }
+                stopped = true;
+            },
+            resume: function () {
+                stopped = false;
+                renderCompleted = true;
+                projector.scheduleRender();
+            },
+            append: function (parentNode, renderMaquetteFunction) {
+                projections.push(exports.dom.append(parentNode, renderMaquetteFunction(), projectionOptions));
+                renderFunctions.push(renderMaquetteFunction);
+            },
+            insertBefore: function (beforeNode, renderMaquetteFunction) {
+                projections.push(exports.dom.insertBefore(beforeNode, renderMaquetteFunction(), projectionOptions));
+                renderFunctions.push(renderMaquetteFunction);
+            },
+            merge: function (domNode, renderMaquetteFunction) {
+                projections.push(exports.dom.merge(domNode, renderMaquetteFunction(), projectionOptions));
+                renderFunctions.push(renderMaquetteFunction);
+            },
+            replace: function (domNode, renderMaquetteFunction) {
+                var vnode = renderMaquetteFunction();
+                createDom(vnode, domNode.parentNode, domNode, projectionOptions);
+                domNode.parentNode.removeChild(domNode);
+                projections.push(createProjection(vnode, projectionOptions));
+                renderFunctions.push(renderMaquetteFunction);
+            },
+            detach: function (renderMaquetteFunction) {
+                for (var i = 0; i < renderFunctions.length; i++) {
+                    if (renderFunctions[i] === renderMaquetteFunction) {
+                        renderFunctions.splice(i, 1);
+                        return projections.splice(i, 1)[0];
+                    }
+                }
+                throw new Error('renderMaquetteFunction was not found');
+            }
+        };
+        return projector;
+    };
+}));

File diff suppressed because it is too large
+ 5 - 0
js/lib/marked.min.js


+ 162 - 0
js/utils/Animation.coffee

@@ -0,0 +1,162 @@
+class Animation
+	slideDown: (elem, props) ->
+		h = elem.offsetHeight
+		cstyle = window.getComputedStyle(elem)
+		margin_top = cstyle.marginTop
+		margin_bottom = cstyle.marginBottom
+		padding_top = cstyle.paddingTop
+		padding_bottom = cstyle.paddingBottom
+		border_top_width = cstyle.borderTopWidth
+		border_bottom_width = cstyle.borderBottomWidth
+		transition = cstyle.transition
+
+		elem.style.boxSizing = "border-box"
+		elem.style.overflow = "hidden"
+		if not props.animate_noscale
+			elem.style.transform = "scale(0.6)"
+		elem.style.opacity = "0"
+		elem.style.height = "0px"
+		elem.style.marginTop = "0px"
+		elem.style.marginBottom = "0px"
+		elem.style.paddingTop = "0px"
+		elem.style.paddingBottom = "0px"
+		elem.style.borderTopWidth = "0px"
+		elem.style.borderBottomWidth = "0px"
+		elem.style.transition = "none"
+
+		setTimeout (->
+			elem.className += " animate-inout"
+			elem.style.height = h+"px"
+			elem.style.transform = "scale(1)"
+			elem.style.opacity = "1"
+			elem.style.marginTop = margin_top
+			elem.style.marginBottom = margin_bottom
+			elem.style.paddingTop = padding_top
+			elem.style.paddingBottom = padding_bottom
+			elem.style.borderTopWidth = border_top_width
+			elem.style.borderBottomWidth = border_bottom_width
+		), 1
+
+		elem.addEventListener "transitionend", ->
+			elem.classList.remove("animate-inout")
+			elem.style.transition = elem.style.transform = elem.style.opacity = elem.style.height = null
+			elem.style.boxSizing = elem.style.marginTop = elem.style.marginBottom = null
+			elem.style.paddingTop = elem.style.paddingBottom = elem.style.overflow = null
+			elem.style.borderTopWidth = elem.style.borderBottomWidth = elem.style.overflow = null
+			elem.removeEventListener "transitionend", arguments.callee, false
+
+	slideDownAnime: (elem, props) ->
+		cstyle = window.getComputedStyle(elem)
+		elem.style.overflowY = "hidden"
+		anime({targets: elem, height: [0, elem.offsetHeight], easing: 'easeInOutExpo'})
+
+	slideUpAnime: (elem, remove_func, props) ->
+		elem.style.overflowY = "hidden"
+		anime({targets: elem, height: [elem.offsetHeight, 0], complete: remove_func, easing: 'easeInOutExpo'})
+
+
+	slideUp: (elem, remove_func, props) ->
+		elem.className += " animate-inout"
+		elem.style.boxSizing = "border-box"
+		elem.style.height = elem.offsetHeight+"px"
+		elem.style.overflow = "hidden"
+		elem.style.transform = "scale(1)"
+		elem.style.opacity = "1"
+		elem.style.pointerEvents = "none"
+		setTimeout (->
+			elem.style.height = "0px"
+			elem.style.marginTop = "0px"
+			elem.style.marginBottom = "0px"
+			elem.style.paddingTop = "0px"
+			elem.style.paddingBottom = "0px"
+			elem.style.transform = "scale(0.8)"
+			elem.style.borderTopWidth = "0px"
+			elem.style.borderBottomWidth = "0px"
+			elem.style.opacity = "0"
+		), 1
+		elem.addEventListener "transitionend", (e) ->
+			if e.propertyName == "opacity" or e.elapsedTime >= 0.6
+				remove_func()
+				elem.removeEventListener "transitionend", arguments.callee, false
+
+
+	showRight: (elem, props) ->
+		elem.className += " animate"
+		elem.style.opacity = 0
+		elem.style.transform = "TranslateX(-20px) Scale(1.01)"
+		setTimeout (->
+			elem.style.opacity = 1
+			elem.style.transform = "TranslateX(0px) Scale(1)"
+		), 1
+		elem.addEventListener "transitionend", ->
+			elem.classList.remove("animate")
+			elem.style.transform = elem.style.opacity = null
+			elem.removeEventListener "transitionend", arguments.callee, false
+
+
+	show: (elem, props) ->
+		delay = arguments[arguments.length-2]?.delay*1000 or 1
+		elem.className += " animate"
+		elem.style.opacity = 0
+		setTimeout (->
+			elem.style.opacity = 1
+		), delay
+		elem.addEventListener "transitionend", ->
+			elem.classList.remove("animate")
+			elem.style.opacity = null
+			elem.removeEventListener "transitionend", arguments.callee, false
+
+	hide: (elem, remove_func, props) ->
+		delay = arguments[arguments.length-2]?.delay*1000 or 1
+		elem.className += " animate"
+		setTimeout (->
+			elem.style.opacity = 0
+		), delay
+		elem.addEventListener "transitionend", (e) ->
+			if e.propertyName == "opacity"
+				remove_func()
+				elem.removeEventListener "transitionend", arguments.callee, false
+
+	addVisibleClass: (elem, props) ->
+		setTimeout ->
+			elem.classList.add("visible")
+
+	cloneAnimation: (elem, animation) ->
+		window.requestAnimationFrame =>
+			if elem.style.pointerEvents == "none"  # Fix if animation called on cloned element
+				elem = elem.nextSibling
+			elem.style.position = "relative"
+			elem.style.zIndex = "2"
+			clone = elem.cloneNode(true)
+			cstyle = window.getComputedStyle(elem)
+			clone.classList.remove("loading")
+			clone.style.position = "absolute"
+			clone.style.zIndex = "1"
+			clone.style.pointerEvents = "none"
+			clone.style.animation = "none"
+
+			# Check the position difference between original and cloned object
+			elem.parentNode.insertBefore(clone, elem)
+			cloneleft = clone.offsetLeft
+
+			clone.parentNode.removeChild(clone)  # Remove from dom to avoid animation
+			clone.style.marginLeft = parseInt(cstyle.marginLeft) + elem.offsetLeft - cloneleft + "px"
+			elem.parentNode.insertBefore(clone, elem)
+
+			clone.style.animation = "#{animation} 0.8s ease-in-out forwards"
+			setTimeout ( -> clone.remove() ), 1000
+
+	flashIn: (elem) ->
+		if elem.offsetWidth > 100
+			@cloneAnimation(elem, "flash-in-big")
+		else
+			@cloneAnimation(elem, "flash-in")
+
+	flashOut: (elem) ->
+		if elem.offsetWidth > 100
+			@cloneAnimation(elem, "flash-out-big")
+		else
+			@cloneAnimation(elem, "flash-out")
+
+
+window.Animation = new Animation()

+ 70 - 0
js/utils/Autosize.coffee

@@ -0,0 +1,70 @@
+class Autosize extends Class
+	constructor: (@attrs={}) ->
+		@node = null
+
+		@attrs.classes ?= {}
+		@attrs.classes.loading = false
+		@attrs.oninput = @handleInput
+		@attrs.onkeydown = @handleKeydown
+		@attrs.afterCreate = @storeNode
+		@attrs.rows = 1
+		@attrs.disabled = false
+
+	@property 'loading',
+		get: -> @attrs.classes.loading
+		set: (loading) ->
+			@attrs.classes.loading = loading
+			@node.value = @attrs.value
+			@autoHeight()
+			Page.projector.scheduleRender()
+
+	storeNode: (node) =>
+		@node = node
+		if @attrs.focused
+			node.focus()
+		setTimeout =>
+			@autoHeight()
+
+	setValue: (value=null) =>
+		@attrs.value = value
+		if @node
+			@node.value = value
+			@autoHeight()
+		Page.projector.scheduleRender()
+
+	autoHeight: =>
+		height_before = @node.style.height
+		if height_before
+			@node.style.height = "0px"
+		h = @node.offsetHeight
+		scrollh = @node.scrollHeight
+		@node.style.height = height_before
+		if scrollh > h
+			anime({targets: @node, height: scrollh, scrollTop: 0})
+		else
+			@node.style.height = height_before
+
+	handleInput: (e=null) =>
+		@attrs.value = e.target.value
+		@autoHeight()
+
+	handleKeydown: (e=null) =>
+		if e.which == 13 and not e.shiftKey and @attrs.onsubmit and @attrs.value.trim()
+			@attrs.onsubmit()
+			setTimeout ( =>
+				@autoHeight()
+			), 100
+			return false
+
+	render: (body=null) =>
+		if body and not @attrs.value
+			@setValue(body)
+		if @loading
+			attrs = clone(@attrs)
+			#attrs.value = "Submitting..."
+			attrs.disabled = true
+			h("textarea.autosize", attrs)
+		else
+			h("textarea.autosize", @attrs)
+
+window.Autosize = Autosize

+ 23 - 0
js/utils/Class.coffee

@@ -0,0 +1,23 @@
+class Class
+	trace: true
+
+	log: (args...) ->
+		return unless @trace
+		return if typeof console is 'undefined'
+		args.unshift("[#{@.constructor.name}]")
+		console.log(args...)
+		@
+		
+	logStart: (name, args...) ->
+		return unless @trace
+		@logtimers or= {}
+		@logtimers[name] = +(new Date)
+		@log "#{name}", args..., "(started)" if args.length > 0
+		@
+		
+	logEnd: (name, args...) ->
+		ms = +(new Date)-@logtimers[name]
+		@log "#{name}", args..., "(Done in #{ms}ms)"
+		@ 
+
+window.Class = Class

+ 13 - 0
js/utils/Debug.coffee

@@ -0,0 +1,13 @@
+class Debug
+	formatException: (err) ->
+		if typeof err == 'object'
+			if err.message
+				console.log('Message: ' + err.message)
+			if err.stack
+				console.log('Stacktrace:')
+				console.log('====================')
+				console.log(err.stack)
+		else
+			console.log(err)
+
+window.Debug = new Debug()

+ 3 - 0
js/utils/Dollar.coffee

@@ -0,0 +1,3 @@
+window.$ = (selector) ->
+	if selector.startsWith("#")
+		return document.getElementById(selector.replace("#", ""))

+ 56 - 0
js/utils/Editable.coffee

@@ -0,0 +1,56 @@
+class Editable extends Class
+	constructor: (@type, @handleSave, @handleDelete) ->
+		@node = null
+		@editing = false
+		@render_function = null
+
+	storeNode: (node) =>
+		@node = node
+
+	handleEditClick: (e) =>
+		@editing = true
+		@field_edit = new Autosize({focused: 1, style: "height: 0px"})
+		return false
+
+	handleCancelClick: =>
+		@editing = false
+		return false
+
+	handleDeleteClick: =>
+		Page.cmd "wrapperConfirm", ["Are you sure?", "Delete"], =>
+			@field_edit.loading = true
+			@handleDelete (res) =>
+				@field_edit.loading = false
+		return false
+
+	handleSaveClick: =>
+		@field_edit.loading = true
+		@handleSave @field_edit.attrs.value, (res) =>
+			@field_edit.loading = false
+			if res
+				@editing = false
+		return false
+
+	render: (body) =>
+		if @editing
+			return h("div.editable.editing", {exitAnimation: Animation.slideUp},
+				@field_edit.render(body),
+				h("div.editablebuttons",
+					h("a.link", {href: "#Cancel", onclick: @handleCancelClick, tabindex: "-1"}, "Cancel"),
+					if @handleDelete
+						h("a.button.button-submit.button-small.button-outline", {href: "#Delete", onclick: @handleDeleteClick, tabindex: "-1"}, "Delete")
+					h("a.button.button-submit.button-small", {href: "#Save", onclick: @handleSaveClick}, "Save")
+				)
+			)
+		else
+			return h("div.editable", {enterAnimation: Animation.slideDown},
+				h("a.icon.icon-edit", {key: @node, href: "#Edit", onclick: @handleEditClick}),
+				if not body
+					h(@type, h("span.empty", {onclick: @handleEditClick}, "Click here to edit this field"))
+				else if @render_function
+					h(@type, {innerHTML: @render_function(body)})
+				else
+					h(@type, body)
+			)
+
+window.Editable = Editable

+ 26 - 0
js/utils/ItemList.coffee

@@ -0,0 +1,26 @@
+class ItemList
+	constructor: (@item_class, @key) ->
+		@items = []
+		@items_bykey = {}
+
+	sync: (rows, item_class, key) ->
+		@items.splice(0, @items.length)  # Empty items
+		for row in rows
+			current_obj = @items_bykey[row[@key]]
+			if current_obj
+				current_obj.row = row
+				@items.push current_obj
+			else
+				item = new @item_class(row, @)
+				@items_bykey[row[@key]] = item
+				@items.push item
+
+	deleteItem: (item) ->
+		index = @items.indexOf(item)
+		if index > -1
+			@items.splice(index, 1)
+		else
+			console.log "Can't delete item", item
+		delete @items_bykey[item.row[@key]]
+
+window.ItemList = ItemList

+ 69 - 0
js/utils/Menu.coffee

@@ -0,0 +1,69 @@
+class Menu
+	constructor: ->
+		@visible = false
+		@items = []
+		@node = null
+
+	show: =>
+		window.visible_menu?.hide()
+		@visible = true
+		window.visible_menu = @
+
+	hide: =>
+		@visible = false
+
+	toggle: =>
+		if @visible
+			@hide()
+		else
+			@show()
+
+
+	addItem: (title, cb, selected=false) ->
+		@items.push([title, cb, selected])
+
+
+	storeNode: (node) =>
+		@node = node
+		# Animate visible
+		if @visible
+			node.className = node.className.replace("visible", "")
+			setTimeout (->
+				node.className += " visible"
+			), 10
+
+	handleClick: (e) =>
+		keep_menu = false
+		for [title, cb] in @items
+			if title == e.target.textContent
+				keep_menu = cb()
+		if keep_menu != true
+			@hide()
+		return false
+
+	renderItem: (item) =>
+		[title, cb, selected] = item
+		if title == "---"
+			h("div.menu-item-separator")
+		else
+			if typeof(cb) == "string"  # Url
+				href = cb
+				onclick = true
+			else  # Callback
+				href = "#"+title
+				onclick = @handleClick
+			h("a.menu-item", {href: href, onclick: onclick, key: title, classes: {"selected": selected}}, [title])
+
+	render: (class_name="") =>
+		if @visible or @node
+			h("div.menu#{class_name}", {classes: {"visible": @visible}, afterCreate: @storeNode}, @items.map(@renderItem))
+
+window.Menu = Menu
+
+# Hide menu on outside click
+document.body.addEventListener "mouseup", (e) ->
+	if not window.visible_menu or not window.visible_menu.node
+		return false
+	if e.target.parentNode != window.visible_menu.node.parentNode and e.target.parentNode != window.visible_menu.node and e.target.parentNode.parentNode != window.visible_menu.node.parentNode
+		window.visible_menu.hide()
+		Page.projector.scheduleRender()

+ 8 - 0
js/utils/Prototypes.coffee

@@ -0,0 +1,8 @@
+String::startsWith = (s) -> @[...s.length] is s
+String::endsWith = (s) -> s is '' or @[-s.length..] is s
+String::repeat = (count) -> new Array( count + 1 ).join(@)
+
+window.isEmpty = (obj) ->
+	for key of obj
+		return false
+	return true

+ 40 - 0
js/utils/RateLimitCb.coffee

@@ -0,0 +1,40 @@
+last_time = {}
+calling = {}
+
+# Rate limit function call and don't allow to run in parallel (until callback is called)
+window.RateLimitCb = (interval, fn, args=[]) ->
+    cb = ->  # Callback when function finished
+        left = interval - (Date.now() - last_time[fn])  # Time life until next call
+        # console.log "CB, left", left, "Calling:", calling[fn]
+        if left <= 0  # No time left from rate limit interval
+            delete last_time[fn]
+            if calling[fn]  # Function called within interval
+                RateLimitCb(interval, fn, calling[fn])
+            delete calling[fn]
+        else  # Time left from rate limit interval
+            setTimeout (->
+                delete last_time[fn]
+                if calling[fn]  # Function called within interval
+                    RateLimitCb(interval, fn, calling[fn])
+                delete calling[fn]
+            ), left
+    if last_time[fn]  # Function called within interval
+        calling[fn] = args  # Schedule call and update arguments
+    else  # Not called within interval, call instantly
+        last_time[fn] = Date.now()
+        fn.apply(this, [cb, args...])
+
+###
+window.s = Date.now()
+window.load = (done, num) ->
+  console.log "Loading #{num}...", Date.now()-window.s
+  setTimeout (-> done()), 1000
+
+RateLimit 500, window.load, [0] # Called instantly
+RateLimit 500, window.load, [1]
+setTimeout (-> RateLimit 500, window.load, [300]), 300
+setTimeout (-> RateLimit 500, window.load, [600]), 600 # Called after 1000ms
+setTimeout (-> RateLimit 500, window.load, [1000]), 1000
+setTimeout (-> RateLimit 500, window.load, [1200]), 1200  # Called after 2000ms
+setTimeout (-> RateLimit 500, window.load, [3000]), 3000  # Called after 3000ms
+###

+ 140 - 0
js/utils/Text.coffee

@@ -0,0 +1,140 @@
+class MarkedRenderer extends marked.Renderer
+	image: (href, title, text) ->
+		return ("<code>![#{text}](#{href})</code>")
+
+class Text
+	toColor: (text, saturation=30, lightness=50) ->
+		hash = 0
+		for i in [0..text.length-1]
+			hash += text.charCodeAt(i)*i
+			hash = hash % 1777
+		return "hsl(" + (hash % 360) + ",#{saturation}%,#{lightness}%)";
+
+
+	renderMarked: (text, options={}) =>
+		options["gfm"] = true
+		options["breaks"] = true
+		options["sanitize"] = true
+		options["renderer"] = marked_renderer
+		text = @fixReply(text)
+		text = marked(text, options)
+		text = @emailLinks text
+		return @fixHtmlLinks text
+
+	emailLinks: (text) ->
+		return text.replace(/([a-zA-Z0-9]+)@zeroid.bit/g, "<a href='?to=$1' onclick='return Page.message_create.show(\"$1\")'>$1@zeroid.bit</a>")
+
+	# Convert zeronet html links to relaitve
+	fixHtmlLinks: (text) ->
+		if window.is_proxy
+			return text.replace(/href="http:\/\/(127.0.0.1|localhost):43110/g, 'href="http://zero')
+		else
+			return text.replace(/href="http:\/\/(127.0.0.1|localhost):43110/g, 'href="')
+
+	# Convert a single link to relative
+	fixLink: (link) ->
+		if window.is_proxy
+			back = link.replace(/http:\/\/(127.0.0.1|localhost):43110/, 'http://zero')
+			return back.replace(/http:\/\/zero\/([^\/]+\.bit)/, "http://$1")  # Domain links
+		else
+			return link.replace(/http:\/\/(127.0.0.1|localhost):43110/, '')
+
+	toUrl: (text) ->
+		return text.replace(/[^A-Za-z0-9]/g, "+").replace(/[+]+/g, "+").replace(/[+]+$/, "")
+
+	getSiteUrl: (address) ->
+		if window.is_proxy
+			if "." in address # Domain
+				return "http://"+address+"/"
+			else
+				return "http://zero/"+address+"/"
+		else
+			return "/"+address+"/"
+
+
+	fixReply: (text) ->
+		return text.replace(/(>.*\n)([^\n>])/gm, "$1\n$2")
+
+	toBitcoinAddress: (text) ->
+		return text.replace(/[^A-Za-z0-9]/g, "")
+
+
+	jsonEncode: (obj) ->
+		return unescape(encodeURIComponent(JSON.stringify(obj)))
+
+	jsonDecode: (obj) ->
+		return JSON.parse(decodeURIComponent(escape(obj)))
+
+	fileEncode: (obj) ->
+		if typeof(obj) == "string"
+			return btoa(unescape(encodeURIComponent(obj)))
+		else
+			return btoa(unescape(encodeURIComponent(JSON.stringify(obj, undefined, '\t'))))
+
+	utf8Encode: (s) ->
+		return unescape(encodeURIComponent(s))
+
+	utf8Decode: (s) ->
+		return decodeURIComponent(escape(s))
+
+
+	distance: (s1, s2) ->
+		s1 = s1.toLocaleLowerCase()
+		s2 = s2.toLocaleLowerCase()
+		next_find_i = 0
+		next_find = s2[0]
+		match = true
+		extra_parts = {}
+		for char in s1
+			if char != next_find
+				if extra_parts[next_find_i]
+					extra_parts[next_find_i] += char
+				else
+					extra_parts[next_find_i] = char
+			else
+				next_find_i++
+				next_find = s2[next_find_i]
+
+		if extra_parts[next_find_i]
+			extra_parts[next_find_i] = ""  # Extra chars on the end doesnt matter
+		extra_parts = (val for key, val of extra_parts)
+		if next_find_i >= s2.length
+			return extra_parts.length + extra_parts.join("").length
+		else
+			return false
+
+
+	queryParse: (query) ->
+		params = {}
+		parts = query.split('&')
+		for part in parts
+			[key, val] = part.split("=")
+			if val
+				params[decodeURIComponent(key)] = decodeURIComponent(val)
+			else
+				params["url"] = decodeURIComponent(key)
+				params["urls"] = params["url"].split("/")
+		return params
+
+	queryEncode: (params) ->
+		back = []
+		if params.url
+			back.push(params.url)
+		for key, val of params
+			if not val or key == "url"
+				continue
+			back.push("#{encodeURIComponent(key)}=#{encodeURIComponent(val)}")
+		return back.join("&")
+
+	highlight: (text, search) ->
+		parts = text.split(RegExp(search, "i"))
+		back = []
+		for part, i in parts
+			back.push(part)
+			if i < parts.length-1
+				back.push(h("span.highlight", {key: i}, search))
+		return back
+
+window.is_proxy = (document.location.host == "zero" or window.location.pathname == "/")
+window.marked_renderer = new MarkedRenderer()
+window.Text = new Text()

+ 39 - 0
js/utils/Time.coffee

@@ -0,0 +1,39 @@
+class Time
+	since: (timestamp) ->
+		now = +(new Date)/1000
+		if timestamp > 1000000000000  # In ms
+			timestamp = timestamp/1000
+		secs = now - timestamp
+		if secs < 60
+			back = "Just now"
+		else if secs < 60*60
+			back = "#{Math.round(secs/60)} minutes ago"
+		else if secs < 60*60*24
+			back = "#{Math.round(secs/60/60)} hours ago"
+		else if secs < 60*60*24*3
+			back = "#{Math.round(secs/60/60/24)} days ago"
+		else
+			back = "on "+@date(timestamp)
+		back = back.replace(/^1 ([a-z]+)s/, "1 $1") # 1 days ago fix
+		return back
+
+
+	date: (timestamp, format="short") ->
+		if timestamp > 1000000000000  # In ms
+			timestamp = timestamp/1000
+		parts = (new Date(timestamp*1000)).toString().split(" ")
+		if format == "short"
+			display = parts.slice(1, 4)
+		else
+			display = parts.slice(1, 5)
+		return display.join(" ").replace(/( [0-9]{4})/, ",$1")
+
+
+	timestamp: (date="") ->
+		if date == "now" or date == ""
+			return parseInt(+(new Date)/1000)
+		else
+			return parseInt(Date.parse(date)/1000)
+
+
+window.Time = new Time

+ 72 - 0
js/utils/Uploadable.coffee

@@ -0,0 +1,72 @@
+class Uploadable extends Class
+	constructor: (@handleSave) ->
+		@node = null
+		@resize_w = 50
+		@resize_h = 50
+
+	storeNode: (node) =>
+		@node = node
+
+	resizeImage: (file, width, height, cb) =>
+		image = new Image()
+
+		# Smooth image resize
+		resizer = (i) ->
+			cc = document.createElement("canvas")
+			cc.width = i.width / 2
+			cc.height = i.height / 2
+			cc_ctx = cc.getContext("2d")
+			cc_ctx.drawImage(i, 0, 0, cc.width, cc.height)
+			return cc
+
+		image.onload = =>
+			canvas = document.createElement("canvas")
+			canvas.width = width
+			canvas.height = height
+			ctx = canvas.getContext("2d")
+			ctx.fillStyle = "#FFF"
+			ctx.fillRect(0, 0, canvas.width, canvas.height);
+			while image.width > width * 2
+				image = resizer(image)
+			ctx.drawImage(image, 0, 0, width, height)
+
+			# Try to optimize to png
+			quant = new RgbQuant({colors: 128, method: 1})
+			quant.sample(canvas)
+			quant.palette(true)
+			canvas_quant = drawPixels(quant.reduce(canvas), width)
+			optimizer = new CanvasTool.PngEncoder(canvas_quant, { bitDepth: 8, colourType: CanvasTool.PngEncoder.ColourType.TRUECOLOR })
+			image_base64uri = "data:image/png;base64," + btoa(optimizer.convert())
+			if image_base64uri.length > 2200
+				# Too large, convert to jpg
+				@log "PNG too large (#{image_base64uri.length} bytes), convert to jpg instead"
+				image_base64uri = canvas.toDataURL("image/jpeg", 0.8)
+
+			@log "Size: #{image_base64uri.length} bytes"
+			cb image_base64uri
+		image.onerror = (e) =>
+			@log "Image upload error", e
+			cb null
+		image.src = URL.createObjectURL(file)
+
+
+	handleUploadClick: (e) =>
+		script = document.createElement("script")
+		script.src = "js-external/pngencoder.js"
+		document.head.appendChild(script)
+		input = document.createElement('input')
+		input.type = "file"
+		input.addEventListener 'change', (e) =>
+			script.onload = @resizeImage input.files[0], @resize_w, @resize_h, (image_base64uri) =>
+				@handleSave(image_base64uri)
+				input.remove()
+		input.click()
+		return false
+
+	render: (body) =>
+		h("div.uploadable",
+			h("a.icon.icon-upload", {href: "#Upload", onclick: @handleUploadClick})
+			body()
+		)
+
+window.Uploadable = Uploadable

+ 96 - 0
js/utils/ZeroFrame.coffee

@@ -0,0 +1,96 @@
+class ZeroFrame extends Class
+	constructor: (url) ->
+		@url = url
+		@waiting_cb = {}
+		@history_state = {}
+		@wrapper_nonce = document.location.href.replace(/.*wrapper_nonce=([A-Za-z0-9]+).*/, "$1")
+		@connect()
+		@next_message_id = 1
+		@init()
+
+
+	init: ->
+		@
+
+
+	connect: ->
+		@target = window.parent
+		window.addEventListener("message", @onMessage, false)
+		@cmd("innerReady")
+
+		# Save scrollTop
+		window.addEventListener "beforeunload", (e) =>
+			@log "Save scrollTop", window.pageYOffset
+			@history_state["scrollTop"] = window.pageYOffset
+			@cmd "wrapperReplaceState", [@history_state, null]
+
+		# Restore scrollTop
+		@cmd "wrapperGetState", [], (state) =>
+			@handleState(state)
+
+	handleState: (state) ->
+		@history_state = state if state?
+		@log "Restore scrollTop", state, window.pageYOffset
+		if window.pageYOffset == 0 and state
+			window.scroll(window.pageXOffset, state.scrollTop)
+
+
+	onMessage: (e) =>
+		message = e.data
+		cmd = message.cmd
+		if cmd == "response"
+			if @waiting_cb[message.to]?
+				@waiting_cb[message.to](message.result)
+			else
+				@log "Websocket callback not found:", message
+		else if cmd == "wrapperReady" # Wrapper inited later
+			@cmd("innerReady")
+		else if cmd == "ping"
+			@response message.id, "pong"
+		else if cmd == "wrapperOpenedWebsocket"
+			@onOpenWebsocket()
+		else if cmd == "wrapperClosedWebsocket"
+			@onCloseWebsocket()
+		else if cmd == "wrapperPopState"
+			@handleState(message.params.state)
+			@onRequest cmd, message.params
+		else
+			@onRequest cmd, message.params
+
+
+	onRequest: (cmd, message) =>
+		@log "Unknown request", message
+
+
+	response: (to, result) ->
+		@send {"cmd": "response", "to": to, "result": result}
+
+
+	cmd: (cmd, params={}, cb=null) ->
+		@send {"cmd": cmd, "params": params}, cb
+
+	cmdp: (cmd, params={}) ->
+		p = new Promise()
+		@send {"cmd": cmd, "params": params}, (res) ->
+			p.resolve(res)
+		return p
+
+	send: (message, cb=null) ->
+		message.wrapper_nonce = @wrapper_nonce
+		message.id = @next_message_id
+		@next_message_id += 1
+		@target.postMessage(message, "*")
+		if cb
+			@waiting_cb[message.id] = cb
+
+
+	onOpenWebsocket: =>
+		@log "Websocket open"
+
+
+	onCloseWebsocket: =>
+		@log "Websocket close"
+
+
+
+window.ZeroFrame = ZeroFrame

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