Compare commits
No commits in common. 'master' and 'readme' have entirely different histories.
@ -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,11 @@
|
|||||||
|
const api = {};
|
||||||
|
|
||||||
|
api.post = function(url, body){
|
||||||
|
return m.request({
|
||||||
|
method: 'POST',
|
||||||
|
url,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
@ -1,43 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
|
||||||
nginx:
|
|
||||||
image: 'nginx:1.19.6-alpine'
|
|
||||||
working_dir: "/usr/share/nginx/html"
|
|
||||||
volumes:
|
|
||||||
# individual files require full path:
|
|
||||||
- ${PWD}/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
- ${PWD}/nginx/public_html:/usr/share/nginx/html
|
|
||||||
ports:
|
|
||||||
- "3001:80"
|
|
||||||
depends_on:
|
|
||||||
- node
|
|
||||||
node:
|
|
||||||
build:
|
|
||||||
context: ./node/
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
# production: no reason to use pm2 if docker offers auto-restart
|
|
||||||
# development: don't use docker auto-restart; rather pm2, which can watch for file changes
|
|
||||||
#restart: always
|
|
||||||
user: "node"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=development
|
|
||||||
volumes:
|
|
||||||
- ./node/:/home/node/app
|
|
||||||
expose:
|
|
||||||
- "80"
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
working_dir: /home/node/app
|
|
||||||
# for production (no file watching):
|
|
||||||
# command: sh -c "yarn install && node server.js"
|
|
||||||
# for development (file watching/reloading):
|
|
||||||
command: sh -c "yarn install && yarn pm2-dev server.js"
|
|
||||||
postgres:
|
|
||||||
image: "postgres:13.1-alpine"
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=pastebin
|
|
||||||
- POSTGRES_PASSWORD=buginoo
|
|
||||||
volumes:
|
|
||||||
- ./postgres/pgdata:/var/lib/postgresql/data
|
|
||||||
expose:
|
|
||||||
- "5432"
|
|
@ -0,0 +1,39 @@
|
|||||||
|
import nanoid from '../nanoid.min.js';
|
||||||
|
//import api from '../api.js';
|
||||||
|
import api from '../api-stub.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,18 @@
|
|||||||
|
//import api from '../api.js';
|
||||||
|
import api from '../api-stub.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-stub.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};
|
@ -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,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}))
|
@ -1,27 +0,0 @@
|
|||||||
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/;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
|||||||
import Note from './Note.js';
|
|
||||||
import {
|
|
||||||
preventDblClickSelection,
|
|
||||||
new_note_handler,
|
|
||||||
new_note_by_dblclick_handler,
|
|
||||||
search_term_change_handler,
|
|
||||||
sorting_change_handler,
|
|
||||||
load_notes,
|
|
||||||
new_bin_handler,
|
|
||||||
bin_name_change_handler,
|
|
||||||
bin_name_editing_toggle_button_handler,
|
|
||||||
bin_name_commit_handler,
|
|
||||||
login_request_handler,
|
|
||||||
logout_request_handler,
|
|
||||||
username_change_handler,
|
|
||||||
password_change_handler,
|
|
||||||
choose_bin_handler
|
|
||||||
} from './handlers/App.js';
|
|
||||||
|
|
||||||
|
|
||||||
function App(vnode_init){
|
|
||||||
const {dispatch} = vnode_init.attrs;
|
|
||||||
//load_notes(state, dispatch);
|
|
||||||
return {
|
|
||||||
view: function(vnode){
|
|
||||||
const s = vnode.attrs.state;
|
|
||||||
const o = function(handler){ return handler.bind(null, s, dispatch); }
|
|
||||||
const o1 = function(handler, p1){ return handler.bind(null, s, dispatch, p1); }
|
|
||||||
return m('.app', {key: 'app'}, [
|
|
||||||
m('.error-message', {key:'error-message'}, s.error_message),
|
|
||||||
m('.top', {key: 'top'}, [
|
|
||||||
m('.top-left', {key: 'top-left'}, [
|
|
||||||
m('button', {key: 'button', onclick: o(new_note_handler)}, 'New Note...'),
|
|
||||||
m('input.search', {key: 'search', value: s.search_term, onkeyup: o(search_term_change_handler)}),
|
|
||||||
m('select.sorting', {key: 'sorting', value: s.sorting, onchange: o(sorting_change_handler)}, [
|
|
||||||
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'}, [
|
|
||||||
(s.login.showing
|
|
||||||
? (s.login.is_logged_in
|
|
||||||
? m('.login-info', {key:'login-info'}, [
|
|
||||||
m('.text', 'Logged in as '+s.db[s.login.user_id].model.username),
|
|
||||||
m('button', {onclick: o(logout_request_handler)}, 'Logout')
|
|
||||||
])
|
|
||||||
: m('.login-form', {key:'login-form'}, [
|
|
||||||
m('input.username', {key:'username', value: s.login.username, onchange: o(username_change_handler)}),
|
|
||||||
m('input.password[type=password]', {key:'password', value: s.login.password, onchange: o(password_change_handler)}),
|
|
||||||
m('button', {key:'login-button', onclick: o(login_request_handler)}, 'Login/Auto-Register')
|
|
||||||
])
|
|
||||||
)
|
|
||||||
: m('.empty', {key:'empty'})
|
|
||||||
),
|
|
||||||
m('.buttons', {key:'buttons'}, [
|
|
||||||
(s.is_editing_bin_name
|
|
||||||
? m('input.bin-name-textbox', {key: 'bin-name-textbox', value: s.temp_bin_name, onchange: o(bin_name_change_handler)})
|
|
||||||
: m('.bin-name', {key: 'bin-name'}, s.db[s.bin_id].model.name)
|
|
||||||
),
|
|
||||||
m('button', {key: 'new-bin-button', onclick: o(new_bin_handler)}, 'New Bin...'),
|
|
||||||
(s.is_editing_bin_name
|
|
||||||
? m('button', {key:'save-bin-name-button', onclick: o(bin_name_commit_handler)}, 'Save')
|
|
||||||
: m('button', {key:'rename-bin-button', onclick: o(bin_name_editing_toggle_button_handler)}, 'Rename')
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
m('ul.bin-list', {key:'bin-list'}, s.login.bin_ids.map(bin_id=>
|
|
||||||
m('li.bin', {key:bin_id, onclick:o1(choose_bin_handler, bin_id)}, s.db[bin_id].model.name)
|
|
||||||
))
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
m('.main', {key: 'main'}, [
|
|
||||||
m('.notes', {ondblclick: o(new_note_by_dblclick_handler), onmousedown:preventDblClickSelection}, s.notes.map(note_state =>
|
|
||||||
m(Note, {state:s, note_state, dispatch, key: note_state.note_id})
|
|
||||||
))
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,24 +0,0 @@
|
|||||||
// 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 = {};
|
|
||||||
|
|
||||||
// not `/api` (relative to domain root) nor `//api` (results in https:///api/...)
|
|
||||||
let prefix = "api";
|
|
||||||
let store = null;
|
|
||||||
|
|
||||||
api.setStore = function(s){
|
|
||||||
store=s;
|
|
||||||
};
|
|
||||||
|
|
||||||
api.post = function(url, body){
|
|
||||||
body = body || {};
|
|
||||||
body.session_id = store.getState().login.session_id;
|
|
||||||
return m.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: prefix+url,
|
|
||||||
body
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default api;
|
|
@ -1,127 +0,0 @@
|
|||||||
import nanoid from '../nanoid.min.js';
|
|
||||||
import api from '../api.js';
|
|
||||||
|
|
||||||
const preventDblClickSelection = function(e){
|
|
||||||
// as per [https://stackoverflow.com/a/43321596]
|
|
||||||
if(e.detail>1){
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const load_notes = function(state, dispatch){
|
|
||||||
api.post('/load-notes', {bin_id: state.bin_id})
|
|
||||||
.then(res=>{
|
|
||||||
if(res.success===true){
|
|
||||||
dispatch('notes-loaded', res.notes);
|
|
||||||
}
|
|
||||||
else if(res.authorized===false){
|
|
||||||
dispatch('notes-unauthorized');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
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 new_note_by_dblclick_handler = function(state, dispatch, e){
|
|
||||||
e.preventDefault(); // for nothing gets highlighted
|
|
||||||
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, name:id});
|
|
||||||
};
|
|
||||||
const username_change_handler = function(state, dispatch, e){
|
|
||||||
dispatch('update-username', e.target.value);
|
|
||||||
};
|
|
||||||
const password_change_handler = function(state, dispatch, e){
|
|
||||||
dispatch('update-password', e.target.value);
|
|
||||||
};
|
|
||||||
const login_request_handler = function(state, dispatch, e){
|
|
||||||
dispatch('login-requested');
|
|
||||||
api.post('/login', {username: state.login.username, password: state.login.password})
|
|
||||||
.then(res=>{
|
|
||||||
if(res.success===true){
|
|
||||||
dispatch('login-succeeded', {user:res.user, session_id:res.session_id});
|
|
||||||
dispatch('user-bin-list-loaded', res.bins);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
dispatch('login-failed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
e=>{ dispatch('login-failed'); });
|
|
||||||
};
|
|
||||||
const logout_request_handler = function(state, dispatch, e){
|
|
||||||
api.post('/logout', {});
|
|
||||||
// we dispatch after the API call because the logout action clears the session_id, which is needed to logout
|
|
||||||
dispatch('logout-requested');
|
|
||||||
// after logout, clear the bin:
|
|
||||||
new_bin_handler(state, dispatch);
|
|
||||||
};
|
|
||||||
const bin_name_editing_toggle_button_handler = function(state, dispatch){
|
|
||||||
dispatch('update-bin-name-editing', !state.is_editing_bin_name);
|
|
||||||
};
|
|
||||||
const bin_name_change_handler = function(state, dispatch, e){
|
|
||||||
dispatch('update-bin-name', e.target.value);
|
|
||||||
};
|
|
||||||
const bin_name_commit_handler = function(state, dispatch){
|
|
||||||
api.post('/bin-rename', {bin_id:state.bin_id, name:state.temp_bin_name});
|
|
||||||
dispatch('commit-bin-name');
|
|
||||||
};
|
|
||||||
const choose_bin_handler = function(state, dispatch, bin_id){
|
|
||||||
window.history.pushState(null,'','#'+bin_id);
|
|
||||||
dispatch('bin-requested', bin_id);
|
|
||||||
api.post('/load-bin', {bin_id})
|
|
||||||
.then(res=>{
|
|
||||||
if(res.success===true){
|
|
||||||
dispatch('bin-loaded', res.bin);
|
|
||||||
api.post('/load-notes', {bin_id})
|
|
||||||
.then(res=>{
|
|
||||||
if(res.success===true && res.authorized===true){
|
|
||||||
dispatch('notes-loaded', res.notes);
|
|
||||||
}
|
|
||||||
else if(res.success===false && res.authorized===false){
|
|
||||||
dispatch('notes-unauthorized');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else if(res.success===false && res.authorized==false){
|
|
||||||
dispatch('bin-unauthorized');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export {preventDblClickSelection,
|
|
||||||
new_note_handler,
|
|
||||||
new_note_by_dblclick_handler,
|
|
||||||
search_term_change_handler,
|
|
||||||
sorting_change_handler,
|
|
||||||
load_notes,
|
|
||||||
new_bin_handler,
|
|
||||||
username_change_handler,
|
|
||||||
password_change_handler,
|
|
||||||
login_request_handler,
|
|
||||||
logout_request_handler,
|
|
||||||
bin_name_editing_toggle_button_handler,
|
|
||||||
bin_name_change_handler,
|
|
||||||
bin_name_commit_handler,
|
|
||||||
choose_bin_handler
|
|
||||||
};
|
|
@ -1,23 +0,0 @@
|
|||||||
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(state, note_state, dispatch){
|
|
||||||
// TODO: this `if` may cause glitches; keep in mind
|
|
||||||
if(note_state.is_new===true){
|
|
||||||
dispatch('immediately-cancel-note', note_state.note_id);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
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 };
|
|
@ -1,48 +0,0 @@
|
|||||||
import api from '../api.js';
|
|
||||||
|
|
||||||
const initial_load_bin_handler = function(state, dispatch, bin_id){
|
|
||||||
api.post('/load-bin', {bin_id})
|
|
||||||
.then(res => {
|
|
||||||
if(res.success===false && res.authorized===false){
|
|
||||||
dispatch('bin-unauthorized');
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
dispatch('bin-loaded', res.bin);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
api.post('/load-notes', {bin_id})
|
|
||||||
.then(res => {
|
|
||||||
if(res.success===true){
|
|
||||||
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-bin', {bin_id})
|
|
||||||
.then(res=>{
|
|
||||||
if(res.success==true){
|
|
||||||
dispatch('bin-loaded', res.bin);
|
|
||||||
}
|
|
||||||
else if(res.authorized===false){
|
|
||||||
dispatch('bin-unauthorized');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
api.post('/load-notes', {bin_id})
|
|
||||||
.then(res => {
|
|
||||||
if(res.success==true){
|
|
||||||
dispatch('notes-loaded', res.notes);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export {initial_load_bin_handler, hash_change_handler};
|
|
@ -1,138 +0,0 @@
|
|||||||
/* 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 */
|
|
||||||
|
|
||||||
.error-message{
|
|
||||||
position: fixed;
|
|
||||||
top: 0px;
|
|
||||||
margin-top: 1rem;
|
|
||||||
width: 40%;
|
|
||||||
margin-right: auto;
|
|
||||||
margin-left: auto;
|
|
||||||
background-color: #ee8888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.app > .main > .notes{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note{
|
|
||||||
width: 17rem;
|
|
||||||
height: 17rem;
|
|
||||||
border: 1px solid black;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background-color: #aaccaa;
|
|
||||||
padding: 0.6rem;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
animation: bounce-in-fwd 0.8s ease-in-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note > textarea{
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
resize: none;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #aaccaa;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------------------------
|
|
||||||
* Generated by Animista on 2021-4-12 15:12:16
|
|
||||||
* Licensed under FreeBSD License.
|
|
||||||
* See http://animista.net/license for more info.
|
|
||||||
* w: http://animista.net, t: @cssanimista
|
|
||||||
* ---------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ----------------------------------------
|
|
||||||
* animation bounce-in-fwd
|
|
||||||
* ----------------------------------------
|
|
||||||
*/
|
|
||||||
@keyframes bounce-in-fwd {
|
|
||||||
0% {
|
|
||||||
transform: scale(0);
|
|
||||||
animation-timing-function: ease-in;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
38% {
|
|
||||||
transform: scale(1);
|
|
||||||
animation-timing-function: ease-out;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
55% {
|
|
||||||
transform: scale(0.7);
|
|
||||||
animation-timing-function: ease-in;
|
|
||||||
}
|
|
||||||
72% {
|
|
||||||
transform: scale(1);
|
|
||||||
animation-timing-function: ease-out;
|
|
||||||
}
|
|
||||||
81% {
|
|
||||||
transform: scale(0.84);
|
|
||||||
animation-timing-function: ease-in;
|
|
||||||
}
|
|
||||||
89% {
|
|
||||||
transform: scale(1);
|
|
||||||
animation-timing-function: ease-out;
|
|
||||||
}
|
|
||||||
95% {
|
|
||||||
transform: scale(0.95);
|
|
||||||
animation-timing-function: ease-in;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
animation-timing-function: ease-out;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,175 +0,0 @@
|
|||||||
import api from './api.js';
|
|
||||||
import App from './App.js';
|
|
||||||
import nanoid from './nanoid.min.js';
|
|
||||||
import {initial_load_bin_handler, hash_change_handler} from './handlers/index.js';
|
|
||||||
import {bin, note} from './models.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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Integrates a database row from the server into the local store: */
|
|
||||||
const integrate = function(s, model_object){
|
|
||||||
const id = model_object.id;
|
|
||||||
s.db[id] = s.db[id] || {ref_count: 0, model: null};
|
|
||||||
s.db[id].model = model_object;
|
|
||||||
};
|
|
||||||
const i = integrate;
|
|
||||||
|
|
||||||
/* integrates a list of model objects, as if 'integrate' were called on each element. */
|
|
||||||
const integrateAll = function(s, model_objects){
|
|
||||||
model_objects.forEach(mo=>{
|
|
||||||
integrate(s, mo);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Register a reference-by-id: */
|
|
||||||
const ref = function(s, o, key, new_model_id){
|
|
||||||
const old_model_id = o[key];
|
|
||||||
s.db[new_model_id].ref_count++;
|
|
||||||
if(old_model_id && s.db[old_model_id]){
|
|
||||||
s.db[old_model_id].ref_count--;
|
|
||||||
if(s.db[old_model_id].ref_count === 0){
|
|
||||||
delete s.db[old_model_id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// this needs to come after the above 'if' statement:
|
|
||||||
o[key] = new_model_id;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Register references in-bulk: */
|
|
||||||
/* o[key] refers to an array-of-ids */
|
|
||||||
const refAll = function(s, o, key, new_model_ids){
|
|
||||||
const old_model_ids = o[key];
|
|
||||||
// ref the new refs:
|
|
||||||
o[key] = new_model_ids;
|
|
||||||
new_model_ids.forEach(new_model_id=>{
|
|
||||||
s.db[new_model_id].ref_count++;
|
|
||||||
});
|
|
||||||
// de-ref the existing refs, deleting no-longer-referred-to models:
|
|
||||||
old_model_ids.forEach(old_model_id=>{
|
|
||||||
s.db[old_model_id].ref_count--;
|
|
||||||
// if this is the last reference, delete it from the store:
|
|
||||||
if(s.db[old_model_id].ref_count === 0){
|
|
||||||
delete s.db[old_model_id];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* TODO: routine to de-ref and ref in bulk when the id is in an object which is in an array (as opposed to refAll, where the id itself is in the array)
|
|
||||||
This routine would need to be called for state.notes, which contains refs of the form: `state.notes[n].note_id`
|
|
||||||
*/
|
|
||||||
|
|
||||||
// TODO: doesn't update anything that references the deleted record; results in dangling pointers:
|
|
||||||
const disintegrate = function(s, id){
|
|
||||||
delete s.db[id];
|
|
||||||
};
|
|
||||||
|
|
||||||
function addToBinListIfLoggedIn(state){
|
|
||||||
const s = state;
|
|
||||||
// if user is logged in:
|
|
||||||
if(s.login.is_logged_in){
|
|
||||||
// if bin is not already in the list:
|
|
||||||
if(s.login.bin_ids.filter(bin_id=>bin_id===s.bin_id).length===0){
|
|
||||||
i(s, {id: s.bin_id, name: s.db[s.bin_id].model.name});
|
|
||||||
// causes problems because ref_count isn't properly updated:
|
|
||||||
//s.login.bin_ids.push(s.bin_id);
|
|
||||||
ref(s, s.login.bin_ids, s.login.bin_ids.length, s.bin_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reducer = handleActions({
|
|
||||||
'new-bin': (s, bin) => { i(s,bin); ref(s,s,'bin_id',bin.id); s.notes=[]; s.temp_bin_name=bin.id; },
|
|
||||||
'bin-requested': (s, bin_id) => { i(s,{id:bin_id}); ref(s,s,'bin_id',bin_id); s.notes = []; s.error_message=''; },
|
|
||||||
'bin-loaded': (s, bin) => { i(s,bin); ref(s,s,'bin_id',bin.id); s.temp_bin_name=bin.name; },
|
|
||||||
'bin-unauthorized': (s) => { s.error_message = 'Not authorized to load bin. Please sign-in.'; },
|
|
||||||
'update-search-term': (s, search_term) => { s.search_term=search_term; },
|
|
||||||
'update-search-results': (s, _notes) => { integrateAll(s,s.notes,'note_id',_notes); s.notes =_notes.map(n=>({is_editing: false, temp_text: '', bin_id: s.bin_id, note_id:n.id})); },
|
|
||||||
'add-note': (s, {id, date}) => { i(s, {id: id, text: '', modified: date}); s.notes.unshift({is_editing: true, temp_text: '', bin_id: s.bin_id, is_focused:true, is_new: true, note_id: id}); },
|
|
||||||
'notes-loaded': (s, _notes) => { integrateAll(s,_notes); s.notes = _notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: s.bin_id, note_id:n.id})); },
|
|
||||||
'notes-unauthorized': (s) => { s.error_message = 'Not authorized to load notes in bin. Please sign-in.'; },
|
|
||||||
'immediately-cancel-note': (s, note_id) => { disintegrate(s,note_id); s.notes.splice(s.notes.findIndex(n=>n.note_id===note_id), 1); },
|
|
||||||
'update-note-text': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note_id===id); note_s.is_new=false; s.db[note_s.note_id].model.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=s.db[note_s.note_id].model.text; },
|
|
||||||
'save-note-edit': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note_id===id); s.db[note_s.note_id].model.text=text; note_s.temp_text=text; note_s.is_editing=false; },
|
|
||||||
'update-sorting': (s, sorting) => { s.sorting=sorting; },
|
|
||||||
'update-username': (s, username) => { s.login.username=username; },
|
|
||||||
'update-password': (s, password) => { s.login.password=password; },
|
|
||||||
'login-requested': (s) => { s.login.showing=false; },
|
|
||||||
'login-succeeded': (s, {user, session_id}) => { i(s, user); s.login.is_logged_in=true; s.login.showing=true; s.login.password=''; ref(s, s.login, 'user_id', user.id); s.login.session_id=session_id; },
|
|
||||||
'login-failed': (s) => { s.login.showing=true; s.login.password=''; },
|
|
||||||
'logout-requested': (s) => { s.login.is_logged_in=false; s.login.showing=true; s.login.username=''; s.login.password=''; s.login.session_id=''; refAll(s, s.login, 'bin_ids', []); },
|
|
||||||
'user-bin-list-loaded': (s, bins) => { integrateAll(s, bins); refAll(s, s.login, 'bin_ids', bins.map(b=>b.id)); },
|
|
||||||
'update-bin-name-editing': (s, is_editing) => { s.is_editing_bin_name=is_editing; },
|
|
||||||
'update-bin-name': (s, name) => { s.temp_bin_name=name; },
|
|
||||||
'commit-bin-name': (s) => { s.db[s.bin_id].model.name=s.temp_bin_name; s.is_editing_bin_name=false; addToBinListIfLoggedIn(s); }
|
|
||||||
}, {
|
|
||||||
bin_id: bin_id,
|
|
||||||
is_editing_bin_name: false,
|
|
||||||
temp_bin_name: bin_id,
|
|
||||||
notes: [
|
|
||||||
//{is_editing: false, temp_text: '', bin_id: '', note_id: ''},
|
|
||||||
],
|
|
||||||
search_term: '',
|
|
||||||
sorting: 'new->old',
|
|
||||||
login: {
|
|
||||||
showing: true,
|
|
||||||
username: '', // value of textbox
|
|
||||||
password: '', // value of textbox
|
|
||||||
is_logged_in: false,
|
|
||||||
user_id: '' /*{
|
|
||||||
id: '',
|
|
||||||
username: ''
|
|
||||||
}*/,
|
|
||||||
session_id: '',
|
|
||||||
bin_ids: []
|
|
||||||
},
|
|
||||||
db: {
|
|
||||||
[bin_id]: {
|
|
||||||
ref_count: 1,
|
|
||||||
model: {id: bin_id, name: bin_id, user_id: ''}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error_message: ''
|
|
||||||
//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(()=>{
|
|
||||||
const state = store.getState();
|
|
||||||
m.render(root, m(App, {state, dispatch}));
|
|
||||||
});
|
|
||||||
|
|
||||||
api.setStore(store);
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", (e)=>hash_change_handler(store.getState(), dispatch, e), false);
|
|
||||||
|
|
||||||
let state = store.getState();
|
|
||||||
initial_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}))
|
|
@ -1,21 +0,0 @@
|
|||||||
function bin(o){
|
|
||||||
const b = {};
|
|
||||||
b.id = o.id;
|
|
||||||
b.name = o.name || b.id;
|
|
||||||
b.user_id = o.user_id || '';
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function note(o){
|
|
||||||
const n = {};
|
|
||||||
n.id = o.id;
|
|
||||||
n.text = o.text || '';
|
|
||||||
n.modified = o.modified || 0;
|
|
||||||
n.bin_id = o.bin_id || '';
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
bin,
|
|
||||||
note
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
FROM node:15.12.0-alpine3.13
|
|
||||||
#RUN apk add --no-cache python3 g++ make
|
|
||||||
WORKDIR /home/node/app
|
|
||||||
COPY ["package.json", "yarn.lock", "./"]
|
|
@ -1,8 +0,0 @@
|
|||||||
FROM node:15.12.0-alpine3.13
|
|
||||||
RUN apk add --no-cache python3 g++ make
|
|
||||||
WORKDIR /home/node/app
|
|
||||||
COPY ["package.json", "yarn.lock", "./"]
|
|
||||||
RUN yarn install --production
|
|
||||||
# the following aren't needed, as they can be accomplished by volumes with docker-compose, instead of making a new image layer:
|
|
||||||
#COPY ./src .
|
|
||||||
#CMD ["node", "server.js"]
|
|
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pastebin-server",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "counterpart to the pastebin frontend",
|
|
||||||
"main": "server.js",
|
|
||||||
"repository": "https://git.sakal.us/brian/pastebin-server.git",
|
|
||||||
"author": "Brian Sakal <brian@sakal.us>",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"pm2": "^4.5.5"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.17.1",
|
|
||||||
"nanoid": "^3.1.22",
|
|
||||||
"pg": "^8.5.1"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,260 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const {nanoid} = require('nanoid');
|
|
||||||
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
const db = new Pool({
|
|
||||||
host: 'postgres',
|
|
||||||
database: 'pastebin',
|
|
||||||
user: 'pastebin',
|
|
||||||
password: 'buginoo'
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// initialize app:
|
|
||||||
const app = express();
|
|
||||||
const port = 80;
|
|
||||||
|
|
||||||
db.query("CREATE TABLE IF NOT EXISTS bin (id VARCHAR PRIMARY KEY, name VARCHAR);"
|
|
||||||
+ "CREATE TABLE IF NOT EXISTS note (id VARCHAR PRIMARY KEY, text TEXT, modified TIMESTAMPTZ);"
|
|
||||||
+ "CREATE TABLE IF NOT EXISTS bin_note (bin_id VARCHAR REFERENCES bin (id), note_id VARCHAR REFERENCES note (id), PRIMARY KEY (bin_id, note_id));"
|
|
||||||
+ "CREATE TABLE IF NOT EXISTS user_ (id VARCHAR PRIMARY KEY, username TEXT, password TEXT);" // table 'user' is already taken
|
|
||||||
+ "CREATE TABLE IF NOT EXISTS bin_user (bin_id VARCHAR REFERENCES bin (id), user_id VARCHAR REFERENCES user_ (id), PRIMARY KEY (bin_id, user_id));"
|
|
||||||
+ "CREATE TABLE IF NOT EXISTS session (id VARCHAR PRIMARY KEY, user_id VARCHAR REFERENCES user_ (id));"
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const load_notes_stmt =
|
|
||||||
"SELECT n.id, n.text, n.modified FROM bin_note AS bn"
|
|
||||||
+" JOIN note AS n"
|
|
||||||
+" ON n.id = bn.note_id"
|
|
||||||
+" WHERE bn.bin_id = $1";
|
|
||||||
const load_bin_stmt =
|
|
||||||
"SELECT b.id, b.name, bu.user_id as owner_user_id, s.user_id as session_user_id FROM bin AS b"
|
|
||||||
+" FULL JOIN bin_user AS bu" // we want the bin regardless of whether it has an associated user, hence LEFT JOIN
|
|
||||||
+" ON bu.bin_id = b.id"
|
|
||||||
+" FULL JOIN session AS s"
|
|
||||||
+" ON s.id = $2"
|
|
||||||
+" WHERE b.id = $1";
|
|
||||||
const search_stmt =
|
|
||||||
"SELECT n.id, n.text, n.modified FROM note AS n"
|
|
||||||
+" INNER JOIN bin_note AS bn"
|
|
||||||
+" ON bn.note_id = n.id AND bn.bin_id = $2"
|
|
||||||
+" WHERE n.text LIKE '%' || $1 || '%'"; // need to use string concat otherwise the `$1` is viewed as part of the string instead of a placeholder
|
|
||||||
const order_desc_stmt = " ORDER BY n.modified DESC";
|
|
||||||
const order_asc_stmt = " ORDER BY n.modified ASC";
|
|
||||||
const upsert_note_stmt =
|
|
||||||
"INSERT INTO note (id, text, modified) VALUES ($1, $2, NOW())"
|
|
||||||
+" ON CONFLICT (id)"
|
|
||||||
+" DO UPDATE SET text = EXCLUDED.text, modified = EXCLUDED.modified";
|
|
||||||
const upsert_bin_stmt =
|
|
||||||
"INSERT INTO bin (id, name) VALUES ($1, $1)"
|
|
||||||
+" ON CONFLICT (id)"
|
|
||||||
+" DO NOTHING";
|
|
||||||
const upsert_bin_note_stmt =
|
|
||||||
"INSERT INTO bin_note (bin_id, note_id) VALUES ($1, $2)"
|
|
||||||
+" ON CONFLICT (bin_id, note_id)"
|
|
||||||
+" DO NOTHING";
|
|
||||||
const upsert_bin_user_stmt =
|
|
||||||
"INSERT INTO bin_user (bin_id, user_id)"
|
|
||||||
+" (SELECT $1, s.user_id FROM session AS s WHERE s.id = $2)"
|
|
||||||
+" ON CONFLICT (bin_id, user_id)"
|
|
||||||
+" DO NOTHING";
|
|
||||||
const login_check_stmt =
|
|
||||||
"SELECT id, password FROM user_ AS u"
|
|
||||||
+" WHERE u.username = $1";
|
|
||||||
const register_stmt =
|
|
||||||
"INSERT INTO user_ (id, username, password) VALUES ($1, $2, $3)";
|
|
||||||
const session_create_stmt =
|
|
||||||
"INSERT INTO session (id, user_id) VALUES ($1, $2)";
|
|
||||||
const user_bin_list_stmt =
|
|
||||||
"SELECT b.id, b.name FROM bin_user AS bu"
|
|
||||||
+" INNER JOIN bin AS b"
|
|
||||||
+" ON bu.bin_id = b.id"
|
|
||||||
+" WHERE bu.user_id = $1";
|
|
||||||
const delete_session_stmt =
|
|
||||||
"DELETE FROM session WHERE id = $1";
|
|
||||||
const rename_bin_stmt =
|
|
||||||
"INSERT INTO bin (id, name) VALUES ($1, $2)"
|
|
||||||
+" ON CONFLICT (id)"
|
|
||||||
+" DO UPDATE SET name = EXCLUDED.name;";
|
|
||||||
|
|
||||||
|
|
||||||
// {bin_id}
|
|
||||||
router.post('/load-notes', (req, res)=>{
|
|
||||||
const {bin_id, session_id} = req.body;
|
|
||||||
db.query(load_bin_stmt, [bin_id, session_id])
|
|
||||||
.then(bin_result =>{
|
|
||||||
if(bin_result.rows.length > 0){
|
|
||||||
const bin = bin_result.rows[0];
|
|
||||||
// if it's a public bin:
|
|
||||||
if(bin.owner_user_id===null){
|
|
||||||
db.query(load_notes_stmt, [bin_id])
|
|
||||||
.then(result => {
|
|
||||||
res.json({success:true, authorized:false, notes:result.rows})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// if it's a private bin, and the owner is signed-in:
|
|
||||||
else if(bin.owner_user_id===bin.session_user_id){
|
|
||||||
db.query(load_notes_stmt, [bin_id])
|
|
||||||
.then(result => {
|
|
||||||
res.json({success:true, authorized:true, notes:result.rows})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
res.json({success:false, authorized:false});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/load-bin', (req, res)=>{
|
|
||||||
const {bin_id, session_id} = req.body;
|
|
||||||
// if a bin has no associated user, it's considered public and can be accessed even when not logged-in.
|
|
||||||
// if a bin has an associated user, it can only be accessed by that user
|
|
||||||
db.query(load_bin_stmt, [bin_id, session_id])
|
|
||||||
.then(result => {
|
|
||||||
const bin = result.rows[0];
|
|
||||||
// if a bin with given id was found:
|
|
||||||
if(result.rows.length>0){
|
|
||||||
const bin = result.rows[0];
|
|
||||||
// if it's a public bin:
|
|
||||||
if(bin.owner_user_id===null){
|
|
||||||
res.json({success:true, authorized:false, bin:{id:bin.id, name:bin.name}});
|
|
||||||
}
|
|
||||||
// if it's a private bin, and the owner is signed-in:
|
|
||||||
else if(bin.owner_user_id===bin.session_user_id){
|
|
||||||
res.json({success:true, authorized:true, bin:{id:bin.id, name:bin.name}});
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
res.json({success:false, authorized:false});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
res.json({success:true, authorized:true, bin:{id:bin_id, name:bin_id}});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// {status: 'ok', bin:{id:bin.id}, notes: bin.notes}
|
|
||||||
});
|
|
||||||
|
|
||||||
// {search_term, sorting, bin_id}
|
|
||||||
router.post('/search', (req, res)=>{
|
|
||||||
const {search_term, bin_id, sorting} = req.body;
|
|
||||||
db.query(search_stmt+(sorting==='old->new'?order_asc_stmt:order_desc_stmt), [search_term, bin_id])
|
|
||||||
.then(result => {
|
|
||||||
res.json({success:true, notes:result.rows});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// {bin_id, note_id, text}
|
|
||||||
router.post('/save', (req, res)=>{
|
|
||||||
const {bin_id, note_id, text, session_id} = req.body;
|
|
||||||
db.connect().then(async (client)=>{
|
|
||||||
// TODO: make the following into a transaction:
|
|
||||||
await client.query(upsert_note_stmt, [note_id, text]);
|
|
||||||
await client.query(upsert_bin_stmt, [bin_id]);
|
|
||||||
await client.query(upsert_bin_note_stmt, [bin_id, note_id]);
|
|
||||||
// if the user is signed-in, ensure the bin and user are associated:
|
|
||||||
if(session_id !== ''){
|
|
||||||
await client.query(upsert_bin_user_stmt, [bin_id, session_id]);
|
|
||||||
}
|
|
||||||
// don't forget to release back into the Pool:
|
|
||||||
client.release();
|
|
||||||
res.json({success:true});
|
|
||||||
});
|
|
||||||
// {status: 'ok'}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/login', (req, res)=>{
|
|
||||||
const {username, password} = req.body;
|
|
||||||
const password_from_client=password;
|
|
||||||
db.query(login_check_stmt, [username])
|
|
||||||
.then(result=>{
|
|
||||||
// if there is such a username:
|
|
||||||
if(result.rows.length > 0){
|
|
||||||
const row = result.rows[0];
|
|
||||||
const user_id=row.id, password_from_db=row.password;
|
|
||||||
// if the passwords match:
|
|
||||||
// TODO: replace `===` with a constant-time comparison function to prevent timing attacks; or better, store passwords as salted hashes and use `===` on that:
|
|
||||||
if(password_from_client === password_from_db){
|
|
||||||
const session_id = nanoid();
|
|
||||||
db.query(session_create_stmt, [session_id, user_id]);
|
|
||||||
db.query(user_bin_list_stmt, [user_id])
|
|
||||||
.then(result=>{
|
|
||||||
const bins = result.rows;
|
|
||||||
res.json({success:true, user:{id:user_id, username}, session_id, bins});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
res.json({success: false});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if no such user exists:
|
|
||||||
else{
|
|
||||||
const user_id = nanoid();
|
|
||||||
const session_id = nanoid();
|
|
||||||
db.query(register_stmt, [user_id, username, password_from_client])
|
|
||||||
.then(r=>{
|
|
||||||
db.query(session_create_stmt, [session_id, user_id]);
|
|
||||||
res.json({success: true, user:{id:user_id, username}, session_id, bins:[]});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/logout', (req, res)=>{
|
|
||||||
db.query(delete_session_stmt, [req.body.session_id]);
|
|
||||||
res.json({success:true});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/bin-rename', (req,res)=>{
|
|
||||||
const {bin_id, name, session_id} = req.body;
|
|
||||||
db.query(rename_bin_stmt, [bin_id, name])
|
|
||||||
.then(x=>{
|
|
||||||
// ensure user and bin are associated if necessary:
|
|
||||||
if(session_id !== ''){
|
|
||||||
// `upsert_bin_user_stmt` is defined above:
|
|
||||||
db.query(upsert_bin_user_stmt, [bin_id, session_id]);
|
|
||||||
}
|
|
||||||
res.json({success:true});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/', express.json(), router);
|
|
||||||
|
|
||||||
|
|
||||||
// run app:
|
|
||||||
const server = app.listen(port, () => {
|
|
||||||
console.log(`Pastebin app listening at http://localhost:${port}`)
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Ruthlessly copied from [https://medium.com/@becintec/building-graceful-node-applications-in-docker-4d2cd4d5d392]:
|
|
||||||
// The signals we want to handle
|
|
||||||
// NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled
|
|
||||||
var signals = {
|
|
||||||
'SIGHUP': 1,
|
|
||||||
'SIGINT': 2,
|
|
||||||
'SIGTERM': 15
|
|
||||||
};
|
|
||||||
// Do any necessary shutdown logic for our application here
|
|
||||||
const shutdown = (signal, value) => {
|
|
||||||
console.log("shutdown!");
|
|
||||||
server.close(() => {
|
|
||||||
console.log(`server stopped by ${signal} with value ${value}`);
|
|
||||||
process.exit(128 + value);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Create a listener for each of the signals that we want to handle
|
|
||||||
Object.keys(signals).forEach((signal) => {
|
|
||||||
process.on(signal, () => {
|
|
||||||
console.log(`process received a ${signal} signal`);
|
|
||||||
shutdown(signal, signals[signal]);
|
|
||||||
});
|
|
||||||
});
|
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue