app delivered as a Docker package: frontend and backend
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
upstream node {
|
||||
server node:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
#error_page 404 /404.html;
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://node/;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Note from './Note.js';
|
||||
import {new_note_handler, search_term_change_handler, sorting_change_handler, load_notes, new_bin_handler} from './handlers/App.js';
|
||||
|
||||
|
||||
function App(vnode_init){
|
||||
const {state, dispatch} = vnode_init.attrs;
|
||||
//load_notes(state, dispatch);
|
||||
return {
|
||||
view: function(vnode){
|
||||
const s = vnode.attrs.state;
|
||||
return m('.app', {key: 'app'}, [
|
||||
m('.top', {key: 'top'}, [
|
||||
m('.top-left', {key: 'top-left'}, [
|
||||
m('button', {key: 'button', onclick: new_note_handler.bind(null, s, dispatch)}, 'New Note...'),
|
||||
m('input.search', {key: 'search', value: s.search_term, onkeyup: search_term_change_handler.bind(null, s, dispatch)}),
|
||||
m('select.sorting', {key: 'sorting', value: s.sorting, onchange: sorting_change_handler.bind(null, s, dispatch)}, [
|
||||
m('option', {key: 'new->old', value: 'new->old'}, 'Newest -> Oldest'),
|
||||
m('option', {key: 'old->new', value: 'old->new'}, 'Oldest -> Newest')
|
||||
])
|
||||
]),
|
||||
m('.top-right', {key: 'top-right'}, [
|
||||
m('.bin-id', {key: 'bin-id'}, s.bin.id),
|
||||
m('button', {key: 'new-bin-button', onclick: new_bin_handler.bind(null, s, dispatch)}, 'New Bin...')
|
||||
])
|
||||
]),
|
||||
m('.main', {key: 'main'}, [
|
||||
m('.notes', s.notes.map(note_state =>
|
||||
m(Note, {state, note_state, dispatch, key: note_state.note.id})
|
||||
))
|
||||
])
|
||||
]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,30 @@
|
||||
import {edit_handler, cancel_handler, text_change_handler, save_handler} from './handlers/Note.js';
|
||||
|
||||
function Note(vnode_init){
|
||||
const {dispatch} = vnode_init.attrs;
|
||||
return {
|
||||
view: function(vnode){
|
||||
const {note_state} = vnode.attrs;
|
||||
const {is_editing, is_focused, note} = note_state;
|
||||
if(is_editing){
|
||||
return m('.note', [
|
||||
m('textarea', {key: 'textarea', onchange: text_change_handler.bind(null, note_state, dispatch), oncreate: is_focused?({dom})=>{ dom.focus(); delete note_state.is_focused; }:null }, note_state.temp_text),
|
||||
m('.buttons', {key: 'editing-buttons'}, [
|
||||
m('button', {key: 'cancel-button', onclick: cancel_handler.bind(null, note_state, dispatch) }, 'Cancel'),
|
||||
m('button', {key: 'save-button', onclick: save_handler.bind(null, note_state, dispatch) }, 'Save')
|
||||
])
|
||||
]);
|
||||
}
|
||||
else{
|
||||
return m('.note', [
|
||||
m('.text', {key: 'text'}, note.text),
|
||||
m('.buttons', {key: 'viewing-buttons'}, [
|
||||
m('button', {key: 'edit-button', onclick: edit_handler.bind(null, note_state, dispatch) }, 'Edit')
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Note;
|
||||
@@ -0,0 +1,81 @@
|
||||
import nanoid from './nanoid.min.js';
|
||||
|
||||
const api_stub = {};
|
||||
|
||||
// for mocking persistent state, for use by the fake endpoints, just as a real server endpoint would have DB access for state:
|
||||
const state = {
|
||||
bins: [
|
||||
{
|
||||
id: nanoid(),
|
||||
notes: [
|
||||
{id: nanoid(), text: 'Note one', modified: Date.now()},
|
||||
{id: nanoid(), text: 'Note two', modified: Date.now()},
|
||||
{id: nanoid(), text: 'Note three', modified: Date.now()}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const endpoints = {
|
||||
post: {}
|
||||
};
|
||||
|
||||
endpoints.post['/load-notes'] = function(resolve, reject, body){
|
||||
let i = 0;
|
||||
if(body.bin_id){
|
||||
i = state.bins.findIndex(b=>b.id===body.bin_id);
|
||||
// if there is no such bin, just return a well-formed response, but don't create such a bin server-side yet, until a save
|
||||
if(i === -1){
|
||||
resolve( {status: 'ok', bin:{id:body.bin_id}, notes:[]} );
|
||||
return;
|
||||
}
|
||||
}
|
||||
const bin = state.bins[i];
|
||||
resolve( {status: 'ok', bin:{id:bin.id}, notes: bin.notes} );
|
||||
};
|
||||
|
||||
endpoints.post['/search'] = function(resolve, reject, body){
|
||||
const notes = state.bins.find(b=>b.id===body.bin_id).notes.filter(n => n.text.indexOf(body.search_term) !== -1 )
|
||||
if(body.sorting==='new->old'){
|
||||
notes.sort((a,b) => a.modified-b.modified);
|
||||
}
|
||||
else{
|
||||
notes.sort((a,b) => b.modified-a.modified);
|
||||
}
|
||||
resolve( {status: 'ok', notes} );
|
||||
};
|
||||
|
||||
endpoints.post['/save'] = function(resolve, reject, body){
|
||||
const {note_id, text} = body;
|
||||
let bin = state.bins.find(b=>b.id===body.bin_id);
|
||||
if(!bin){
|
||||
bin = {id: body.bin_id, notes: []};
|
||||
state.bins.push(bin);
|
||||
}
|
||||
const note = bin.notes.find( n => n.id===note_id );
|
||||
if(note){
|
||||
note.text = text;
|
||||
note.modified = Date.now();
|
||||
}
|
||||
else{
|
||||
bin.notes.push({id: note_id, text, modified: Date.now()});
|
||||
}
|
||||
resolve( {status: 'ok'} );
|
||||
};
|
||||
|
||||
|
||||
|
||||
api_stub.post = function(url, body){
|
||||
if(endpoints.post[url]){
|
||||
return new Promise((resolve, reject)=>{
|
||||
endpoints.post[url](resolve, reject, body);
|
||||
});
|
||||
}
|
||||
else{
|
||||
return new Promise((resolve, reject)=>{
|
||||
reject( {error: 'no such endpoint'} );
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default api_stub;
|
||||
@@ -0,0 +1,16 @@
|
||||
// for development, to use api-stub, just import it here and export it at the end;
|
||||
// it's a one-liner that switches from stub to actual api
|
||||
|
||||
const api = {};
|
||||
|
||||
let prefix = "/api";
|
||||
|
||||
api.post = function(url, body){
|
||||
return m.request({
|
||||
method: 'POST',
|
||||
url: prefix+url,
|
||||
body
|
||||
});
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,38 @@
|
||||
import nanoid from '../nanoid.min.js';
|
||||
import api from '../api.js';
|
||||
|
||||
const load_notes = function(state, dispatch){
|
||||
api.post('/load-notes', {bin_id: state.bin.id})
|
||||
.then(res=>{
|
||||
dispatch('notes-loaded', res.notes);
|
||||
});
|
||||
};
|
||||
const runSearch = function(state, dispatch){
|
||||
api.post('/search', {search_term: state.search_term, sorting: state.sorting, bin_id: state.bin.id})
|
||||
.then(res=>{
|
||||
dispatch('update-search-results', res.notes);
|
||||
});
|
||||
};
|
||||
const new_note_handler = function(state, dispatch){
|
||||
dispatch('add-note', {id: nanoid(), date: Date.now()});
|
||||
};
|
||||
const search_term_change_handler = function(state, dispatch, e){
|
||||
if(e.code === 'Enter'){
|
||||
runSearch(state, dispatch);
|
||||
}
|
||||
else{
|
||||
dispatch('update-search-term', e.target.value);
|
||||
}
|
||||
};
|
||||
const sorting_change_handler = function(state, dispatch, e){
|
||||
runSearch(state, dispatch);
|
||||
dispatch('update-sorting', e.target.value);
|
||||
};
|
||||
const new_bin_handler = function(state, dispatch){
|
||||
const id = nanoid();
|
||||
// change browser location in address bar:
|
||||
window.history.pushState(null,'','#'+id);
|
||||
dispatch('new-bin', {id});
|
||||
};
|
||||
|
||||
export {new_note_handler, search_term_change_handler, sorting_change_handler, load_notes, new_bin_handler};
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from '../api.js';
|
||||
|
||||
const edit_handler = function(note_state, dispatch){
|
||||
dispatch('update-note-editing', {id: note_state.note.id, is_editing: true});
|
||||
};
|
||||
const cancel_handler = function(note_state, dispatch){
|
||||
dispatch('update-note-editing', {id: note_state.note.id, is_editing: false});
|
||||
};
|
||||
const text_change_handler = function(note_state, dispatch, e){
|
||||
note_state.temp_text = e.target.value;
|
||||
};
|
||||
const save_handler = function(note_state, dispatch){
|
||||
dispatch('save-note-edit', {id: note_state.note.id, text: note_state.temp_text});
|
||||
api.post('/save', {bin_id: note_state.bin_id, note_id: note_state.note.id, text: note_state.temp_text})
|
||||
};
|
||||
|
||||
export { edit_handler, cancel_handler, text_change_handler, save_handler };
|
||||
@@ -0,0 +1,32 @@
|
||||
import api from '../api.js';
|
||||
|
||||
const load_bin_handler = function(state, dispatch, bin_id){
|
||||
/*
|
||||
api.post('/load-bin', {id: bin_id})
|
||||
.then(res => {
|
||||
dispatch('bin-loaded', {bin:res.bin, _notes:res.notes});
|
||||
});
|
||||
*/
|
||||
api.post('/load-notes', {bin_id})
|
||||
.then(res => {
|
||||
dispatch('notes-loaded', res.notes);
|
||||
});
|
||||
};
|
||||
|
||||
const hash_change_handler = function(state, dispatch, e){
|
||||
// get bin id from URL
|
||||
let bin_id = window.location.hash.substring(1); // extract the leading '#'
|
||||
if(bin_id === ''){
|
||||
const old_bin_id = state.bin.id;
|
||||
window.history.replaceState(null,'', '#'+old_bin_id);
|
||||
}
|
||||
else{
|
||||
dispatch('bin-requested', bin_id);
|
||||
api.post('/load-notes', {bin_id})
|
||||
.then(res => {
|
||||
dispatch('notes-loaded', res.notes);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export {load_bin_handler, hash_change_handler};
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
/* begin full-page app */
|
||||
html, body{
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
body{
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* end full-page app */
|
||||
|
||||
.app{
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0.4rem;
|
||||
background-color: #AAAACC;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app > .top{
|
||||
height: auto;
|
||||
margin-bottom: 0.1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app > .main{
|
||||
|
||||
}
|
||||
.app > .main > .notes{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note{
|
||||
width: 17rem;
|
||||
border: 1px solid black;
|
||||
border-radius: 1rem;
|
||||
background-color: #aaccaa;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Tabler</title>
|
||||
<!--<link rel="stylesheet" href="tachyons.min.css"></link>-->
|
||||
<link rel="stylesheet" href="index.css"></link>
|
||||
<!--<link rel="stylesheet" href="mini-default.min.css"></link>-->
|
||||
<!--<link rel="stylesheet" href="animate.min.css"></link>-->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="./mithril.min.js"></script>
|
||||
<script src="./redux.min.js"></script>
|
||||
<script src="./immer.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
import App from './App.js';
|
||||
import nanoid from './nanoid.min.js';
|
||||
import {load_bin_handler, hash_change_handler} from './handlers/index.js';
|
||||
const produce = immer.produce;
|
||||
immer.setAutoFreeze(false); // needed for high-frequency updated values, like onkeyup->note.temp_text; only once 'save' is called will it produce a new immutable state tree
|
||||
|
||||
var root = document.body;
|
||||
|
||||
/* Ruthlessly taken from [https://gist.github.com/kitze/fb65f527803a93fb2803ce79a792fff8]: */
|
||||
const handleActions = (actionsMap, defaultState) =>
|
||||
(state=defaultState, {type, payload}) =>
|
||||
produce(state, draft => {
|
||||
const action = actionsMap[type];
|
||||
action && action(draft, payload);
|
||||
});
|
||||
|
||||
// get bin id from URL, and load notes from that bin; if a bin id isn't specified in the URL, create a new one and update the URL
|
||||
let bin_id = window.location.hash.substring(1); // extract the leading '#'
|
||||
if(bin_id === ''){
|
||||
bin_id = nanoid();
|
||||
window.history.replaceState(null,'', '#'+bin_id);
|
||||
}
|
||||
// the actual loading from server is done later, after store and dispatch are defined
|
||||
|
||||
const reducer = handleActions({
|
||||
'new-bin': (s, bin) => { s.bin=bin; s.notes=[]; },
|
||||
'bin-requested': (s, bin_id) => { s.bin={id:bin_id}; s.notes = []; },
|
||||
'bin-loaded': (s, {bin, _notes}) => { s.bin=bin; s.notes=_notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: bin.id, note:n})); },
|
||||
'update-search-term': (s, search_term) => { s.search_term=search_term; },
|
||||
'update-search-results': (s, _notes) => { s.notes =_notes.map(n=>({is_editing: false, temp_text: '', bin_id: s.bin.id, note:n})); },
|
||||
'add-note': (s, {id, date}) => { s.notes.unshift({is_editing: true, temp_text: '', bin_id: s.bin.id, is_focused:true, note: {id: id, text: '', modified: date}}); },
|
||||
'notes-loaded': (s, _notes) => { s.notes = _notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: s.bin_id, note:n})); },
|
||||
'update-note-text': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note.id===id); note_s.note.text=text; }, // updates underlying note text (i.e. the "model", not the app note_state) "in the background" (e.g. from a server-pushed update), regardless of whether it's being edited; "save" is a separate action, below
|
||||
'update-note-editing': (s, {id, is_editing}) => { const note_s=s.notes.find(n=>n.note.id===id); note_s.is_editing=is_editing; note_s.temp_text=note_s.note.text; },
|
||||
'save-note-edit': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note.id===id); note_s.note.text=text; note_s.temp_text=text; note_s.is_editing=false; },
|
||||
'update-sorting': (s, sorting) => { s.sorting=sorting; }
|
||||
}, {
|
||||
bin: {id: bin_id},
|
||||
notes: [
|
||||
//{is_editing: false, temp_text: '', bin_id: '', note: {id: nanoid(), text: 'Note one', modified: 1}},
|
||||
],
|
||||
search_term: '',
|
||||
sorting: 'new->old'
|
||||
//search_result_notes: []
|
||||
});
|
||||
|
||||
// create Redux store, with Redux DevTools enabled:
|
||||
const store = Redux.createStore(reducer, /* preloadedState, */
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__());
|
||||
const dispatch = function(action_type, payload){ store.dispatch({type:action_type, payload}); };
|
||||
|
||||
store.subscribe(()=>{
|
||||
m.render(root, m(App, {state: store.getState(), dispatch}));
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", (e)=>hash_change_handler(store.getState(), dispatch, e), false);
|
||||
|
||||
let state = store.getState();
|
||||
load_bin_handler(state, dispatch, bin_id);
|
||||
|
||||
// we don't want Mithril auto-redraw system in place, since Redux will manually re-render when necessary with store.subscribe():
|
||||
// m.mount(root, App);
|
||||
m.render(root, m(App, {state, dispatch}))
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default (t=21)=>{let e="",r=crypto.getRandomValues(new Uint8Array(t));for(;t--;){let n=63&r[t];e+=n<36?n.toString(36):n<62?(n-26).toString(36).toUpperCase():n<63?"_":"-"}return e};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user