Browse Source

hey, shopping list!

master
Martin Dørum 4 years ago
commit
345c27e75c

+ 2
- 0
client/.gitignore View File

@@ -0,0 +1,2 @@
node_modules
public/bundle.*

+ 68
- 0
client/README.md View File

@@ -0,0 +1,68 @@
*Psst — looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)*

---

# svelte app

This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.

To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):

```bash
npx degit sveltejs/template svelte-app
cd svelte-app
```

*Note that you will need to have [Node.js](https://nodejs.org) installed.*


## Get started

Install the dependencies...

```bash
cd svelte-app
npm install
```

...then start [Rollup](https://rollupjs.org):

```bash
npm run dev
```

Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.


## Deploying to the web

### With [now](https://zeit.co/now)

Install `now` if you haven't already:

```bash
npm install -g now
```

Then, from within your project folder:

```bash
now
```

As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon.

### With [surge](https://surge.sh/)

Install `surge` if you haven't already:

```bash
npm install -g surge
```

Then, from within your project folder:

```bash
npm run build
surge public
```

+ 2650
- 0
client/package-lock.json
File diff suppressed because it is too large
View File


+ 25
- 0
client/package.json View File

@@ -0,0 +1,25 @@
{
"name": "svelte-app",
"version": "1.0.0",
"devDependencies": {
"dev-refresh": "file:../../../../dev/dev-refresh",
"npm-run-all": "^4.1.5",
"rollup": "^1.10.1",
"rollup-plugin-commonjs": "^9.3.4",
"rollup-plugin-livereload": "^1.0.0",
"rollup-plugin-node-resolve": "^4.2.3",
"rollup-plugin-svelte": "^5.0.3",
"rollup-plugin-terser": "^4.0.4",
"svelte": "^3.0.0"
},
"dependencies": {
"sirv-cli": "^0.4.4"
},
"scripts": {
"build": "NODE_ENV=production rollup -c",
"build:dev": "NODE_ENV=development rollup -c",
"dev": "dev-refresh src --port 8080 --proxy 'http://localhost:8081' --cmd 'npm run build:dev' -n",
"start": "sirv public --single",
"start:dev": "sirv public --single --dev"
}
}

BIN
client/public/favicon.png View File


+ 8
- 0
client/public/global.css View File

@@ -0,0 +1,8 @@
html, body {
margin: 0px;
height: 100%;
}

* {
box-sizing: border-box;
}

+ 8
- 0
client/public/icon-check.svg View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="8px" height="8px" viewBox="0 0 8 8" enable-background="new 0 0 8 8" xml:space="preserve">
<rect x="-0.013" y="4.258" transform="matrix(-0.707 -0.7072 0.7072 -0.707 0.0891 10.1702)" width="4.33" height="1.618"/>
<rect x="2.227" y="2.899" transform="matrix(-0.7071 0.7071 -0.7071 -0.7071 11.6877 2.6833)" width="6.121" height="1.726"/>
</svg>

+ 17
- 0
client/public/index.html View File

@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset='utf8'>
<meta name='viewport' content='width=device-width'>

<title>Shopping List</title>

<link rel='icon' type='image/png' href='/favicon.png'>
<link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/bundle.css'>
</head>

<body>
<script src='/bundle.js'></script>
</body>
</html>

+ 39
- 0
client/rollup.config.js View File

@@ -0,0 +1,39 @@
import svelte from 'rollup-plugin-svelte';
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import { terser } from 'rollup-plugin-terser';

const production = !process.env.NODE_ENV || process.env.NODE_ENV == "production";

export default {
input: 'src/main.js',
output: {
sourcemap: !production,
format: 'iife',
name: 'app',
file: 'public/bundle.js'
},
plugins: [
svelte({
// enable run-time checks when not in production
dev: !production,
// we'll extract any component CSS out into
// a separate file — better for performance
css: css => {
css.write('public/bundle.css');
}
}),

// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve({ browser: true }),
commonjs(),

// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
};

+ 134
- 0
client/src/App.svelte View File

@@ -0,0 +1,134 @@
<script>
import ListItem from './ListItem.svelte';
import ListInput from './ListInput.svelte';
import LoadingIcon from './LoadingIcon.svelte';
import { fade } from 'svelte/transition';
export let wsock;

let words = {};
let items = {};
let pendingItems = [];
let sortedItems = [];
let connected = false;

function sortItems() {
let pending = [];
for (let i in pendingItems)
pending.push(pendingItems[i]);
pending.sort((a, b) => b.index - a.index);

let rest = [];
for (let i in items)
rest.push(items[i]);
rest.sort((a, b) => b.index - a.index);

sortedItems = [];
for (let item of pending)
sortedItems.push(item);
for (let item of rest)
sortedItems.push(item);
}

function onAdd(evt) {
let content = evt.detail;
let item = { pending: true, content, index: pendingItems.length };
pendingItems.push(item);
sortItems();

wsock.send({ type: "item-add", content }).then(({ index }) => {
delete pendingItems[item.index];

item.index = index;
items[index] = item;

if (words[content] == null)
words[content] = 0;
words[content] += 1;

sortItems();
setTimeout(() => { item.pending = false; sortedItems = sortedItems; }, 50);
});
}

function onRemove(evt) {
let item = evt.detail;
if (item.pending)
return;

item.pending = true;
sortedItems = sortedItems;

wsock.send({ type: "item-del", index: item.index }).then(() => {
delete items[item.index];
sortItems();
});
}

export function onInitialData(data) {
words = data.words;

items = {};
for (let i in data.items)
items[i] = { pending: false, content: data.items[i], index: i };
sortItems();

connected = true;
}

export function onDisconnect() {
connected = false;
}

export function onAddFromServer(index, content) {
let item = { pending: true, content, index };
items[index] = item;
sortItems();
setTimeout(() => { item.pending = false; sortedItems = sortedItems; }, 10);
}

export function onRemoveFromServer(index) {
delete items[index];
sortItems();
}
</script>

<style>
.container {
position: relative;
margin: auto;
text-align: center;
width: 100%;
max-width: 500px;
height: 100%;
}

.items {
margin-top: 24px;
}

.loading {
display: flex;
width: 100%;
height: 100%;
position: absolute;
top: 0px;
left: 0px;
justify-content: center;
padding-top: 25vh;
background: rgba(0, 0, 0, 0.7);
}
</style>

<div class="container">
<ListInput {words} on:add={onAdd} />

<div class="items">
{#each sortedItems as item (item)}
<ListItem {item} on:remove={onRemove} />
{/each}
</div>
</div>

{#if !connected}
<div out:fade={{duration: 100}} class="loading"><LoadingIcon /></div>
{/if}

+ 123
- 0
client/src/ListInput.svelte View File

@@ -0,0 +1,123 @@
<script>
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
export let words;

let dispatch = createEventDispatcher();
let inputEl;
let hidden = true;
let wordList = [];

function updateWords() {
wordList = [];
for (let content in words) {
if (content.includes(inputEl.value))
wordList.push({ content, score: words[content] });
}
wordList.sort((a, b) => b.score - a.score);
}

function onBlur() {
setTimeout(() => hidden = true, 50);
}

function onFocus() {
updateWords();
hidden = false;
}

function onSubmit(evt) {
let val = inputEl.value.toLowerCase();
if (!val)
return;

inputEl.value = "";
dispatch("add", val);
}

function add(content) {
inputEl.value = "";
dispatch("add", content.toLowerCase());
}

function capitalize(str) {
var first = str[0].toUpperCase();
return first + str.substring(1);
}
</script>

<style>
.add {
padding-top: 12px;
}
.add .content,
.add .submit {
box-sizing: content-box;
-webkit-appearance: none;
font-size: 16px;
border: 1px solid #aaa;
border-radius: 5px;
background-color: #fff;
height: 20px;
padding: 6px;
line-height: 0px;
}
.add .content {
width: 78%;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.add .submit {
width: 8%;
background-color: #eee;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.add .submit:hover {
background-color: #ddd;
}
.add .suggestions {
z-index: 2;
background: white;
box-shadow: 0px 0px 10px 1px #ccc;
position: absolute;
width: 90%;
margin: auto;
left: 0px;
right: 0px;
margin-top: 15px;
padding-top: 6px;
padding-bottom: 6px;

max-height: 70%;
overflow-y: auto;
overflow-x: hidden;
}
.add .suggestions .suggestion:last-child {
border-bottom: none;
}
.add .suggestions .suggestion {
padding: 12px;
cursor: pointer;
border-bottom: 1px solid #ccc;
text-overflow: ellipsis;
max-width: 100%;
overflow: hidden;
}
</style>

<form on:submit|preventDefault={onSubmit} class="add">
<input
class="content" name="content" autocomplete="off"
on:focus={onFocus} on:blur={onBlur} on:keydown={() => window.setTimeout(updateWords, 10)} bind:this={inputEl}>
<button class="submit" type="submit">+</button>
{#if !hidden}
<div transition:fade={{duration: 50}} class="suggestions">
{#each wordList as word}
<div class="suggestion" on:click={() => add(word.content)}>
{capitalize(word.content)}
</div>
{/each}
</div>
{/if}
</form>

+ 108
- 0
client/src/ListItem.svelte View File

@@ -0,0 +1,108 @@
<script>
import { createEventDispatcher } from 'svelte';
export let item;

let dispatch = createEventDispatcher();
let emitRemove = () => dispatch("remove", item);

function transIn(node) {
if (!item.pending) return { delay: 0, duration: 0 };

let comp = getComputedStyle(node);
let height = parseInt(comp.height);
let paddingTop = parseInt(comp["padding-top"]);

return {
delay: 0,
duration: 200,
css: t => `
overflow: hidden;
height: ${t * height}px;
padding-top: ${t * paddingTop}px;`
};
}

function transOut(node) {
let comp = getComputedStyle(node);
let opacity = parseFloat(comp.opacity);
let height = parseInt(comp.height);
let paddingTop = parseInt(comp["padding-top"]);

function part(t, from, to) {
return Math.max((t - from), 0) / (to - from);
}

return {
delay: 0,
duration: 200,
css: t => `
overflow: hidden;
height: ${part(t, 0.3, 1) * height}px;
padding-top: ${part(t, 0.3, 1) * paddingTop}px;
transform: translateX(-${(1 - t) * 150}px);
opacity: ${part(t, 0, 0.7) * opacity}`
};
}

function capitalize(str) {
var first = str[0].toUpperCase();
return first + str.substring(1);
}
</script>

<style>
.item {
transition: background-color 0.3s;
padding-left: 12px;
padding-top: 42px;
position: relative;
margin: auto;
text-align: left;
width: 100%;
height: 70px;
overflow: hidden;
border-bottom: 1px solid #ccc;
}

.item.pending {
background-color: #eee;
}

.item .name {
position: relative;
display: inline-block;
max-width: calc(100% - 60px);
overflow: hidden;
text-overflow: ellipsis;
}

.item .ok {
position: relative;
display: inline-block;
float: right;
margin-right: 6px;
height: 50px;
width: 50px;
bottom: 33px;

background-image: url(/icon-check.svg);
background-size: 24px 24px;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;

border-radius: 100px;
background-color: #eee;
}

.item .ok:hover {
background-color: #ddd;
}
</style>

<div in:transIn out:transOut class={`item ${item.pending ? 'pending' : ''}`}>
<div class="name">{capitalize(item.content)}</div>
{#if !item.pending}
<span class="ok" on:click={emitRemove}></span>
{/if}
</div>

+ 88
- 0
client/src/LoadingIcon.svelte View File

@@ -0,0 +1,88 @@
<style>
.lds-default {
display: inline-block;
position: relative;
width: 64px;
height: 64px;
}
.lds-default div {
position: absolute;
width: 5px;
height: 5px;
background: #fff;
border-radius: 50%;
animation: lds-default 1.2s linear infinite;
}
.lds-default div:nth-child(1) {
animation-delay: 0s;
top: 29px;
left: 53px;
}
.lds-default div:nth-child(2) {
animation-delay: -0.1s;
top: 18px;
left: 50px;
}
.lds-default div:nth-child(3) {
animation-delay: -0.2s;
top: 9px;
left: 41px;
}
.lds-default div:nth-child(4) {
animation-delay: -0.3s;
top: 6px;
left: 29px;
}
.lds-default div:nth-child(5) {
animation-delay: -0.4s;
top: 9px;
left: 18px;
}
.lds-default div:nth-child(6) {
animation-delay: -0.5s;
top: 18px;
left: 9px;
}
.lds-default div:nth-child(7) {
animation-delay: -0.6s;
top: 29px;
left: 6px;
}
.lds-default div:nth-child(8) {
animation-delay: -0.7s;
top: 41px;
left: 9px;
}
.lds-default div:nth-child(9) {
animation-delay: -0.8s;
top: 50px;
left: 18px;
}
.lds-default div:nth-child(10) {
animation-delay: -0.9s;
top: 53px;
left: 29px;
}
.lds-default div:nth-child(11) {
animation-delay: -1s;
top: 50px;
left: 41px;
}
.lds-default div:nth-child(12) {
animation-delay: -1.1s;
top: 41px;
left: 50px;
}
@keyframes lds-default {
0%, 20%, 80%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.5);
}
}
</style>

<div class="lds-default">
<div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>

+ 46
- 0
client/src/main.js View File

@@ -0,0 +1,46 @@
import App from './App.svelte';
import WSockMan from './ws.js';

let wsock = new WSockMan(
`${location.protocol == "http:" ? "ws:" : "wss:"}//${location.host}/`);

let app = new App({ target: document.body, props: { wsock }});

let key = localStorage.getItem("key");
if (!key)
key = prompt("Key?");

function auth(k) {
wsock.send({ type: "init", key: k })
.then(res => {
localStorage.setItem("key", key);
key = k;
app.onInitialData(res.data);
})
.catch(err => {
console.trace(err);
let k = prompt(err);
if (k != null)
auth(k);
});
};

wsock.onconnect = () => auth(key);
wsock.ondisconnect = app.onDisconnect.bind(app);

wsock.onmessage = msg => {
switch (msg.type) {
case "item-del":
app.onRemoveFromServer(msg.index);
break;

case "item-add":
app.onAddFromServer(msg.index, msg.content);
break;

default:
console.warn("Unknown message type", msg.type);
}
};

export default app;

+ 83
- 0
client/src/ws.js View File

@@ -0,0 +1,83 @@
export default class WSockMan {
constructor(url) {
this.ready = false;
this.sock = null;
this.url = url;
this.sendQ = [];
this.ackQ = [];
this.ackID = 0;

this.ondisconnect = () => {};
this.onconnect = () => {};
this.onmessage = () => {};

this.createWS();
}

send(obj, cb) {
return new Promise((resolve, reject) => {
if (this.ready) {
return this.reallySend(obj, resolve, reject);
} else {
this.sendQ.push([obj, resolve, reject]);
}
});
}

createWS() {
if (this.sock)
this.sock.close();
this.sock = new WebSocket(this.url);

this.sock.onopen = () => {
this.ready = true;
this.ackID = 0;

for (let i = 0; i < this.sendQ.length; ++i)
this.reallySend(...this.sendQ[i]);
this.sendQ = [];

this.onconnect();
};

this.sock.onclose = () => {
console.error("Connection closed.");
this.ready = false;
this.sock = null;

for (let i = 0; i < this.ackQ.length; ++i) {
let [ resolve, reject ] = this.ackQ[i];
reject("Lost connection.");
}
this.ackQ = [];

this.ondisconnect();

setTimeout(() => this.createWS(), 1000);
};

this.sock.onerror = evt => {
console.error("WebSocket error:", evt);
};

this.sock.onmessage = evt => {
let obj = JSON.parse(evt.data);
if (obj.type == "ack") {
let [ resolve, reject ] = this.ackQ[obj.ackID];
if (obj.error)
reject(obj.error);
else
resolve(obj);
} else {
this.onmessage(obj);
}
};
}

reallySend(obj, resolve, reject) {
let ackID = this.ackID++;
this.ackQ[ackID] = [ resolve, reject ];
obj.ackID = ackID;
this.sock.send(JSON.stringify(obj));
}
}

+ 2
- 0
server/.gitignore View File

@@ -0,0 +1,2 @@
db.json
node_modules

+ 65
- 0
server/filesrv.js View File

@@ -0,0 +1,65 @@
let http = require("http");
let pathlib = require("path");
let fs = require("fs");

module.exports = function createFileServer(webroot) {
let server = http.createServer((req, res) => {
console.log(req.method+" "+req.url);
if (req.method != "HEAD" && req.method != "GET") {
req.writeHead(405);
req.end(`Unexpected method: ${req.method}`);
return;
}

let path = pathlib.normalize(pathlib.join(webroot, req.url));
if (!path.startsWith(webroot)) {
res.writeHead(404);
res.end(`404 Not Found: ${req.url}`);
return;
}

(function read(path) {
let rs = fs.createReadStream(path);

rs.once("error", err => {
if (err.code == "ENOENT") {
res.writeHead(404);
res.end(`404 Not Found: ${req.url}`);
} else if (err.code == "EISDIR") {
if (!req.url.endsWith("/")) {
res.writeHead(302, {
location: `${req.url}/`,
});
res.end(`302 Found: ${req.url}/`);
} else {
read(path+"/index.html");
}
} else {
console.warn(`Failed to open ${path}`);
console.trace(err);
res.writeHead(500);
res.end("500 Internal Server Error");
}
});

rs.once("open", () => {
let ctype = null;
if (path.endsWith(".html"))
ctype = "text/html";
else if (path.endsWith(".js"))
ctype = "application/javascript";
else if (path.endsWith(".css"))
ctype = "text/css";

if (ctype)
res.writeHead(200, { "Content-Type": ctype });
else
res.writeHead(200);

rs.pipe(res);
});
})(path);
});

return server;
}

+ 33
- 0
server/package-lock.json View File

@@ -0,0 +1,33 @@
{
"name": "server",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"async-limiter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz",
"integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg=="
},
"dev-refresh": {
"version": "file:../../../../dev/dev-refresh",
"dev": true,
"requires": {
"colors": "^1.3.3",
"minimist": "^1.2.0",
"node-watch": "^0.6.2",
"open": "^6.4.0",
"webframe": "^0.9.0",
"ws": "^7.1.0"
}
},
"ws": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.0.1.tgz",
"integrity": "sha512-ILHfMbuqLJvnSgYXLgy4kMntroJpe8hT41dOVWM8bxRuw6TK4mgMp9VJUNsZTEc5Bh+Mbs0DJT4M0N+wBG9l9A==",
"requires": {
"async-limiter": "^1.0.0"
}
}
}
}

+ 18
- 0
server/package.json View File

@@ -0,0 +1,18 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js'",
"dev": "dev-refresh *.js --cmd 'KEY=test PORT=8081 node server.js'"
},
"author": "",
"license": "ISC",
"dependencies": {
"ws": "^7.0.1"
},
"devDependencies": {
"dev-refresh": "file:../../../../dev/dev-refresh"
}
}

+ 129
- 0
server/server.js View File

@@ -0,0 +1,129 @@
let WebSocket = require("ws");
let createFileServer = require("./filesrv");
let fs = require("fs");

let datafile = "db.json";
let scratchfile = ".db.scratch";

let key = process.env.KEY;
if (!key) {
console.log("Environmnt variable 'KEY' not set.");
process.exit(1);
}

let server = createFileServer("../client/public");
let wss = new WebSocket.Server({ server });

function deserialize(defaults) {
try {
let json = fs.readFileSync(datafile);
return JSON.parse(json);
} catch (err) {
return defaults;
}
}

function serialize(data) {
fs.writeFileSync(scratchfile, JSON.stringify(data));
fs.renameSync(scratchfile, datafile);
}

let data = deserialize({
items: { "0": "foo", "2": "bar" },
words: {},
nextIdx: 3,
});

let conns = [];
let connID = 0;

function ack(conn, msg, reply = {}) {
reply.type = "ack";
reply.ackID = msg.ackID;

conn.sock.send(JSON.stringify(reply));
}

function broadcast(from, obj) {
for (let conn of conns) {
if (!conn) continue;
if (conn != from)
conn.sock.send(JSON.stringify(obj));
}
}

function onMessage(conn, msg) {
if (msg.type == "init") {
if (!msg.key || msg.key != key)
return ack(conn, msg, { error: "Invalid key." });

ack(conn, msg, { data });
conn.ready = true;
conns[conn.id] = conn;
return;
}

if (!conn.ready)
return ack(conn, msg, { error: "Not initialized." });

switch (msg.type) {
case "item-add":
if (typeof msg.content != "string")
return ack(conn, msg, { error: "Missing content." });

let content = msg.content.toLowerCase();
let index = data.nextIdx.toString();
data.nextIdx = (data.nextIdx + 1) % 2048;

data.items[index] = content;

if (data.words[content] == null)
data.words[content] = 0;
data.words[content] += 1;

ack(conn, msg, { index });
broadcast(conn, { type: "item-add", content, index });
serialize(data);
break;

case "item-del":
if (typeof msg.index != "string")
return ack(conn, msg, { error: "Missing index." });

delete data.items[msg.index];
ack(conn, msg);
broadcast(conn, { type: "item-del", index: msg.index });
serialize(data);
break;
}
}

wss.on("connection", sock => {
let conn = {
sock,
ready: false,
id: connID,
};
connID = (connID + 1) % 2048;

sock.on("close", () => {
delete conns[conn.id];
});

sock.on("message", msg => {
let obj;
try {
obj = JSON.parse(msg);
} catch (err) {
console.error("Client message has invalid JSON:", err);
console.error(err);
return;
}

onMessage(conn, obj);
});
});

let port = process.env.PORT ? parseInt(process.env.PORT) : 8080;
server.listen(port);
console.log(`Listening on port ${port}.`);

Loading…
Cancel
Save