├── now.json ├── .gitignore ├── app.json ├── Dockerfile ├── stores ├── networkStatus.js ├── documents.js └── shoppingList.js ├── server ├── periodicRestart.js ├── middleware │ ├── serviceWorkerNoCache.js │ └── redirectToHttps.js ├── csp.js ├── makeImages.js ├── makeServiceWorker.js ├── index.js └── dbGateway.js ├── static ├── img │ ├── dat-hexagon.svg │ ├── ic_sync_black_24px.svg │ ├── ic_sync_problem_black_24px.svg │ ├── ic_shopping_cart_black_24px.svg │ ├── ic_sync_disabled_black_24px.svg │ ├── dat-shopping-list.svg │ └── bg-landing-page.svg └── manifest.webmanifest ├── index.css ├── .glitch-assets ├── components ├── svgIcon.js ├── debugTools.js ├── shoppingListTitle.js ├── button.js ├── footer.js ├── header.js ├── customAlert.js ├── statusDisplay.js ├── githubButton.js └── writeStatus.js ├── lib ├── dumpWriters.js ├── downloadZip.js └── websocketGateway.js ├── LICENSE.md ├── views ├── create.js ├── addLink.js ├── main.js └── shoppingList.js ├── package.json ├── index.html ├── index.js ├── README.md ├── CODE_OF_CONDUCT.md └── EXTRA-NOTES.md /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "docker" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | .data 4 | package-lock.json 5 | .less* 6 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dat Shopping List", 3 | "description": "A demo of dat multiwriter support (hyperdrive+hyperdb) in a PWA", 4 | "repository": "https://github.com/jimpick/dat-shopping-list", 5 | "logo": "https://dat-shopping-list.glitch.me/img/dat-shopping-list.svg", 6 | "keywords": ["node", "dat"] 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:carbon 2 | 3 | # Copy files 4 | WORKDIR /usr/src/app 5 | 6 | COPY components ./components 7 | COPY lib ./lib 8 | COPY server ./server 9 | COPY static ./static 10 | COPY stores ./stores 11 | COPY views ./views 12 | COPY package.json index.* ./ 13 | 14 | RUN npm install 15 | 16 | EXPOSE 5000 17 | 18 | CMD ["npm", "start"] 19 | 20 | -------------------------------------------------------------------------------- /stores/networkStatus.js: -------------------------------------------------------------------------------- 1 | module.exports = store 2 | 3 | function store (state, emitter) { 4 | updateOnlineStatus() 5 | window.addEventListener('online', updateOnlineStatus) 6 | window.addEventListener('offline', updateOnlineStatus) 7 | 8 | function updateOnlineStatus () { 9 | state.networkStatus = navigator.onLine 10 | emitter.emit('render') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/periodicRestart.js: -------------------------------------------------------------------------------- 1 | // Restart after an interval so that the demo doesn't seed content forever 2 | // Active clients should re-connect 3 | 4 | module.exports = periodicRestart 5 | 6 | function periodicRestart (intervalMinutes) { 7 | setTimeout(() => { 8 | console.log(`Planned periodic restart after ${intervalMinutes} minutes.`) 9 | process.exit(0) 10 | }, intervalMinutes * 60 * 1000) 11 | } 12 | -------------------------------------------------------------------------------- /static/img/dat-hexagon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /static/img/ic_sync_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/middleware/serviceWorkerNoCache.js: -------------------------------------------------------------------------------- 1 | const nocache = require('nocache') 2 | 3 | module.exports = serviceWorkerNoCache 4 | 5 | const nocacheMiddleware = nocache() 6 | 7 | function serviceWorkerNoCache (req, res, next) { 8 | if ( 9 | req.url === '/' || 10 | req.url === '/sw.js' || 11 | req.url === '/index.js' 12 | ) { 13 | return nocacheMiddleware(req, res, next) 14 | } 15 | next() 16 | } 17 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | font-family: 'Source Sans Pro','PT Sans',Calibri,sans-serif; 5 | margin: 0 auto; 6 | overflow-x: hidden; 7 | max-width: 800px; 8 | } 9 | 10 | * { 11 | outline-color: var(--color-green); 12 | } 13 | 14 | .link { 15 | text-decoration: none; 16 | color: var(--color-green); 17 | } 18 | 19 | .link:focus { 20 | outline: 1px dotted currentColor; 21 | } 22 | -------------------------------------------------------------------------------- /server/middleware/redirectToHttps.js: -------------------------------------------------------------------------------- 1 | module.exports = redirectToHttps 2 | 3 | function redirectToHttps (req, res, next) { 4 | // Glitch has a proxy 5 | const xfpHeader = req.headers['x-forwarded-proto'] 6 | if (!xfpHeader.match(/^https/)) { 7 | const redirectUrl = 'https://' + req.headers['host'] + req.url 8 | res.writeHead(301, {Location: redirectUrl}) 9 | res.end() 10 | } else { 11 | next() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.glitch-assets: -------------------------------------------------------------------------------- 1 | {"name":"dat-hexagon.svg","date":"2018-04-17T01:25:47.670Z","url":"https://cdn.glitch.com/9f4aafd1-7795-46ec-a944-a7f7ffd4da84%2Fdat-hexagon.svg","type":"image/svg+xml","size":388,"imageWidth":240,"imageHeight":240,"thumbnail":"https://cdn.glitch.com/9f4aafd1-7795-46ec-a944-a7f7ffd4da84%2Fdat-hexagon.svg","thumbnailWidth":240,"thumbnailHeight":240,"dominantColor":null,"uuid":"ortBJorRpyLmCzAe"} 2 | {"uuid":"ortBJorRpyLmCzAe","deleted":true} 3 | -------------------------------------------------------------------------------- /components/svgIcon.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const raw = require('choo/html/raw') 3 | const html = require('choo/html') 4 | 5 | class SvgIcon extends Component { 6 | constructor () { 7 | super() 8 | this.svgData = null 9 | } 10 | 11 | createElement (data) { 12 | this.svgData = data 13 | return html`${raw(data)}` 14 | } 15 | 16 | update () { 17 | return false 18 | } 19 | } 20 | 21 | module.exports = SvgIcon 22 | -------------------------------------------------------------------------------- /static/img/ic_sync_problem_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/img/ic_shopping_cart_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/img/ic_sync_disabled_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dat Shopping List", 3 | "short_name": "Dat Shopping", 4 | "description": "A demo of dat multiwriter support (hyperdrive+hyperdb) in a PWA", 5 | "lang": "en-US", 6 | "start_url": "/", 7 | "display": "standalone", 8 | "orientation": "portrait", 9 | "theme_color": "#2ACA4B", 10 | "background_color": "#fff", 11 | "icons": [ 12 | { 13 | "src": "/img/dat-shopping-list-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "/img/dat-shopping-list-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /server/csp.js: -------------------------------------------------------------------------------- 1 | const csp = require('helmet-csp') 2 | 3 | module.exports = csp({ 4 | directives: { 5 | defaultSrc: ["'self'"], 6 | imgSrc: ["'self'", 'https://cdn.glitch.com'], 7 | connectSrc: [ 8 | 'https://api.github.com', 9 | (req, res) => { 10 | // Glitch has a proxy 11 | const xfpHeader = req.headers['x-forwarded-proto'] 12 | if (!xfpHeader || !xfpHeader.match(/^https/)) { 13 | return 'ws://' + req.headers['host'] 14 | } else { 15 | return 'wss://' + req.headers['host'] 16 | } 17 | } 18 | ], 19 | scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], 20 | styleSrc: ["'self'", "'unsafe-inline'"] 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /server/makeImages.js: -------------------------------------------------------------------------------- 1 | const sharp = require('sharp') 2 | 3 | module.exports = makeImages 4 | 5 | function makeImages (cb) { 6 | console.log('Making images') 7 | const base = 'dat-shopping-list' 8 | const sizes = [16, 32, 96, 120, 152, 167, 180, 192, 196, 512] 9 | 10 | sizes.reduce( 11 | (promise, size) => { 12 | console.log('Making png', base, size) 13 | return promise 14 | .then(() => { 15 | return sharp(`./static/img/${base}.svg`) 16 | .resize(size, size, { background: 'white' }) 17 | .flatten() 18 | .toFile(`./.data/img/${base}-${size}.png`) 19 | }) 20 | }, 21 | Promise.resolve() 22 | ).then(() => cb()) 23 | .catch(err => { console.error('Error', err) }) 24 | } 25 | -------------------------------------------------------------------------------- /static/img/dat-shopping-list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/dumpWriters.js: -------------------------------------------------------------------------------- 1 | const prettyHash = require('pretty-hash') 2 | 3 | module.exports = dumpWriters 4 | 5 | function dumpWriters (archive) { 6 | const db = archive.db 7 | console.log( 8 | 'Connected Peers:', 9 | db.source.peers.filter(peer => !!peer.remoteId).length 10 | ) 11 | console.log('Writers:') 12 | db._writers.forEach(writer => { 13 | console.log( 14 | ` ${writer._id} ${writer._feed.key.toString('hex')} ` + 15 | `dk: ${prettyHash(writer._feed.discoveryKey)} ` + 16 | `${writer._feed.length} R${writer._feed.remoteLength} ` 17 | ) 18 | }) 19 | console.log('Content feeds:') 20 | db.contentFeeds.forEach((feed, index) => { 21 | if (feed) { 22 | console.log( 23 | ` ${index} ${feed.key.toString('hex')} ` + 24 | `dk: ${prettyHash(feed.discoveryKey)} ` + 25 | `${feed.length} R${feed.remoteLength} ` 26 | ) 27 | } else { 28 | console.log(' ', index, 'No feed') 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /components/debugTools.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | 4 | module.exports = debugTools 5 | 6 | const prefix = css` 7 | :host { 8 | margin: 2rem; 9 | } 10 | ` 11 | 12 | function debugTools (state, emit) { 13 | if (!state.devMode) return null 14 | return html` 15 |
16 | Debug tools: ${' '} 17 | Download Zip 18 | Unregister Service Worker 19 |
20 | ` 21 | 22 | function downloadZip (event) { 23 | emit('downloadZip') 24 | event.preventDefault() 25 | } 26 | 27 | function unregisterServiceWorker (event) { 28 | if (navigator.serviceWorker) { 29 | navigator.serverWorker.getRegistrations() 30 | .then(registrations => { 31 | if (registrations && registrations[0]) { 32 | registrations[0].unregister() 33 | .then(() => alert('Unregistered')) 34 | } 35 | }) 36 | } 37 | event.preventDefault() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/makeServiceWorker.js: -------------------------------------------------------------------------------- 1 | const swPrecache = require('sw-precache') 2 | 3 | module.exports = makeServiceWorker 4 | 5 | function makeServiceWorker (cb) { 6 | console.log('Making service worker') 7 | swPrecache.write('.data/sw.js', { 8 | skipWaiting: true, 9 | clientsClaim: true, 10 | navigateFallback: '/', 11 | navigateFallbackWhitelist: [/^\/doc/, /^\/create/, /^\/add-link/], 12 | staticFileGlobs: ['index.html', 'static/manifest.webmanifest', 'static/**/*.svg', '.data/**/*.png'], 13 | stripPrefixMulti: { 14 | 'static': '', 15 | '.data': '' 16 | }, 17 | runtimeCaching: [ 18 | { 19 | urlPattern: /\/index.js$/, 20 | handler: 'fastest' 21 | }, 22 | { 23 | urlPattern: new RegExp('^https://cdn.glitch.com/'), 24 | handler: 'fastest' 25 | }, 26 | { 27 | urlPattern: new RegExp('^https://buttons.github.io/'), 28 | handler: 'fastest' 29 | }, 30 | { 31 | urlPattern: new RegExp('^https://api.github.com/'), 32 | handler: 'fastest' 33 | } 34 | ] 35 | }, cb) 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jim Pick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/shoppingListTitle.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const copy = require('clipboard-copy') 4 | const prettyHash = require('pretty-hash') 5 | const customAlert = require('./customAlert') 6 | 7 | module.exports = title 8 | 9 | const prefix = css` 10 | :host { 11 | position: relative; 12 | h1 { 13 | margin: 0 0 0.5em 0; 14 | } 15 | .hash { 16 | font-size: 12px; 17 | font-family: monospace; 18 | position: absolute; 19 | top: -0.6rem; 20 | right: 0; 21 | cursor: pointer; 22 | } 23 | } 24 | ` 25 | 26 | function title (state, emit) { 27 | return html` 28 |
29 |

${state.docTitle}

30 |
31 | ${prettyHash(state.key)} 32 |
33 |
34 | ` 35 | 36 | function copyUrl (event) { 37 | copy(document.location.href).then(() => { 38 | customAlert.show('Shopping list URL copied to clipboard') 39 | }) 40 | } 41 | 42 | function keydown (event) { 43 | if (event.key === ' ' || event.key === 'Enter') { 44 | event.target.click() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/button.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | 4 | const prefix = css` 5 | :host { 6 | color: var(--color-white); 7 | background-color: var(--color-green); 8 | font-size: 1rem; 9 | font-weight: 700; 10 | text-decoration: none; 11 | padding: 0.5rem 1rem; 12 | display: inline-block; 13 | backface-visibility: hidden; 14 | transform: translateZ(0); 15 | transition: transform .25s ease-out,-webkit-transform .25s ease-out; 16 | border-color: transparent; 17 | &:active { 18 | transform: scale(0.9); 19 | } 20 | &:focus, &:hover { 21 | transform: scale(1.05); 22 | } 23 | -webkit-appearance: none; 24 | -moz-appearance: none; 25 | border-radius: 0; 26 | cursor: pointer; 27 | &::-moz-focus-inner { 28 | border: 0; 29 | } 30 | } 31 | ` 32 | 33 | module.exports = { 34 | button, 35 | submit 36 | } 37 | 38 | function button (label, onclick) { 39 | return html` 40 | 43 | ` 44 | } 45 | 46 | function submit (label, onclick) { 47 | return html` 48 | 49 | ` 50 | } 51 | -------------------------------------------------------------------------------- /components/footer.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const GitHubButton = require('./githubButton') 4 | 5 | const prefix = css` 6 | :host { 7 | margin-top: 0.2rem; 8 | margin-left: 1rem; 9 | margin-right: 1rem; 10 | margin-bottom: 2rem; 11 | display: flex; 12 | flex-wrap: wrap; 13 | align-items: center; 14 | 15 | & > * { 16 | margin: 0.1rem; 17 | } 18 | 19 | & > a { 20 | margin-right: 0.2rem; 21 | } 22 | 23 | #more { 24 | justify-self: end; 25 | width: 7rem; 26 | margin-left: auto; 27 | } 28 | 29 | .github-button { 30 | opacity: 0; 31 | } 32 | } 33 | ` 34 | 35 | module.exports = footer 36 | 37 | function footer (state) { 38 | const {glitchAppName, gitHubRepoName} = state 39 | const ghButton = state.cache(GitHubButton, 'gitHubButton', gitHubRepoName).render() 40 | /* 41 | 51 | */ 52 | return html` 53 | 56 | ` 57 | } 58 | -------------------------------------------------------------------------------- /components/header.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const statusDisplay = require('./statusDisplay') 4 | 5 | const prefix = css` 6 | :host { 7 | border-bottom: 1px solid var(--color-neutral-10); 8 | flex: 0 64px; 9 | color: var(--color-neutral); 10 | font-weight: 700; 11 | font-size: 1.5rem; 12 | display: flex; 13 | padding-left: 1rem; 14 | padding-right: 1rem; 15 | position: relative; 16 | a { 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | text-decoration: none; 21 | transition: opacity .15s ease-in; 22 | &:hover, &:focus { 23 | opacity: 0.5; 24 | } 25 | img { 26 | width: 2rem; 27 | height: 2rem; 28 | margin-right: 0.5rem; 29 | transition: transform .5s ease-in-out; 30 | &:hover, &:focus { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | } 35 | .title { 36 | color: var(--color-neutral-60); 37 | white-space: nowrap; 38 | } 39 | .first-word { 40 | color: var(--color-neutral); 41 | } 42 | } 43 | ` 44 | 45 | module.exports = header 46 | 47 | function header (state) { 48 | return html` 49 | 58 | ` 59 | } 60 | -------------------------------------------------------------------------------- /views/create.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const header = require('../components/header') 4 | const button = require('../components/button') 5 | 6 | const prefix = css` 7 | :host { 8 | .content { 9 | margin: 1em; 10 | } 11 | input[type="text"] { 12 | width: 100%; 13 | font-size: 1.5rem; 14 | } 15 | } 16 | ` 17 | 18 | module.exports = createView 19 | 20 | function createView (state, emit) { 21 | emit('DOMTitleChange', 'Dat Shopping List - Create') 22 | const input = html`` 23 | input.isSameNode = function (target) { 24 | return (target && target.nodeName && target.nodeName === 'INPUT') 25 | } 26 | 27 | return html` 28 | 29 | ${header(state)} 30 |
31 |

32 | Enter a name for your new shopping list 33 |

34 |
35 | ${input} 36 |

37 | ${button.submit('Submit')} 38 |

39 |
40 |
41 | 42 | ` 43 | 44 | function submit (event) { 45 | const docName = event.target.querySelector('input').value 46 | if (docName) { 47 | const textInput = event.target.querySelector('input[type="text"]') 48 | textInput.setAttribute('disabled', 'disabled') 49 | const submitButton = event.target.querySelector('input[type="submit"]') 50 | submitButton.setAttribute('disabled', 'disabled') 51 | emit('createDoc', docName) 52 | } 53 | event.preventDefault() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dat-shopping-list", 3 | "version": "0.0.9", 4 | "description": "A demo of Dat multiwriter support (hyperdrive+hyperdb) in a PWA", 5 | "author": "Jim Pick (@jimpick)", 6 | "main": "server/index.js", 7 | "bin": { 8 | "dat-shopping-list": "./server/index.js" 9 | }, 10 | "scripts": { 11 | "start": "node server" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/jimpick/dat-shopping-list.git" 16 | }, 17 | "dependencies": { 18 | "brfs": "^2.0.1", 19 | "budo": "^11.5.0", 20 | "choo": "^6.13.1", 21 | "choo-service-worker": "^2.4.0", 22 | "clipboard-copy": "^2.0.1", 23 | "compression": "^1.7.3", 24 | "dat-colors": "^3.5.1", 25 | "deep-equal": "^1.0.1", 26 | "express": "^4.16.4", 27 | "express-ws": "^4.0.0", 28 | "file-saver": "^2.0.0", 29 | "focus-trap": "^3.0.0", 30 | "helmet-csp": "^2.7.1", 31 | "hsts": "^2.1.0", 32 | "hypercore": "^6.10.4", 33 | "hyperdiscovery": "^8.0.0", 34 | "jszip": "^3.1.5", 35 | "mkdirp": "^0.5.1", 36 | "monotonic-timestamp-base36": "^1.0.0", 37 | "nocache": "^2.0.0", 38 | "pretty-hash": "^1.0.1", 39 | "random-access-idb": "^1.0.4", 40 | "random-access-memory": "^3.0.0", 41 | "sharp": "^0.29.3", 42 | "sheetify": "^7.3.3", 43 | "sheetify-nested": "^1.0.2", 44 | "sw-precache": "^5.2.1", 45 | "thunky": "^1.0.3", 46 | "to-buffer": "^1.1.1", 47 | "websocket-stream": "^5.1.2", 48 | "@jimpick/hyperdrive-next": "^9.12.3-7", 49 | "is-buffer": "^2.0.5" 50 | }, 51 | "engines": { 52 | "node": "16.x" 53 | }, 54 | "license": "MIT" 55 | } -------------------------------------------------------------------------------- /views/addLink.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const header = require('../components/header') 4 | const button = require('../components/button') 5 | const customAlert = require('../components/customAlert') 6 | 7 | const prefix = css` 8 | :host { 9 | .content { 10 | margin: 1em; 11 | } 12 | input[type="text"] { 13 | width: 100%; 14 | font-size: 1.5rem; 15 | } 16 | } 17 | ` 18 | 19 | module.exports = addLinkView 20 | 21 | function addLinkView (state, emit) { 22 | emit('DOMTitleChange', 'Dat Shopping List - Add Link') 23 | const input = html`` 24 | input.isSameNode = function (target) { 25 | return (target && target.nodeName && target.nodeName === 'INPUT') 26 | } 27 | 28 | return html` 29 | 30 | ${header(state)} 31 |
32 |

33 | Paste in a URL link or a hexadecimal key 34 |

35 |
36 | ${input} 37 |

38 | ${button.submit('Submit')} 39 |

40 |
41 |
42 | ${customAlert.alertBox(state, emit)} 43 | 44 | ` 45 | 46 | function submit (event) { 47 | const link = event.target.querySelector('input').value 48 | if (link) { 49 | const textInput = event.target.querySelector('input[type="text"]') 50 | textInput.setAttribute('disabled', 'disabled') 51 | const submitButton = event.target.querySelector('input[type="submit"]') 52 | submitButton.setAttribute('disabled', 'disabled') 53 | emit('addLink', link) 54 | } 55 | event.preventDefault() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/downloadZip.js: -------------------------------------------------------------------------------- 1 | const JSZip = require('jszip') 2 | const {saveAs} = require('file-saver') 3 | 4 | module.exports = downloadZip 5 | 6 | function downloadZip (archive) { 7 | const keyHex = archive.key.toString('hex') 8 | const storage = archive.db._storage 9 | const request = window.indexedDB.open('doc-' + keyHex) 10 | request.onerror = function (event) { 11 | console.log('IndexedDB error') 12 | } 13 | request.onsuccess = function (event) { 14 | const db = event.target.result 15 | const objectStore = db.transaction('data').objectStore('data') 16 | const files = [] 17 | objectStore.openCursor().onsuccess = function (event) { 18 | const cursor = event.target.result 19 | if (cursor) { 20 | const match = cursor.key.match(/^(.*)\0length$/) 21 | if (match) { 22 | const filename = match[1] 23 | const length = cursor.value 24 | console.log('zipping', filename, length) 25 | files.push({filename, length}) 26 | } 27 | cursor.continue() 28 | } else { 29 | zipUpFiles(keyHex, storage, files) 30 | } 31 | } 32 | } 33 | } 34 | 35 | function zipUpFiles (keyHex, storage, files) { 36 | const zip = new JSZip() 37 | addFilesToZip(() => { 38 | zip.generateAsync({type: 'blob'}).then(blob => { 39 | saveAs(blob, `hyperdrive-${keyHex}.zip`) 40 | }) 41 | }) 42 | 43 | function addFilesToZip (cb) { 44 | if (files.length === 0) return cb() 45 | const {filename, length} = files.shift() 46 | setTimeout(() => { 47 | const file = storage(filename) 48 | file.read(0, length, (err, buf) => { 49 | if (err) throw err 50 | zip.file(filename, buf) 51 | addFilesToZip(cb) 52 | }) 53 | }, 0) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Dat Shopping List 27 | 28 | 29 | 30 | Generating bundle / loading... 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | const chooServiceWorker = require('choo-service-worker') 3 | const css = require('sheetify') 4 | 5 | const networkStatusStore = require('./stores/networkStatus') 6 | const documentsStore = require('./stores/documents') 7 | const shoppingListStore = require('./stores/shoppingList') 8 | 9 | const mainView = require('./views/main') 10 | const createView = require('./views/create') 11 | const addLinkView = require('./views/addLink') 12 | const shoppingListView = require('./views/shoppingList') 13 | 14 | css('dat-colors') 15 | css('./index.css') 16 | 17 | const app = choo() 18 | 19 | // app.use(require('choo-service-worker/clear')()) 20 | app.use(chooServiceWorker()) 21 | app.use((state, emitter) => { 22 | emitter.on('sw:installed', () => { console.log('sw:installed') }) 23 | emitter.on('sw:updated', () => { console.log('sw:updated') }) 24 | emitter.on('sw:redundant', () => { console.log('sw:redundant') }) 25 | if (navigator.serviceWorker) { 26 | console.log('Service worker controller', navigator.serviceWorker.controller) 27 | navigator.serviceWorker.getRegistrations() 28 | .then(registrations => { 29 | console.log('Service worker registrations', registrations) 30 | }) 31 | navigator.serviceWorker.ready.then(serviceWorker => { 32 | console.log('Service worker ready', serviceWorker) 33 | state.serviceWorker = true 34 | }) 35 | } 36 | }) 37 | 38 | app.use(state => { 39 | state.glitchAppName = 'dat-shopping-list' 40 | state.gitHubRepoName = 'jimpick/dat-shopping-list' 41 | state.devMode = false 42 | state.devLabel = 'f' 43 | }) 44 | app.use(networkStatusStore) 45 | app.use(documentsStore) 46 | app.use(shoppingListStore) 47 | 48 | app.route('/', mainView) 49 | app.route('/create', createView) 50 | app.route('/add-link', addLinkView) 51 | app.route('/doc/:key', shoppingListView) 52 | 53 | app.mount('body') 54 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const budo = require('budo') 5 | const express = require('express') 6 | const compression = require('compression') 7 | const hsts = require('hsts') 8 | const mkdirp = require('mkdirp') 9 | const dbGateway = require('./dbGateway') 10 | const serviceWorkerNoCache = require('./middleware/serviceWorkerNoCache') 11 | const redirectToHttps = require('./middleware/redirectToHttps') 12 | const makeServiceWorker = require('./makeServiceWorker') 13 | const makeImages = require('./makeImages') 14 | const periodicRestart = require('./periodicRestart') 15 | const csp = require('./csp') 16 | 17 | require('events').prototype._maxListeners = 100 18 | 19 | process.chdir(path.resolve(__dirname, '..')) 20 | 21 | const router = express.Router() 22 | 23 | function serveIndex (req, res, next) { 24 | req.url = '/' 25 | next() 26 | } 27 | 28 | router.get('/', csp, serveIndex) 29 | router.get('/index.html', csp, serveIndex) 30 | router.get('/create', csp, serveIndex) 31 | router.get('/add-link', csp, serveIndex) 32 | router.get('/doc/:key', csp, serveIndex) 33 | 34 | const attachWebsocket = dbGateway(router) 35 | 36 | function runBudo () { 37 | const port = process.env.PORT || 5000 38 | const devServer = budo('index.js', { 39 | port, 40 | browserify: { 41 | transform: [ 42 | 'brfs', 43 | ['sheetify', {transform: ['sheetify-nested']}] 44 | ] 45 | }, 46 | middleware: [ 47 | hsts({maxAge: 10886400}), 48 | compression(), 49 | serviceWorkerNoCache, 50 | redirectToHttps, 51 | express.static('img'), 52 | router 53 | ], 54 | dir: ['.', 'static', '.data'], 55 | staticOptions: { 56 | cacheControl: true, 57 | maxAge: 60 * 60 * 1000 // one hour 58 | } 59 | /* 60 | stream: process.stdout, 61 | verbose: true 62 | */ 63 | }) 64 | devServer.on('connect', event => { 65 | console.log('Listening on', event.uri) 66 | attachWebsocket(event.server) 67 | periodicRestart(24 * 60) // Daily 68 | }) 69 | } 70 | 71 | mkdirp.sync('.data/img') 72 | 73 | makeServiceWorker(err => { 74 | if (err) { 75 | console.error(err) 76 | throw err 77 | } 78 | makeImages(err => { 79 | if (err) { 80 | console.error(err) 81 | throw err 82 | } 83 | runBudo() 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /components/customAlert.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | const css = require('sheetify') 4 | const focusTrap = require('focus-trap') 5 | const { button } = require('./button') 6 | 7 | const prefix = css` 8 | :host { 9 | position: absolute; 10 | top: 0; 11 | right: 0; 12 | bottom: 0; 13 | left: 0; 14 | background: rgba(0, 0, 0, 0.4); 15 | z-index: 1000; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | visibility: hidden; 20 | opacity: 0; 21 | transition: opacity 0.3s; 22 | 23 | &.show { 24 | visibility: visible; 25 | opacity: 1; 26 | 27 | .alertContent { 28 | transform: scale(1); 29 | opacity: 1; 30 | } 31 | } 32 | 33 | .alertContent { 34 | background: white; 35 | border: 1px solid black; 36 | padding: 3rem; 37 | margin: 3rem; 38 | font-size: 1.5rem; 39 | transform: scale(0.7); 40 | opacity: 0; 41 | transition: all 0.3s; 42 | border-radius: 0.5rem; 43 | 44 | button { 45 | margin: 2rem 0 0 0; 46 | width: 100%; 47 | font-size: 1.2rem; 48 | padding: 1rem; 49 | } 50 | } 51 | 52 | } 53 | ` 54 | 55 | module.exports = { 56 | alertBox, 57 | show 58 | } 59 | 60 | const local = { 61 | emit: null, 62 | display: false, 63 | message: '', 64 | cb: null 65 | } 66 | 67 | class Alert extends Component { 68 | constructor () { 69 | super() 70 | this.message = '' 71 | this.display = false 72 | this.close = this.close.bind(this) 73 | } 74 | 75 | createElement (message, display, cb) { 76 | this.message = message 77 | this.display = display 78 | this.cb = cb 79 | const show = display ? 'show' : '' 80 | if (this.focusTrap) { 81 | if (display) { 82 | setTimeout(this.focusTrap.activate, 100) // Let animation start 83 | } else { 84 | this.focusTrap.deactivate() 85 | } 86 | } 87 | return html` 88 |
89 |
90 |
91 | ${message} 92 |
93 | ${button('OK', this.close)} 94 |
95 |
96 | ` 97 | } 98 | 99 | load (el) { 100 | this.focusTrap = focusTrap(el) 101 | if (this.display) this.focusTrap.activate() 102 | } 103 | 104 | unload (el) { 105 | this.focusTrap = null 106 | } 107 | 108 | update (message, display, cb) { 109 | this.cb = cb 110 | return message !== this.message || display !== this.display 111 | } 112 | 113 | close (event) { 114 | event.stopPropagation() 115 | if (this.cb) this.cb() 116 | } 117 | } 118 | 119 | function alertBox (state, emit) { 120 | local.emit = emit 121 | return state.cache(Alert, 'alert').render(local.message, local.display, close) 122 | function close () { 123 | local.display = false 124 | emit('render') 125 | if (local.cb) local.cb() 126 | } 127 | } 128 | 129 | function show (message, cb) { 130 | Object.assign(local, {display: true, message, cb}) 131 | window.scroll({top: 0}) 132 | local.emit('render') 133 | } 134 | -------------------------------------------------------------------------------- /server/dbGateway.js: -------------------------------------------------------------------------------- 1 | const expressWebSocket = require('express-ws') 2 | const websocketStream = require('websocket-stream/stream') 3 | const ram = require('random-access-memory') 4 | const hyperdrive = require('@jimpick/hyperdrive-next') 5 | const hyperdiscovery = require('hyperdiscovery') 6 | const pump = require('pump') 7 | const dumpWriters = require('../lib/dumpWriters') 8 | 9 | module.exports = dbGateway 10 | 11 | const maxArchives = 100 12 | const archives = {} 13 | 14 | setInterval(function cleanup () { 15 | const sortedArchives = Object.values(archives).sort((a, b) => a.lastAccess - b.lastAccess) 16 | console.log('Oldest to newest gatewayed archives:') 17 | sortedArchives.forEach((entry, index) => { 18 | const {archive, lastAccess, clients} = entry 19 | const key = archive.key && archive.key.toString('hex') 20 | const peers = archive.db.source.peers.length 21 | console.log(` ${index} ${lastAccess} ${key} (${clients} clients, ${peers} peers)`) 22 | }) 23 | if (sortedArchives.length > maxArchives) { 24 | for (let i = 0; i < sortedArchives.length - maxArchives; i++) { 25 | const archive = sortedArchives[i].archive 26 | const key = archive.key && archive.key.toString('hex') 27 | console.log(`Releasing ${i} ${key}`) 28 | sortedArchives[i].cancel() 29 | } 30 | } 31 | }, 60 * 1000) 32 | 33 | function dbGateway (router) { 34 | return function attachWebsocket (server) { 35 | console.log('Attaching websocket') 36 | expressWebSocket(router, server, { 37 | perMessageDeflate: false 38 | }) 39 | 40 | router.ws('/archive/:key', (ws, req) => { 41 | const archiveKey = req.params.key 42 | console.log('Websocket initiated for', archiveKey) 43 | let archive 44 | if (archives[archiveKey]) { 45 | archive = archives[archiveKey].archive 46 | archives[archiveKey].lastAccess = Date.now() 47 | } else { 48 | archive = hyperdrive(ram, archiveKey) 49 | archives[archiveKey] = { 50 | archive, 51 | lastAccess: Date.now(), 52 | cancel, 53 | clients: 0 54 | } 55 | archive.on('ready', () => { 56 | console.log('archive ready') 57 | // Join swarm 58 | const sw = hyperdiscovery(archive) 59 | archives[archiveKey].swarm = sw 60 | sw.on('connection', (peer, info) => { 61 | console.log('Swarm connection', info) 62 | }) 63 | const watcher = archive.db.watch(() => { 64 | console.log('Archive updated:', archive.key.toString('hex')) 65 | dumpWriters(archive) 66 | }) 67 | watcher.on('error', err => { 68 | console.error('Watcher error', err) 69 | }) 70 | }) 71 | } 72 | archive.ready(() => { 73 | archives[archiveKey].clients += 1 74 | const stream = websocketStream(ws) 75 | pump( 76 | stream, 77 | archive.replicate({encrypt: false, live: true}), 78 | stream, 79 | err => { 80 | console.log('pipe finished for ' + archiveKey, err && err.message) 81 | archives[archiveKey].clients -= 1 82 | } 83 | ) 84 | }) 85 | 86 | function cancel () { 87 | console.log(`Cancelling ${archiveKey}`) 88 | const sw = archives[archiveKey].swarm 89 | if (sw) sw.close() 90 | archive.db.source.peers.forEach(peer => peer.end()) 91 | delete archives[archiveKey] 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dat-shopping-list 2 | 3 | ![Logo](https://dat-shopping-list.glitch.me/img/dat-shopping-list-96.png) 4 | 5 | [https://dat-shopping-list.glitch.me/](https://dat-shopping-list.glitch.me/) 6 | 7 | # Overview 8 | 9 | Dat Shopping List is a "Progressive Web App" built to demonstrate how to use the 10 | new "multiwriter" capabalities that are being added to the [Dat Project](https://datproject.org/). 11 | 12 | The demo is a simple "to do list" app, in the spirit of the [TodoMVC](http://todomvc.com/) project. 13 | 14 | You can run it on any modern web browser. Also, you can run it on your mobile phone (iOS and Android), and it should work offline as well as online. 15 | 16 | Check out the blog post: 17 | 18 | * [Demo: A Collaborative Shopping List Built On Dat](https://blog.datproject.org/2018/05/14/dat-shopping-list/) 19 | 20 | ![Quick Usage Gif](https://dat-shopping-list-video-jimpick.hashbase.io/dat-shopping-list-basic.gif) 21 | 22 | ## Video Walkthrough 23 | 24 | Here is a short (2.5 minute) walkthrough of the demo. 25 | 26 | * [Video Walkthrough (MP4)](https://dat-shopping-list-video-jimpick.hashbase.io/dat-shopping-list-1.mp4) 27 | 28 | 29 | # Quick Deploy / Free Hosting Options 30 | 31 | The demo is very easy to deploy, as it is self-contained, and requires no storage. 32 | 33 | There are many ways for you to run your own instance. You may want to run your own instance for privacy reasons, reliability reasons, or so you can customize it. 34 | 35 | ## Glitch 36 | 37 | dat-shopping-list was developed on Glitch. Glitch is very nice. It is free, and it gives you a Node.js backend as well as an in-browser IDE with multi-user editing and debugging! Use the following link to fork your own copy of the Node.js gateway service and front-end user interface: 38 | 39 | [![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/dat-shopping-list) 40 | 41 | Note: Occasionally, Glitch gets into a state where it can't read from the disk. One way to get going again is to run: `rm -rf node_modules && npm install` on the console. 42 | 43 | ## Heroku 44 | 45 | The app can easily be deployed to Heroku, which offers either 550-1000 hours a month for free (sleeps after 30 minutes of inactivity). 46 | 47 | [Heroku Pricing](https://www.heroku.com/pricing) 48 | 49 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 50 | 51 | ## Zeit now.sh 52 | 53 | [Zeit](https://zeit.co/account/plan) offers a free "OSS" plan that works with the Dockerfile. Once you are signed up and have the command line tool installed, you can deploy straight from the github repo: 54 | 55 | ``` 56 | now jimpick/dat-shopping-list 57 | ``` 58 | 59 | Note: If you use the OSS plan, the logs will be publicly available, which may not be what you want, as people will be able to see the keys of shopping lists that have been synced through the gateway, and they will be able to download the data. The logging could be changed to hide the keys. 60 | 61 | ## Docker 62 | 63 | The git repo includes a simple Dockerfile. There is also a Docker image published here: 64 | 65 | [https://hub.docker.com/r/jimpick/dat-shopping-list/](https://hub.docker.com/r/jimpick/dat-shopping-list/) 66 | 67 | If you have docker installed, you should be able to run it: 68 | 69 | ``` 70 | docker run -p 5000:5000 jimpick/dat-shopping-list 71 | ``` 72 | 73 | Several of the major cloud hosting companies offer a free tier or introductory offer where you can run Docker images. 74 | 75 | ## npm 76 | 77 | The demo is published on npm: 78 | 79 | [https://www.npmjs.com/package/dat-shopping-list](https://www.npmjs.com/package/dat-shopping-list) 80 | 81 | You can try it out using `npx`: 82 | 83 | ``` 84 | npx dat-shopping-list 85 | ``` 86 | 87 | or you can install it globally and run it: 88 | 89 | ``` 90 | npm install -g dat-shopping-list 91 | 92 | dat-shopping-list 93 | ``` 94 | 95 | It should work on Mac and Linux. It hasn't been tested on Windows. 96 | 97 | # License 98 | 99 | MIT 100 | -------------------------------------------------------------------------------- /components/statusDisplay.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const html = require('choo/html') 3 | const css = require('sheetify') 4 | const SvgIcon = require('./svgIcon') 5 | 6 | // Use browserify + brfs to convert these to inline strings 7 | const fs = require('fs') 8 | const syncIcon = fs.readFileSync( 9 | path.resolve(__dirname, '../static/img/ic_sync_black_24px.svg'), 10 | 'utf8' 11 | ) 12 | const syncIconDisabled = fs.readFileSync( 13 | path.resolve(__dirname, '../static/img/ic_sync_disabled_black_24px.svg'), 14 | 'utf8' 15 | ) 16 | const syncIconProblem = fs.readFileSync( 17 | path.resolve(__dirname, '../static/img/ic_sync_problem_black_24px.svg'), 18 | 'utf8' 19 | ) 20 | 21 | const prefix = css` 22 | :host { 23 | position: absolute; 24 | top: 0.4rem; 25 | right: 0.4rem; 26 | font-weight: 300; 27 | font-size: 0.8rem; 28 | 29 | @media only screen and (max-width : 350px) { 30 | font-size: 0.6rem; 31 | 32 | img { 33 | width: 0.7rem; 34 | height: 0.7rem; 35 | } 36 | } 37 | 38 | .online { 39 | color: var(--color-green); 40 | 41 | svg { 42 | fill: var(--color-green); 43 | } 44 | } 45 | 46 | .offline { 47 | color: var(--color-red); 48 | 49 | svg { 50 | fill: var(--color-red); 51 | } 52 | } 53 | 54 | .connecting { 55 | color: var(--color-yellow); 56 | 57 | svg { 58 | fill: var(--color-yellow); 59 | } 60 | } 61 | 62 | .online svg, 63 | .offline svg, 64 | .connecting svg { 65 | width: 1rem; 66 | height: 1rem; 67 | vertical-align: text-top; 68 | } 69 | 70 | img { 71 | width: 0.8rem; 72 | height: 0.8rem; 73 | } 74 | } 75 | ` 76 | 77 | module.exports = statusDisplay 78 | 79 | function statusDisplay (state) { 80 | if (!state) return null 81 | let networkStatus 82 | let connected 83 | let pendingUpload = state.localUploadLength - state.syncedUploadLength 84 | if (pendingUpload <= 0 || isNaN(pendingUpload)) pendingUpload = null 85 | if (pendingUpload) pendingUpload = html`${pendingUpload}↑` 86 | let pendingDownload = state.localDownloadLength - state.syncedDownloadLength 87 | if (pendingDownload <= 0 || isNaN(pendingDownload)) pendingDownload = null 88 | if (pendingDownload) pendingDownload = html`${pendingDownload}↓` 89 | if (state.networkStatus !== undefined) { 90 | const onlineOffline = state.networkStatus 91 | ? html`Online` 92 | : html`Offline` 93 | networkStatus = html` 94 |
95 | Network: ${onlineOffline} 96 |
97 | ` 98 | } 99 | if (state.connected !== undefined) { 100 | if (state.connecting) { 101 | connected = html` 102 | 103 | ${state.cache(SvgIcon, 'sync').render(syncIcon)} 104 | ${pendingDownload} 105 | ${pendingUpload} 106 | 107 | ` 108 | } else if (state.connected) { 109 | connected = html` 110 | 111 | ${state.cache(SvgIcon, 'sync').render(syncIcon)} 112 | ${pendingDownload} 113 | ${pendingUpload} 114 | 115 | ` 116 | } else { 117 | if (state.networkStatus) { 118 | connected = html` 119 | 120 | ${state.cache(SvgIcon, 'syncProblem').render(syncIconProblem)} 121 | ${pendingDownload} 122 | ${pendingUpload} 123 | 124 | ` 125 | } else { 126 | connected = html` 127 | 128 | ${state.cache(SvgIcon, 'syncDisabled').render(syncIconDisabled)} 129 | ${pendingDownload} 130 | ${pendingUpload} 131 | 132 | ` 133 | } 134 | } 135 | connected = html` 136 |
137 | Sync: ${connected} 138 |
139 | ` 140 | } 141 | let serviceWorker = state.serviceWorker ? html`
Worker Ready
` : null 142 | let devLabel = state.devMode ? html`
Label: ${state.devLabel}
` : null 143 | return html` 144 |
145 | ${networkStatus} 146 | ${connected} 147 | ${serviceWorker} 148 | ${devLabel} 149 |
150 | ` 151 | } 152 | -------------------------------------------------------------------------------- /components/githubButton.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | const raw = require('choo/html/raw') 4 | const css = require('sheetify') 5 | 6 | // CSS and HTML based on http://buttons.github.io/ 7 | 8 | const prefix = css` 9 | :host { 10 | margin: 0; 11 | font-size: 0; 12 | white-space: nowrap; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 14 | user-select: none; 15 | width: 100px; 16 | height: 28px; 17 | border: none; 18 | 19 | .btn { 20 | padding: 0 10px; 21 | font-size: 12px; 22 | background-color: #eff3f6; 23 | background-image: linear-gradient(to bottom, #fafbfc, #e4ebf0); 24 | background-repeat: repeat-x; 25 | background-size: 110% 110%; 26 | 27 | &:active { 28 | background-color: #e9ecef; 29 | background-image: none; 30 | border-color: #afb1b2; 31 | box-shadow: inset 0 0.15em 0.3em rgba(27,31,35,0.15); 32 | } 33 | 34 | &:hover { 35 | background-color: #e6ebf1; 36 | background-image: linear-gradient(to bottom, #f0f3f6, #dce3ec); 37 | border-color: #afb1b2; 38 | } 39 | 40 | span { 41 | vertical-align: 0; 42 | } 43 | } 44 | 45 | .social-count { 46 | padding: 0 7px; 47 | margin-left: 7px; 48 | font-size: 11px; 49 | position: relative; 50 | background-color: #fff; 51 | 52 | b { 53 | margin-right: 0; 54 | border-right-color: #d1d2d3 !important; 55 | } 56 | 57 | i { 58 | margin-right: -1.5px; 59 | border-right-color: #fff !important; 60 | } 61 | 62 | b, i { 63 | margin-top: -6px; 64 | position: absolute; 65 | top: 50%; 66 | right: 100%; 67 | display: block; 68 | width: 0; 69 | height: 0; 70 | border: 6px solid transparent; 71 | } 72 | 73 | span { 74 | vertical-align: 0; 75 | } 76 | 77 | &:hover { 78 | color: #0366d6; 79 | } 80 | } 81 | 82 | .btn, .social-count { 83 | height: 26px; 84 | line-height: 26px; 85 | display: inline-block; 86 | font-weight: 600; 87 | vertical-align: middle; 88 | cursor: pointer; 89 | border: 1px solid #d1d2d3; 90 | border-radius: 0.25em; 91 | 92 | &:focus { 93 | border-color: #c8e1ff; 94 | } 95 | } 96 | 97 | a { 98 | color: #24292e; 99 | text-decoration: none; 100 | outline: 0; 101 | } 102 | 103 | .octicon { 104 | height: 16px; 105 | top: 4px; 106 | position: relative; 107 | display: inline-block; 108 | fill: currentColor; 109 | } 110 | } 111 | ` 112 | 113 | class GitHubButton extends Component { 114 | constructor (id, state, emit, repo) { 115 | super() 116 | this.loaded = false 117 | this.repo = repo 118 | this.stargazersCount = null 119 | } 120 | 121 | createElement () { 122 | const {repo} = this 123 | const svg = raw(` 124 | 127 | `) 128 | return html` 129 |
130 | 134 | ${svg} 135 | Star 136 | 137 |
138 | ` 139 | } 140 | 141 | load (el) { 142 | const url = `https://api.github.com/repos/${this.repo}` 143 | window.fetch(url) 144 | .then(res => res.json()) 145 | .then(({stargazers_count: stargazersCount}) => { 146 | this.stargazersCount = stargazersCount 147 | const linkEl = this.stargazersLink() 148 | el.appendChild(linkEl) 149 | }) 150 | } 151 | 152 | stargazersLink () { 153 | const {repo, stargazersCount} = this 154 | return html` 155 | 159 | ${stargazersCount} 160 | 161 | ` 162 | } 163 | 164 | update () { 165 | return false 166 | } 167 | } 168 | 169 | module.exports = GitHubButton 170 | -------------------------------------------------------------------------------- /views/main.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const prettyHash = require('pretty-hash') 4 | const header = require('../components/header') 5 | const button = require('../components/button') 6 | const footer = require('../components/footer') 7 | 8 | const prefix = css` 9 | :host { 10 | .content { 11 | margin: 1rem 1rem 2rem 1rem; 12 | } 13 | .uvp { 14 | box-shadow: 0 0 20px rgba(0,0,0,.15); 15 | padding: 1em; 16 | background-color: var(--color-white); 17 | } 18 | .uvp h4 { 19 | margin: 0.5rem 1rem 1rem 1rem; 20 | font-size: 1.3rem; 21 | text-align: center; 22 | } 23 | h3 { 24 | margin-top: 2rem; 25 | } 26 | ul { 27 | padding: 0 0.3rem 0.5rem 0.3rem; 28 | } 29 | li { 30 | list-style-type: none; 31 | border: 1px solid var(--color-neutral-20); 32 | border-radius: 0.5rem; 33 | margin: 0 0 0.5rem 0; 34 | padding: 0 0.5rem; 35 | min-height: 3rem; 36 | position: relative; 37 | cursor: pointer; 38 | font-size: 1.2rem; 39 | background-color: var(--color-white); 40 | display: flex; 41 | 42 | .link { 43 | margin: 1rem 0.5rem; 44 | } 45 | 46 | span { 47 | font-size: 12px; 48 | font-family: monospace; 49 | line-height: 1rem; 50 | position: absolute; 51 | top: 0.1rem; 52 | right: 0.3rem; 53 | pointer-events: none; 54 | } 55 | } 56 | .solo { 57 | background-image: url(/img/bg-landing-page.svg); 58 | background-position: center; 59 | background-repeat: no-repeat; 60 | height: 16rem; 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | flex-direction: column; 65 | 66 | button { 67 | height: 4rem; 68 | } 69 | 70 | .addLinkButton button { 71 | margin-top: 1.5rem; 72 | height: 2.5rem; 73 | font-size: 0.8rem; 74 | font-weight: 500; 75 | } 76 | 77 | } 78 | 79 | .notSolo { 80 | display: flex; 81 | justify-content: space-between; 82 | margin: 0 0.5rem; 83 | 84 | .createButton { 85 | margin-right: 0.5rem; 86 | } 87 | 88 | .addLinkButton { 89 | margin-left: 0.5rem; 90 | } 91 | } 92 | 93 | .addLinkButton button { 94 | color: var(--color-green); 95 | background: var(--color-white); 96 | border-color: var(--color-green); 97 | } 98 | } 99 | ` 100 | 101 | module.exports = mainView 102 | 103 | function mainView (state, emit) { 104 | emit('DOMTitleChange', 'Dat Shopping List') 105 | const documents = state.documents.map(doc => { 106 | return html` 107 |
  • 108 | ${prettyHash(doc.key)} 109 | ${doc.name} 110 |
  • 111 | ` 112 | function click (event) { 113 | const link = event.target.querySelector('a') 114 | if (link) link.click() 115 | } 116 | function keydown (event) { 117 | if (event.key === ' ' || event.key === 'Enter') { 118 | event.target.querySelector('a').click() 119 | } 120 | } 121 | }) 122 | const docHeader = documents.length > 0 ? html`

    Shopping Lists

    ` : null 123 | const soloCta = documents.length === 0 ? 'solo' : 'notSolo' 124 | return html` 125 | 126 | ${header(state)} 127 |
    128 |
    129 |

    Test drive multi-writer Dat!

    130 |

    131 | This is a Progressive Web App built to demonstrate the use of the new 132 | multi-writer capabilities from the 133 | Dat Project. 134 |

    135 |

    136 | Make shopping lists and use them online or offline, and sync between multiple 137 | devices or users. Read the blog post! 139 |

    140 |

    141 |
    142 | ${docHeader} 143 |
    144 | 147 |
    148 |
    149 | ${button.button('Create a new Shopping List', () => emit('pushState', '/create'))} 150 |
    151 |
    152 | ${button.button('Have a Link? Paste it Here', () => emit('pushState', '/add-link'))} 153 |
    154 |
    155 |
    156 | ${footer(state)} 157 | 158 | ` 159 | } 160 | -------------------------------------------------------------------------------- /lib/websocketGateway.js: -------------------------------------------------------------------------------- 1 | const websocket = require('websocket-stream') 2 | const pump = require('pump') 3 | const equal = require('deep-equal') 4 | const dumpWriters = require('./dumpWriters') 5 | 6 | module.exports = connectToGateway 7 | 8 | let replicationCount = 0 9 | let connecting = 0 10 | 11 | function connectToGateway (archive, updateSyncStatus, updateConnecting) { 12 | const key = archive.key.toString('hex') 13 | const host = document.location.host 14 | const proto = document.location.protocol === 'https:' ? 'wss' : 'ws' 15 | const url = `${proto}://${host}/archive/${key}` 16 | console.log('connectToGateway', key) 17 | 18 | let cancelled = false 19 | let connected = false 20 | 21 | const intervalId = setInterval(dumpWritersIfChanged, 1000) 22 | 23 | let archiveStream 24 | function connectWebsocket () { 25 | if (cancelled) return 26 | if (connected) return 27 | if (navigator.onLine === false) { 28 | console.log('Offline, not syncing') 29 | console.log('Waiting 5 seconds to reconnect') 30 | setTimeout(connectWebsocket, 5000) 31 | return 32 | } 33 | console.log('Connecting websocket', url) 34 | console.log('Active replications', ++replicationCount) 35 | const stream = websocket(url) 36 | archiveStream = archive.replicate({encrypt: false, live: true}) 37 | updateConnecting(++connecting) 38 | connected = true 39 | window.addEventListener('offline', goOfflineNow) 40 | window.removeEventListener('online', goOnlineNow) 41 | pump( 42 | stream, 43 | archiveStream, 44 | stream, 45 | err => { 46 | connected = false 47 | updateConnecting(--connecting) 48 | if (err) { 49 | console.log('Pipe finished', err.message) 50 | if (err.stack) { 51 | console.log(err.stack) 52 | } 53 | } else { 54 | console.log('Pipe finished, no errors') 55 | } 56 | console.log('Active replications', --replicationCount) 57 | window.removeEventListener('offline', goOfflineNow) 58 | if (!cancelled) { 59 | console.log('Waiting 5 seconds to reconnect') 60 | setTimeout(connectWebsocket, 5000) 61 | window.addEventListener('online', goOnlineNow) 62 | } else { 63 | dumpWritersIfChanged() 64 | clearInterval(intervalId) 65 | } 66 | } 67 | ) 68 | 69 | function goOfflineNow () { 70 | if (connected && !archiveStream.destroyed) { 71 | console.log('Browser went offline - Ending replication on websocket') 72 | archiveStream.finalize() // Gracefully end the stream 73 | } 74 | } 75 | 76 | function goOnlineNow () { 77 | if (!connected && archiveStream.destroyed) { 78 | console.log('Browser went online - Restarting replication on websocket') 79 | connectWebsocket() 80 | } 81 | } 82 | } 83 | connectWebsocket() 84 | 85 | let lastUpdate 86 | 87 | function dumpWritersIfChanged () { 88 | const db = archive.db 89 | const update = { 90 | connectedPeers: db.source.peers.filter(peer => !!peer.remoteId).length, 91 | writers: db._writers.map( 92 | writer => writer && [writer._feed.length, writer._feed.remoteLength] 93 | ), 94 | contentFeeds: db.contentFeeds.map( 95 | feed => feed && [feed.length, feed.remoteLength] 96 | ) 97 | } 98 | if (!equal(update, lastUpdate)) { 99 | dumpWriters(archive) 100 | lastUpdate = update 101 | } 102 | const {connectedPeers} = update 103 | const syncStatus = update.writers.reduce( 104 | (acc, pair, index) => { 105 | const newAcc = Object.assign({}, acc) 106 | const contentPair = update.contentFeeds[index] 107 | if (index === db._localWriter._id) { 108 | if (pair) { 109 | newAcc.localUploadLength += pair[0] 110 | newAcc.remoteUploadLength += pair[1] 111 | } 112 | if (contentPair) { 113 | newAcc.localUploadLength += contentPair[0] 114 | newAcc.remoteUploadLength += contentPair[1] 115 | } 116 | } else { 117 | if (pair) { 118 | newAcc.localDownloadLength += pair[0] 119 | newAcc.remoteDownloadLength += pair[1] 120 | } 121 | if (contentPair) { 122 | newAcc.localDownloadLength += contentPair[0] 123 | newAcc.remoteDownloadLength += contentPair[1] 124 | } 125 | } 126 | return newAcc 127 | }, 128 | { 129 | key, 130 | connectedPeers, 131 | localUploadLength: 0, 132 | remoteUploadLength: 0, 133 | localDownloadLength: 0, 134 | remoteDownloadLength: 0 135 | } 136 | ) 137 | updateSyncStatus(syncStatus) 138 | } 139 | 140 | function cancel () { 141 | cancelled = true 142 | console.log('Ending replication on websocket') 143 | if (!connected || !archiveStream) { 144 | clearInterval(intervalId) 145 | } 146 | if (archiveStream) { 147 | archiveStream.finalize() // Gracefully end the stream 148 | } 149 | } 150 | 151 | return cancel 152 | } 153 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## 1. Purpose 4 | 5 | A primary goal of dat-shopping-list is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). 6 | 7 | This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all those who participate in dat-shopping-list to help us create safe and positive experiences for everyone. 10 | 11 | ## 2. Open Source Citizenship 12 | 13 | A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. 14 | 15 | Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. 16 | 17 | If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. 18 | 19 | ## 3. Expected Behavior 20 | 21 | The following behaviors are expected and requested of all community members: 22 | 23 | * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. 24 | * Exercise consideration and respect in your speech and actions. 25 | * Attempt collaboration before conflict. 26 | * Refrain from demeaning, discriminatory, or harassing behavior and speech. 27 | * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. 28 | * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. 29 | 30 | ## 4. Unacceptable Behavior 31 | 32 | The following behaviors are considered harassment and are unacceptable within our community: 33 | 34 | * Violence, threats of violence or violent language directed against another person. 35 | * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. 36 | * Posting or displaying sexually explicit or violent material. 37 | * Posting or threatening to post other people’s personally identifying information ("doxing"). 38 | * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. 39 | * Inappropriate photography or recording. 40 | * Inappropriate physical contact. You should have someone’s consent before touching them. 41 | * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. 42 | * Deliberate intimidation, stalking or following (online or in person). 43 | * Advocating for, or encouraging, any of the above behavior. 44 | * Sustained disruption of community events, including talks and presentations. 45 | 46 | ## 5. Consequences of Unacceptable Behavior 47 | 48 | Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. 49 | 50 | Anyone asked to stop unacceptable behavior is expected to comply immediately. 51 | 52 | If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). 53 | 54 | ## 6. Reporting Guidelines 55 | 56 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. jim@jimpick.com. 57 | 58 | [LINK_TO_REPORTING_GUIDELINES] 59 | 60 | Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. 61 | 62 | ## 7. Addressing Grievances 63 | 64 | If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify jim@jimpick.com with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 65 | 66 | [LINK_TO_POLICY] 67 | 68 | ## 8. Scope 69 | 70 | We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business. 71 | 72 | This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. 73 | 74 | ## 9. Contact info 75 | 76 | jim@jimpick.com 77 | 78 | ## 10. License and attribution 79 | 80 | This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). 81 | 82 | Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). 83 | 84 | Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/) 85 | -------------------------------------------------------------------------------- /stores/documents.js: -------------------------------------------------------------------------------- 1 | const thunky = require('thunky') 2 | 3 | module.exports = store 4 | 5 | function store (state, emitter) { 6 | state.documents = [] 7 | 8 | const ready = thunky(openDocumentsDB) 9 | 10 | ready(() => { emitter.emit('render') }) 11 | 12 | emitter.on('writeNewDocumentRecord', (keyHex, docName) => { 13 | ready(() => { 14 | if (state.documents.find(doc => doc.key === keyHex)) return 15 | writeDocumentRecord(keyHex, docName, err => { 16 | if (err) throw err 17 | emitter.emit('pushState', `/doc/${keyHex}`) 18 | }) 19 | }) 20 | }) 21 | 22 | emitter.on('deleteCurrentDoc', () => { 23 | const keyHex = state.params.key 24 | deleteDoc(keyHex, err => { 25 | if (err) throw err 26 | console.log('Doc deleted', keyHex) 27 | emitter.emit('pushState', '/') 28 | }) 29 | }) 30 | 31 | emitter.on('fetchDocLastSync', fetchDocLastSync) 32 | emitter.on('updateDocLastSync', updateDocLastSync) 33 | 34 | // Store documents in indexedDB 35 | function openDocumentsDB (cb) { 36 | const request = window.indexedDB.open('documents', 2) 37 | request.onerror = function (event) { 38 | console.log('IndexedDB error') 39 | } 40 | request.onsuccess = function (event) { 41 | state.documentsDB = event.target.result 42 | readDocuments(cb) 43 | } 44 | request.onupgradeneeded = function (event) { 45 | const db = event.target.result 46 | let objectStore 47 | if (event.oldVersion === 0) { 48 | objectStore = db.createObjectStore('documents', {keyPath: 'key'}) 49 | objectStore.createIndex('name', 'name') 50 | } else { 51 | objectStore = event.target.transaction.objectStore('documents') 52 | } 53 | objectStore.createIndex('dateAdded', 'dateAdded') 54 | objectStore.transaction.oncomplete = function (event) { 55 | console.log('Document db created') 56 | } 57 | } 58 | } 59 | 60 | function readDocuments (cb) { 61 | const db = state.documentsDB 62 | if (!db) return 63 | const objectStore = db.transaction('documents').objectStore('documents') 64 | const index = objectStore.index('dateAdded') 65 | state.documents = [] 66 | index.openCursor().onsuccess = function (event) { 67 | const cursor = event.target.result 68 | if (cursor) { 69 | state.documents.push(cursor.value) 70 | cursor.continue() 71 | } else { 72 | cb() 73 | } 74 | } 75 | } 76 | 77 | function writeDocumentRecord (key, name, cb) { 78 | const db = state.documentsDB 79 | if (!db) return 80 | const request = db.transaction('documents', 'readwrite') 81 | .objectStore('documents') 82 | .add({ 83 | key, 84 | name, 85 | dateAdded: Date.now(), 86 | lastSync: null, 87 | syncedUploadLength: 0, 88 | syncedDownloadLength: 0 89 | }) 90 | request.onsuccess = function (event) { 91 | readDocuments(() => { 92 | console.log('documents reloaded') 93 | cb() 94 | }) 95 | } 96 | request.onerror = function (err) { 97 | cb(err) 98 | } 99 | } 100 | 101 | function deleteDoc (key, cb) { 102 | const db = state.documentsDB 103 | const request = db.transaction('documents', 'readwrite') 104 | .objectStore('documents') 105 | .delete(key) 106 | request.onsuccess = function (event) { 107 | // Note: Deleting db doesn't return success ... probably because it's 108 | // still in use? It appears that it still gets cleaned up. 109 | window.indexedDB.deleteDatabase(`doc-${key}`) 110 | readDocuments(() => { 111 | console.log('documents reloaded') 112 | cb() 113 | }) 114 | } 115 | request.onerror = function (err) { 116 | cb(err) 117 | } 118 | } 119 | 120 | function fetchDocLastSync (key) { 121 | state.lastSync = null 122 | state.syncedUploadLength = null 123 | state.syncedDownloadLength = null 124 | state.localUploadLength = null 125 | state.localDownloadLength = null 126 | ready(() => { 127 | const db = state.documentsDB 128 | const objectStore = db.transaction('documents', 'readwrite') 129 | .objectStore('documents') 130 | const request = objectStore.get(key) 131 | request.onsuccess = function (event) { 132 | const data = event.target.result 133 | if (!data) return 134 | state.lastSync = data.lastSync 135 | state.syncedUploadLength = data.syncedUploadLength 136 | state.syncedDownloadLength = data.syncedDownloadLength 137 | } 138 | request.onerror = function (event) { 139 | console.error('fetchDocLastSync error', event) 140 | } 141 | }) 142 | } 143 | 144 | function updateDocLastSync ({key, syncedUploadLength, syncedDownloadLength}) { 145 | ready(() => { 146 | const db = state.documentsDB 147 | const objectStore = db.transaction('documents', 'readwrite') 148 | .objectStore('documents') 149 | const request = objectStore.get(key) 150 | request.onsuccess = function (event) { 151 | const data = event.target.result 152 | if (!data) return 153 | data.syncedUploadLength = syncedUploadLength 154 | data.syncedDownloadLength = syncedDownloadLength 155 | data.lastSync = Date.now() 156 | const requestUpdate = objectStore.put(data) 157 | requestUpdate.onerror = function (event) { 158 | console.error('updateDocLastSync update error', event) 159 | } 160 | } 161 | request.onerror = function (event) { 162 | console.error('updateDocLastSync error', event) 163 | } 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /views/shoppingList.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const raw = require('choo/html/raw') 3 | const css = require('sheetify') 4 | const header = require('../components/header') 5 | const button = require('../components/button') 6 | const footer = require('../components/footer') 7 | const debugTools = require('../components/debugTools') 8 | const shoppingListTitle = require('../components/shoppingListTitle') 9 | const writeStatus = require('../components/writeStatus') 10 | const customAlert = require('../components/customAlert') 11 | 12 | const prefix = css` 13 | :host { 14 | .content { 15 | margin: 1rem 1rem 2rem 1rem; 16 | } 17 | 18 | .error { 19 | padding: 1rem; 20 | border: 2px solid red; 21 | border-radius: 1rem; 22 | text-align: center; 23 | margin: 1rem; 24 | } 25 | 26 | ul { 27 | padding: 0 0.3rem 0.5rem 0.3rem; 28 | } 29 | 30 | li { 31 | list-style-type: none; 32 | border: 1px solid var(--color-neutral-20); 33 | border-radius: 0.5rem; 34 | margin: 0 0 0.5rem 0; 35 | padding: 0 0.5rem; 36 | min-height: 3rem; 37 | cursor: pointer; 38 | font-size: 1.2rem; 39 | display: flex; 40 | align-items: center; 41 | 42 | &:focus { 43 | outline: none; 44 | border-color: var(--color-green); 45 | } 46 | 47 | input[type="checkbox"] { 48 | pointer-events: none; 49 | margin: 0 0.4rem; 50 | } 51 | 52 | .text { 53 | flex: 1; 54 | overflow: hidden; 55 | text-overflow: ellipsis; 56 | margin: 0.5rem; 57 | } 58 | 59 | .text[data-bought="true"] { 60 | text-decoration: line-through; 61 | } 62 | 63 | .delete { 64 | opacity: 0.6; 65 | font-size: 1.5rem; 66 | font-weight: 900; 67 | color: var(--color-green); 68 | flex: 0 0; 69 | padding: 0.6rem 0.6rem; 70 | } 71 | 72 | &.addGroceryItem { 73 | border-color: transparent; 74 | 75 | form { 76 | display: flex; 77 | margin: 0 0 0 1.5rem; 78 | width: 100%; 79 | 80 | input[type="text"] { 81 | font-size: 1.2rem; 82 | flex: 1; 83 | width: 100%; 84 | } 85 | 86 | input[type="submit"] { 87 | margin-left: 0.6rem; 88 | } 89 | } 90 | } 91 | 92 | } 93 | 94 | .bottomNav { 95 | .delete { 96 | color: var(--color-red); 97 | text-decoration: none; 98 | float: right; 99 | } 100 | } 101 | } 102 | ` 103 | 104 | module.exports = shoppingListView 105 | 106 | function shoppingListView (state, emit) { 107 | emit('DOMTitleChange', 'Dat Shopping List - ' + state.docTitle) 108 | 109 | function layout (inner) { 110 | return html` 111 | 112 | ${header(state)} 113 |
    114 | ${inner} 115 | 119 |
    120 | ${footer(state)} 121 | ${debugTools(state, emit)} 122 | ${customAlert.alertBox(state, emit)} 123 | 124 | ` 125 | } 126 | 127 | if (state.error) { 128 | return layout(html` 129 |
    130 | ${state.error}
    131 | (Try reloading, there occasionally are problems during sync) 132 |
    133 | `) 134 | } 135 | if (state.loading) return layout('Loading...') 136 | 137 | const items = state.shoppingList 138 | .sort((a, b) => a.dateAdded - b.dateAdded) 139 | .map(item => { 140 | const id = item.file.replace('.json', '') 141 | return html` 142 |
  • 143 | 144 |
    ${item.name}
    145 |
    ${raw('×')}
    146 |
  • 147 | ` 148 | 149 | function toggle () { 150 | emit('toggleBought', this.file) 151 | } 152 | 153 | function remove (event) { 154 | emit('remove', this.file) 155 | event.stopPropagation() 156 | } 157 | }) 158 | const addItemInput = html`` 159 | addItemInput.isSameNode = function (target) { 160 | return (target && target.nodeName && target.nodeName === 'INPUT') 161 | } 162 | 163 | items.push(html` 164 |
  • 165 |
    166 | ${addItemInput} 167 | ${button.submit('Add')} 168 |
    169 |
  • 170 | `) 171 | function submitAddItem (event) { 172 | const input = event.target.querySelector('input') 173 | const name = input.value.trim() 174 | if (name !== '') emit('addItem', name) 175 | input.value = '' 176 | event.preventDefault() 177 | event.target.scrollIntoView() 178 | } 179 | const noItems = state.shoppingList.length === 0 ? html`

    No items.

    ` : null 180 | return layout(html` 181 |
    182 | ${shoppingListTitle(state, emit)} 183 | ${writeStatus(state, emit)} 184 | 187 | ${noItems} 188 |
    189 | `) 190 | 191 | function deleteList (event) { 192 | const confirm = window.confirm('Delete this list?') 193 | if (confirm) { 194 | emit('deleteCurrentDoc') 195 | } 196 | event.preventDefault() 197 | } 198 | 199 | function keydown (event) { 200 | if (event.key === ' ' || event.key === 'Enter') { 201 | event.target.click() 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /components/writeStatus.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const raw = require('choo/html/raw') 3 | const css = require('sheetify') 4 | const copy = require('clipboard-copy') 5 | const customAlert = require('./customAlert') 6 | const button = require('./button') 7 | 8 | module.exports = writeStatus 9 | 10 | const prefix = css` 11 | :host { 12 | box-shadow: 0 0 10px rgba(0,0,0,.15); 13 | padding: 0.7rem; 14 | position: relative; 15 | -webkit-tap-highlight-color: transparent; 16 | 17 | .collapseExpand { 18 | position: absolute; 19 | top: -0.8rem; 20 | right: 0.6rem; 21 | z-index: 1; 22 | font-size: 0.8rem; 23 | cursor: pointer; 24 | color: var(--color-green); 25 | background: var(--color-white); 26 | border: 2px solid var(--color-neutral-10); 27 | border-radius: 0.8rem; 28 | width: 5rem; 29 | height: 1.4rem; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | } 34 | 35 | .noAuth { 36 | color: var(--color-red); 37 | font-weight: 700; 38 | } 39 | 40 | .okAuth { 41 | color: var(--color-green); 42 | font-weight: 700; 43 | } 44 | 45 | .help { 46 | font-size: 0.8rem; 47 | font-weight: 500; 48 | margin-left: 0.5rem; 49 | margin-right: 0.5rem; 50 | } 51 | 52 | .localKeySection { 53 | -webkit-tap-highlight-color: black; 54 | background: var(--color-neutral-10); 55 | padding: 0.5rem; 56 | 57 | .noWrap { 58 | white-space: nowrap; 59 | display: flex; 60 | 61 | .localKey { 62 | color: var(--color-blue-darker); 63 | text-overflow: ellipsis; 64 | overflow: hidden; 65 | } 66 | } 67 | 68 | @media only screen and (min-device-width : 500px) and (max-device-width : 600px) { 69 | .localKey { 70 | font-size: 12px; 71 | } 72 | } 73 | 74 | @media only screen and (min-device-width : 400px) and (max-device-width : 500px) { 75 | .localKey { 76 | font-size: 10px; 77 | } 78 | } 79 | 80 | @media only screen and (max-width : 400px) { 81 | .localKey { 82 | font-size: 8px; 83 | } 84 | } 85 | 86 | button { 87 | font-size: 0.7rem; 88 | padding: 0.5rem 0.5rem; 89 | font-weight: 400; 90 | margin-right: 1rem; 91 | } 92 | } 93 | 94 | form { 95 | margin: 0; 96 | 97 | .writerInputs { 98 | display: flex; 99 | flex-wrap: wrap; 100 | align-items: center; 101 | font-size: 16px; 102 | 103 | div { 104 | margin-right: 0.4rem; 105 | } 106 | 107 | input[type="text"] { 108 | font-size: 16px; 109 | flex: 1; 110 | margin-right: 0.4rem; 111 | } 112 | 113 | input[type="submit"] { 114 | font-size: 16px; 115 | padding: 0.1rem 0.5rem; 116 | font-weight: 400; 117 | } 118 | } 119 | } 120 | } 121 | ` 122 | 123 | function writeStatus (state, emit) { 124 | const db = state.archive && state.archive.db 125 | if (!db) return null 126 | const localKey = db.local.key.toString('hex') 127 | let sourceCopy = null 128 | if (!state.writeStatusCollapsed) { 129 | sourceCopy = db.local === db.source 130 | ? 'You created this document.' 131 | : 'You joined this document.' 132 | } 133 | let authStatus = null 134 | if (state.authorized) { 135 | if (state.writeStatusCollapsed) { 136 | authStatus = html`
    Authorized (Expand to add a writer)
    ` 137 | } else { 138 | authStatus = html`
    You are authorized to write to this document.
    ` 139 | } 140 | } else { 141 | let explanationAndLocalKey = null 142 | if (!state.writeStatusCollapsed) { 143 | explanationAndLocalKey = html` 144 |
    145 |

    146 | You may edit your local copy, but changes will not be synchronized until you 147 | pass your "local key" to an owner of the document and they authorize you. 148 |

    149 |
    e.stopPropagation()}> 150 | Your local key is: 151 |
    152 | ${localKey} 153 |
    154 | ${button.button('Copy to Clipboard', copyToClipboard)} 155 | ${state.localKeyCopied ? 'Copied!' : null} 156 |
    157 |
    158 | ` 159 | } 160 | let noAuth 161 | if (!state.writeStatusCollapsed) { 162 | noAuth = html`
    163 | You are not currently authorized to write to this document. 164 |
    ` 165 | } else { 166 | noAuth = html`
    167 | Not authorized 168 | (Expand for more info) 169 |
    ` 170 | } 171 | authStatus = html`
    172 | ${noAuth} 173 | ${explanationAndLocalKey} 174 |
    ` 175 | } 176 | let authForm = null 177 | if (!state.writeStatusCollapsed && state.authorized) { 178 | const localKeyInput = html` 179 | 180 | ` 181 | localKeyInput.isSameNode = function (target) { 182 | return (target && target.nodeName && target.nodeName === 'INPUT') 183 | } 184 | authForm = html` 185 |
    186 |

    187 | You can share this shopping list to multiple devices or other 188 | people. Just copy the URL and paste it into another browser. 189 | (Hint: You can click 190 | on the "hex number" on the upper right to copy the URL to your 191 | clipboard). Other copies may write to this document if you 192 | authorize them by pasting their 'local key' into the form below. 193 |

    194 |
    e.stopPropagation()}> 195 |
    Add a writer:
    196 | ${localKeyInput} 197 | ${button.submit('Authorize')} 198 |
    199 |
    200 | ` 201 | } 202 | const collapseExpand = state.writeStatusCollapsed 203 | ? raw('▼ Expand') : raw('▲ Collapse') 204 | return html` 205 |
    emit('toggleWriteStatusCollapsed')}> 206 |
    207 | ${collapseExpand} 208 |
    209 |
    ${sourceCopy}
    210 | ${authStatus} 211 | ${authForm} 212 |
    213 | ` 214 | 215 | function copyToClipboard () { 216 | copy(localKey).then(() => { 217 | customAlert.show('"Local Key" copied to clipboard') 218 | state.localKeyCopied = true 219 | emit('render') 220 | }) 221 | } 222 | 223 | function submit (event) { 224 | const input = event.target.querySelector('input') 225 | const writerKey = input.value.trim() 226 | if (writerKey !== '') { 227 | emit('authorize', writerKey) 228 | input.value = '' 229 | } 230 | event.preventDefault() 231 | } 232 | } 233 | 234 | function keydown (event) { 235 | if (event.key === ' ' || event.key === 'Enter') { 236 | event.target.click() 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /EXTRA-NOTES.md: -------------------------------------------------------------------------------- 1 | # Extra Notes 2 | 3 | These were part of an early draft of the blog post, but I cut them out because the post was too long. 4 | 5 | ### Dat Keys 6 | 7 | In the demo, each shopping list is a Dat archive. When a Dat archive is created, it gets a "public key" (also know as just the "key") which consists of a long random string of hexadecimal numbers. It looks something like this: 8 | 9 | `621d7eb5478cabe2597141c40231893dfebd3490bb14b1a38012fdc3f25b9696` 10 | 11 | The key is essentially the "name" used to represent the Dat archive when it's time to share and replicate the data. The data inside the archive is also encrypted, so somebody needs the public key to be able to read what's inside. The key is long enough to be "unguessable" ... it's safe to assume that the only people who can sync and read the data are those who got their hands on the public key. If you want to keep your shopping list secret just to yourself, **don't share that key!** If you want to share a list between friends, just share the key with them only. If you want the whole world to see your shopping list, post the key publicly. 12 | 13 | ### The "gateway" service 14 | 15 | When you create a shopping list, and you are online, the data is automatically synced to a web service that is part of the demo. It is a simple two direction sync between your web browser and the Node.js program that acts as the "gateway" service. When you go offline, syncing stops, and when you go online again, syncing starts again. There is a small status indicator in the upper right to give you some feedback on whether or not you are connected, and if your data has been synced. 16 | 17 | ### Who do you trust? 18 | 19 | It is very important to make sure that you trust the gateway service, as it has a full copy of your data and the public key, so whoever is running the server can read any shopping list synchronized with it. 20 | 21 | If privacy is your concern, it is very easy to re-install the server used in this demo on some hardware that you control -- instructions are in the [README](https://github.com/jimpick/dat-shopping-list/blob/master/README.md) file. 22 | 23 | ### The "swarm" 24 | 25 | Once the data is synced, the gateway server will keep it's copy of the data and make it available for syncing to any other computers on the internet that want it. If somebody else has the "key", they can connect to the gateway server to download the data using the Dat project's "discovery" mechanisms. 26 | 27 | The peer-to-peer networking that goes on when many peers are connecting and sharing data to other peers is called "the swarm", because when there are many peers, the network activity is as busy as a beehive. 28 | 29 | The good news is that the web app doesn't have to drain your cell phone battery doing all that communication. Once the data is synced to the gateway server, the server does all that work on your behalf, even when you are no longer online. 30 | 31 | In order to prevent abuse, the gateway that is used in the demo clears itself out every 24 hours, and has a limit on the number of shopping lists that it will host in the swarm. Nobody will lose data when the gateway resets, as the master copy is in their web browser storage, and they can resync at any time. But don't expect to be able to sync with devices that have been offline for a long time. If you run your own gateway service, you can modify it to "pin" your data in the swarm for a longer period of time. 32 | 33 | A possible future enhancement would be to use a "pinning" service such as [Hashbase](https://hashbase.io/) (commercial) or [Homebase](https://github.com/beakerbrowser/homebase) (self-hosted) to keep synchronized shopping lists alive in the swarm forever. 34 | 35 | ### Replicating a shopping list 36 | 37 | Once you have created a shopping list in one place, and you want to sync it to somewhere else, you have to do a little "cut-and-paste" between devices (or people if you are sharing with somebody else). 38 | 39 | On the device where you created the shopping list, you need to get the "key" of the shopping list. In a web browser, you can simply copy the URL from the browser's location bar, eg: 40 | 41 | [https://dat-shopping-list.glitch.me/doc/621d7eb5478cabe2597141c40231893dfebd3490bb14b1a38012fdc3f25b9696](https://dat-shopping-list.glitch.me/doc/621d7eb5478cabe2597141c40231893dfebd3490bb14b1a38012fdc3f25b9696) 42 | 43 | As it's hard to copy the URL in a mobile browser, and impossible if you have saved the app to your home screen, there is a shortcut you can use. Just tap the "hex number" in the upper right (under the status display) and the URL will be copied to your clipboard. 44 | 45 | You can simple paste the URL you copied into a chat app or a wiki to transfer it privately to your other device, and then open it in a web browser on the other device. 46 | 47 | If you have already saved the app to your home screen, it is impossible to open the URL directly. In that case, you can use the "Have a link? Paste it here" button on the home screen. 48 | 49 | Once you have opened the link, the shopping list will be synced down to your browser. It is also registered in the list of all shopping lists in your browser. 50 | 51 | ## Multiple writers 52 | 53 | When you open a new link from another device or from somebody else, you will see a notice at the top that says "You are not currently authorized to write to this document" in red letters. In this demo, "document", "shopping list" and "multiwriter Dat archive" all mean the same thing. 54 | 55 | Every separate device, browser or user can write their own changes, so they are called "writers". To make things easier to explain, we're going to refer to each instance of the document as a separate writer, even though they might be controlled by the same person, be on the same device in a different browser, etc. 56 | 57 | As a new writer, you can make changes to your local copy of the document, but they won't be automatically synced back to the original writer. 58 | 59 | On the original device that created the document, it is possible to "authorize" new writers, and their changes will be replicated and merged into the "source" document. Once a local key is authorized, you could consider the new writer to be an "owner" of the document, as they can also authorize new writers. 60 | 61 | Each new writer has their own "local key" which represents their changes. In order to get authorized, they must copy this key from their local writable copy and send it back to any writer that is already authorized to write to the document. 62 | 63 | In a user-friendly system, this "key exchange" might be automated in some manner, but for this demo, we wanted to teach the basics. 64 | 65 | As a new writer, if you are unauthorized, you will see your local key on the screen, with a green button to "Copy to Clipboard." Simply send this key to an "owner", and they will paste it into the "Add a writer" input in their shopping list and click "Authorize". This will update the document, and the new writer should see that they are now authorized on their next sync. Any changes that were made by the new writer before they were authorized will be incorporated into the shopping list. 66 | 67 | Currently, there is no mechanism for de-authorizing already authorized writers. 68 | 69 | ## Things to try 70 | 71 | The demo has been tested on the following platforms: 72 | 73 | * Google Chrome 74 | * Firefox 75 | * Apple Safari 76 | * Microsoft Edge 77 | * Mobile Safari on iOS 78 | * Google Chrome on Android 79 | 80 | Some suggested experiments: 81 | 82 | 1. Try creating a shopping list on a web browser on your desktop or laptop, and then open it in a web browser on your phone. 83 | 84 | 2. Try exchanging keys so that both devices can write to the same shopping list. 85 | 86 | 3. On a phone, try the "Add to Home Screen" feature from the web browser. It works differently on Android than iOS. On Android, you will see the same shopping lists in the home screen app as you see in your browser, and you can only save one icon to the home screen. On iOS, you can make multiple icons on your home screen, and each icon will act like a different web browser with separate storage and have it's own independent list of shopping lists. 87 | 88 | 4. After syncing, try putting your phone into 'airplane' mode. The status display should display that the network is offline, and when you make changes to the shopping list, it should display the number of records to sync. If the status display displays "Worker Ready", then that means your platform supports service workers, so you should be able reload the page even when offline. 89 | 90 | 5. Try making some lists and share them with other people. 91 | 92 | 6. Try making a list and putting it on every device and web browser that you have. 93 | 94 | 7. Try using the experimental `dat-next` [command line](https://github.com/joehand/dat-next) tool to download the files from one of your shopping lists to your local file system. 95 | 96 | ## Development Notes 97 | 98 | The demo was primarily developed on [Glitch](https://glitch.com/edit/#!/dat-shopping-list). Glitch is really neat. It gives you a multi-user editing environment, as well as a backing virtual machine so you can run Node.js, as well as one click forking. 99 | 100 | The source code is published on [GitHub](https://github.com/jimpick/dat-shopping-list). The README has some additional information on how to deploy the code to platforms such as Heroku and Zeit, as well as Docker and running the demo using the `npx` tool from npm. 101 | 102 | Presently, multiwriter support hasn't been introduced into all of the Dat project tools, but it is being developed in the "hyperdb-backend" branch of [hyperdrive](https://github.com/mafintosh/hyperdrive/tree/hyperdb-backend). The core of multiwriter support is implemented in the [hyperdb](https://github.com/mafintosh/hyperdb) library. 103 | 104 | Hyperdb is useful standalone, without hyperdrive. Hyperdrive gives you a filesystem abstraction, ideal if you are dealing with large files, whereas hyperdb gives you a key/value store. The dat-shopping-list demo is quite simple and could have easily been implemented using only hyperdb, but I wanted to try out the filesystem capabilities. 105 | 106 | For the client side web framework, I used the [choo](https://github.com/choojs/choo) framework. To generate the service worker, I used [Workbox](https://developers.google.com/web/tools/workbox/) from Google. For bundling, it uses [budo](https://github.com/mattdesl/budo) which is a development server for [browserify](http://browserify.org/) projects. Unfortunately, Glitch has a few filesystem issues, so I couldn't use watch.json with budo, so it's necessary to do a full rebuild after every edit. There is no separate production build for the demo... the development server is the production server. Many other very useful npm modules were used ... you can find the list in the [package.json](https://github.com/jimpick/dat-shopping-list/blob/master/package.json) file. 107 | 108 | I dogfooded the project by keeping my development [task list](https://dat-shopping-list.glitch.me/doc/95fe65d1af31a38b22a31ab31bc7862e80071f8482e17c8aacd18e02842b3f55) as a shopping list - it was nice being able to synchronize between many devices and to be able to check off tasks as they were completed. I even managed to find and squash some corruption issues in hyperdb that popped. I have a list of possible [future features](https://dat-shopping-list.glitch.me/doc/bc14e0054876d561e4890c747ff9d38fe87bcc83a969e2bdb2ce5e4147defe11) in another list. 109 | -------------------------------------------------------------------------------- /stores/shoppingList.js: -------------------------------------------------------------------------------- 1 | const rai = require('random-access-idb') 2 | const toBuffer = require('to-buffer') 3 | // const hyperdrive = require('hyperdrive') 4 | const hyperdrive = require('@jimpick/hyperdrive-next') 5 | const crypto = require('hypercore/lib/crypto') 6 | const newId = require('monotonic-timestamp-base36') 7 | const dumpWriters = require('../lib/dumpWriters') 8 | const downloadZip = require('../lib/downloadZip') 9 | const connectToGateway = require('../lib/websocketGateway') 10 | const customAlert = require('../components/customAlert') 11 | 12 | require('events').prototype._maxListeners = 100 13 | 14 | module.exports = store 15 | 16 | function store (state, emitter) { 17 | state.shoppingList = [] 18 | state.localKeyCopied = false 19 | state.writeStatusCollapsed = window.localStorage.getItem( 20 | 'writeStatusCollapsed' 21 | ) 22 | 23 | emitter.on('DOMContentLoaded', updateDoc) 24 | emitter.on('navigate', updateDoc) 25 | 26 | emitter.on('addLink', link => { 27 | const match = link.match(/([0-9a-fA-F]{64})\/?$/) 28 | if (match) { 29 | const key = match[1] 30 | emitter.emit('pushState', `/doc/${key}`) 31 | } else { 32 | customAlert.show('URL or key must contain a 64 character hex value', () => { 33 | const textInput = document.querySelector('.content input[type="text"]') 34 | textInput.removeAttribute('disabled') 35 | const submitButton = document.querySelector('.content input[type="submit"]') 36 | submitButton.removeAttribute('disabled') 37 | }) 38 | } 39 | }) 40 | 41 | function updateDoc () { 42 | emitter.once('render', () => { 43 | document.body.scrollIntoView(true) 44 | // Do it again for mobile Safari 45 | setTimeout(() => document.body.scrollIntoView(true), 200) 46 | }) 47 | state.error = null 48 | state.authorized = null 49 | state.shoppingList = [] 50 | state.localKeyCopied = false 51 | state.docTitle = '' 52 | if (!state.params || !state.params.key) { 53 | state.archive = null 54 | state.key = null 55 | state.loading = false 56 | emitter.emit('render') 57 | } else { 58 | const keyHex = state.params.key 59 | console.log(`Loading ${keyHex}`) 60 | state.localFeedLength = null 61 | emitter.emit('fetchDocLastSync', keyHex) 62 | const storage = rai(`doc-${keyHex}`) 63 | const archive = hyperdrive(storage, keyHex) 64 | state.loading = true 65 | emitter.emit('render') 66 | archive.ready(() => { 67 | console.log('hyperdrive ready') 68 | console.log('Local key:', archive.db.local.key.toString('hex')) 69 | dumpWriters(archive) 70 | state.archive = archive 71 | state.key = archive.key 72 | if (state.cancelGatewayReplication) state.cancelGatewayReplication() 73 | state.cancelGatewayReplication = connectToGateway( 74 | archive, updateSyncStatus, updateConnecting 75 | ) 76 | readShoppingList() 77 | archive.db.watch(() => { 78 | console.log('Archive updated:', archive.key.toString('hex')) 79 | dumpWriters(archive) 80 | readShoppingList() 81 | }) 82 | }) 83 | } 84 | } 85 | 86 | emitter.on('createDoc', docName => { 87 | const {publicKey: key, secretKey} = crypto.keyPair() 88 | const keyHex = key.toString('hex') 89 | console.log('Create doc:', docName, keyHex) 90 | const storage = rai(`doc-${keyHex}`) 91 | const archive = hyperdrive(storage, key, {secretKey}) 92 | archive.ready(() => { 93 | console.log('hyperdrive ready') 94 | state.key = key 95 | state.archive = archive 96 | let shoppingList = ['Rice', 'Bananas', 'Kale', 'Avocados', 'Bread', 'Quinoa', 'Beer'] 97 | writeDatJson(() => { 98 | writeShoppingListItems(() => { 99 | console.log('Done') 100 | emitter.emit('writeNewDocumentRecord', keyHex, docName) 101 | }) 102 | }) 103 | 104 | function writeDatJson (cb) { 105 | const json = JSON.stringify({ 106 | url: `dat://${keyHex}/`, 107 | title: docName, 108 | description: `Dat Shopping List demo - https://${state.glitchAppName}.glitch.me/` 109 | }, null, 2) 110 | archive.writeFile('dat.json', json, err => { 111 | if (err) throw err 112 | cb() 113 | }) 114 | } 115 | 116 | function writeShoppingListItems (cb) { 117 | const item = shoppingList.shift() 118 | if (!item) return cb() 119 | const json = JSON.stringify({ 120 | name: item, 121 | bought: false, 122 | dateAdded: Date.now() 123 | }) 124 | archive.writeFile(`/shopping-list/${newId()}.json`, json, err => { 125 | if (err) throw err 126 | writeShoppingListItems(cb) 127 | }) 128 | } 129 | }) 130 | }) 131 | 132 | function updateSyncStatus (message) { 133 | const { 134 | key, 135 | connectedPeers, 136 | localUploadLength, 137 | remoteUploadLength, 138 | localDownloadLength, 139 | remoteDownloadLength 140 | } = message 141 | if (state.key && key !== state.key.toString('hex')) return 142 | state.connected = !!connectedPeers 143 | state.localUploadLength = state.loading ? null : localUploadLength 144 | state.localDownloadLength = state.loading ? null : localDownloadLength 145 | if (state.key && connectedPeers) { 146 | state.connecting = false 147 | state.syncedUploadLength = remoteUploadLength 148 | state.syncedDownloadLength = remoteDownloadLength 149 | emitter.emit( 150 | 'updateDocLastSync', 151 | { 152 | key, 153 | syncedUploadLength: remoteUploadLength, 154 | syncedDownloadLength: remoteDownloadLength 155 | } 156 | ) 157 | } 158 | emitter.emit('render') 159 | } 160 | 161 | function updateConnecting (connecting) { 162 | state.connecting = connecting 163 | } 164 | 165 | function readShoppingList () { 166 | const archive = state.archive 167 | const shoppingList = [] 168 | archive.readdir('/shopping-list', (err, fileList) => { 169 | if (err) { 170 | console.log('Error', err) 171 | state.error = 'Error loading shopping list' 172 | emitter.emit('render') 173 | return 174 | } 175 | console.log('Shopping list files:', fileList.length) 176 | readTitleFromDatJson((err, title) => { 177 | if (err) { 178 | console.log('Error', err) 179 | state.error = 'Error loading shopping list' 180 | emitter.emit('render') 181 | return 182 | } 183 | readShoppingListFiles(err => { 184 | if (err) { 185 | console.log('Error', err) 186 | state.error = 'Error loading shopping list' 187 | emitter.emit('render') 188 | return 189 | } 190 | console.log('Done reading files.', title) 191 | updateAuthorized(err => { 192 | if (err) throw err 193 | state.loading = false 194 | state.docTitle = title 195 | state.shoppingList = shoppingList 196 | emitter.emit('writeNewDocumentRecord', state.params.key, title) 197 | emitter.emit('render') 198 | }) 199 | }) 200 | }) 201 | 202 | function readTitleFromDatJson (cb) { 203 | archive.readFile('dat.json', 'utf8', (err, contents) => { 204 | if (err) { 205 | console.error('dat.json error', err) 206 | return cb(null, 'Unknown') 207 | } 208 | if (!contents) return cb(null, 'Unknown') 209 | try { 210 | const metadata = JSON.parse(contents) 211 | cb(null, metadata.title) 212 | } catch (e) { 213 | console.error('Parse error', e) 214 | cb(null, 'Unknown') 215 | } 216 | }) 217 | } 218 | 219 | function readShoppingListFiles (cb) { 220 | const file = fileList.shift() 221 | if (!file) return cb() 222 | archive.readFile(`/shopping-list/${file}`, 'utf8', (err, contents) => { 223 | if (err) return cb(err) 224 | try { 225 | const item = JSON.parse(contents) 226 | item.file = file 227 | shoppingList.push(item) 228 | } catch (e) { 229 | console.error('Parse error', e) 230 | } 231 | readShoppingListFiles(cb) 232 | }) 233 | } 234 | }) 235 | } 236 | 237 | function updateAuthorized (cb) { 238 | if (state.authorized === true) return cb() 239 | const db = state.archive.db 240 | console.log('Checking if local key is authorized') 241 | db.authorized(db.local.key, (err, authorized) => { 242 | if (err) return cb(err) 243 | console.log('Authorized status:', authorized) 244 | if ( 245 | state.authorized === false && 246 | authorized === true && 247 | !state.writeStatusCollapsed 248 | ) { 249 | emitter.emit('toggleWriteStatusCollapsed') 250 | } 251 | state.authorized = authorized 252 | cb() 253 | }) 254 | } 255 | 256 | emitter.on('toggleBought', itemFile => { 257 | const item = state.shoppingList.find(item => item.file === itemFile) 258 | console.log('toggleBought', itemFile, item) 259 | // item.bought = !item.bought 260 | const archive = state.archive 261 | const json = JSON.stringify({ 262 | name: item.name, 263 | bought: !item.bought, 264 | dateAdded: item.dateAdded 265 | }) 266 | archive.writeFile(`/shopping-list/${item.file}`, json, err => { 267 | if (err) throw err 268 | console.log(`Rewrote: ${item.file}`) 269 | }) 270 | }) 271 | 272 | emitter.on('remove', itemFile => { 273 | const item = state.shoppingList.find(item => item.file === itemFile) 274 | console.log('remove', itemFile, item) 275 | // item.bought = !item.bought 276 | const archive = state.archive 277 | archive.unlink(`/shopping-list/${item.file}`, err => { 278 | if (err) throw err 279 | console.log(`Unlinked: ${item.file}`) 280 | }) 281 | }) 282 | 283 | emitter.on('addItem', name => { 284 | console.log('addItem', name) 285 | const archive = state.archive 286 | const json = JSON.stringify({ 287 | name, 288 | bought: false, 289 | dateAdded: Date.now() 290 | }) 291 | const file = newId() + '.json' 292 | archive.writeFile(`/shopping-list/${file}`, json, err => { 293 | if (err) throw err 294 | console.log(`Created: ${file}`) 295 | }) 296 | }) 297 | 298 | emitter.on('authorize', writerKey => { 299 | console.log('authorize', writerKey) 300 | if (!writerKey.match(/^[0-9a-f]{64}$/)) { 301 | customAlert.show('Key must be a 64 character hex value') 302 | return 303 | } 304 | const archive = state.archive 305 | archive.authorize(toBuffer(writerKey, 'hex'), err => { 306 | if (err) { 307 | customAlert.show('Error while authorizing: ' + err.message) 308 | } else { 309 | console.log(`Authorized.`) 310 | customAlert.show('Authorized new writer') 311 | } 312 | emitter.emit('render') 313 | }) 314 | }) 315 | 316 | emitter.on('toggleWriteStatusCollapsed', docName => { 317 | state.writeStatusCollapsed = !state.writeStatusCollapsed 318 | window.localStorage.setItem( 319 | 'writeStatusCollapsed', 320 | state.writeStatusCollapsed 321 | ) 322 | emitter.emit('render') 323 | }) 324 | 325 | emitter.on('downloadZip', () => { 326 | console.log('Download zip') 327 | downloadZip(state.archive) 328 | }) 329 | } 330 | -------------------------------------------------------------------------------- /static/img/bg-landing-page.svg: -------------------------------------------------------------------------------- 1 | bg-landing-page --------------------------------------------------------------------------------