├── screenshot.jpg ├── go.mod ├── go.sum ├── Makefile ├── .gitignore ├── package.json ├── static ├── index.html ├── css │ └── main.css └── js │ ├── main.js │ └── torus.min.js ├── pico.service ├── README.md ├── LICENSE ├── src └── pico.go ├── .eslintrc.js └── yarn.lock /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/pico/HEAD/screenshot.jpg -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thesephist/pico 2 | 3 | go 1.14 4 | 5 | require github.com/gorilla/mux v1.7.4 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= 2 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | pico = ./src/ 2 | 3 | all: run 4 | 5 | 6 | run: 7 | go run -race ${pico} 8 | 9 | 10 | # build for specific OS target 11 | build-%: 12 | GOOS=$* GOARCH=amd64 go build -o pico-$* ${pico} 13 | 14 | 15 | build: 16 | go build -o pico ${pico} 17 | 18 | 19 | # clean any generated files 20 | clean: 21 | rm -rvf pico pico-* 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # pico 2 | data.json 3 | node_modules/ 4 | pico 5 | pico-* 6 | 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pico", 3 | "version": "0.1.0", 4 | "description": "Notes, todos, nothing fancy.", 5 | "main": "static/js/main.js", 6 | "repository": "git@github.com:thesephist/pico.git", 7 | "author": "Linus Lee ", 8 | "license": "MIT", 9 | "scripts": { 10 | "fmt": "eslint ./static/js/main.js --fix" 11 | }, 12 | "devDependencies": { 13 | "eslint": "^7.3.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pico 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /pico.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=pico server 3 | ConditionPathExists=/home/pico-user/pico/pico 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=pico-user 9 | LimitNOFILE=256 10 | 11 | Restart=on-failure 12 | RestartSec=10 13 | StartLimitIntervalSec=60 14 | 15 | WorkingDirectory=/home/pico-user/pico/ 16 | ExecStart=/home/pico-user/pico/pico 17 | 18 | # make sure log directory exists and owned by syslog 19 | PermissionsStartOnly=true 20 | ExecStartPre=/bin/mkdir -p /var/log/pico 21 | ExecStartPre=/bin/chown syslog:adm /var/log/pico 22 | ExecStartPre=/bin/chmod 755 /var/log/pico 23 | StandardOutput=syslog 24 | StandardError=syslog 25 | SyslogIdentifier=pico 26 | 27 | [Install] 28 | WantedBy=multi-user.target 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pico ✏️ 2 | 3 | >Todos, notes, nothing fancy. 4 | 5 | Pico is a more lightweight, ephemeral companion to my [Ligature](https://github.com/thesephist/polyx#ligature) notes application. While Ligature is designed to be a long-term archive of the most important notes in my life, Pico is for the more temporary notes -- the todos, the things I jot down between meetings, the thoughts I need to put somewhere before I triage them into a different document. 6 | 7 | Pico is built with [Torus](https://github.com/thesephist/torus) as a fully client-side rendered application, with a minimal Go backend. You'll notice that the interface is extremely bare. Pico isn't really meant for other people to use, and is tailor-made specifically for my limited use case. The interface isn't user-friendly, but it works great for me. 8 | 9 | ![Pico screenshot](screenshot.jpg) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Linus Lee 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 | -------------------------------------------------------------------------------- /src/pico.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | const dbPath = "./data.json" 15 | 16 | func ensureDataDirExists() { 17 | _, err := os.Stat(dbPath) 18 | if os.IsNotExist(err) { 19 | dataFile, err := os.OpenFile(dbPath, os.O_CREATE|os.O_WRONLY, 0644) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | defer dataFile.Close() 24 | 25 | // empty JSON array 26 | _, err = dataFile.Write([]byte("[]")) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | } else if err != nil { 31 | log.Fatal(err) 32 | } 33 | } 34 | 35 | func writeErr(w http.ResponseWriter, err error) { 36 | w.WriteHeader(http.StatusInternalServerError) 37 | io.WriteString(w, err.Error()) 38 | } 39 | 40 | func save(data []byte) error { 41 | file, err := os.OpenFile(dbPath, os.O_WRONLY|os.O_TRUNC, 0644) 42 | if err != nil { 43 | return err 44 | } 45 | defer file.Close() 46 | 47 | _, err = file.Write(data) 48 | return err 49 | } 50 | 51 | func get() ([]byte, error) { 52 | file, err := os.Open(dbPath) 53 | if err != nil { 54 | return nil, err 55 | } 56 | defer file.Close() 57 | 58 | return ioutil.ReadAll(file) 59 | } 60 | 61 | func handleSave(w http.ResponseWriter, r *http.Request) { 62 | data, err := ioutil.ReadAll(r.Body) 63 | if err != nil { 64 | writeErr(w, err) 65 | return 66 | } 67 | err = save(data) 68 | if err != nil { 69 | writeErr(w, err) 70 | return 71 | } 72 | } 73 | 74 | func handleGet(w http.ResponseWriter, r *http.Request) { 75 | data, err := get() 76 | if err != nil { 77 | writeErr(w, err) 78 | return 79 | } 80 | 81 | w.Write(data) 82 | } 83 | 84 | func index(w http.ResponseWriter, r *http.Request) { 85 | indexFile, err := os.Open("./static/index.html") 86 | if err != nil { 87 | io.WriteString(w, "error reading index") 88 | return 89 | } 90 | defer indexFile.Close() 91 | 92 | io.Copy(w, indexFile) 93 | } 94 | 95 | func main() { 96 | ensureDataDirExists() 97 | 98 | r := mux.NewRouter() 99 | 100 | srv := &http.Server{ 101 | Handler: r, 102 | Addr: "127.0.0.1:9110", 103 | WriteTimeout: 60 * time.Second, 104 | ReadTimeout: 60 * time.Second, 105 | } 106 | 107 | r.HandleFunc("/", index) 108 | r.Methods("POST").Path("/data").HandlerFunc(handleSave) 109 | r.Methods("GET").Path("/data").HandlerFunc(handleGet) 110 | r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) 111 | 112 | log.Printf("Pico listening on %s\n", srv.Addr) 113 | log.Fatal(srv.ListenAndServe()) 114 | } 115 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | font-family: 'Barlow', system-ui, sans-serif; 5 | font-size: 18px; 6 | 7 | --fg: #222; 8 | --bg: #fafafa; 9 | 10 | background: var(--bg); 11 | color: var(--fg); 12 | min-height: 100vh; 13 | } 14 | 15 | body.dark { 16 | --fg: #fafafa; 17 | --bg: #222; 18 | } 19 | 20 | header { 21 | margin-top: 0; 22 | margin-bottom: 2em; 23 | } 24 | 25 | a, a:visited { 26 | color: var(--fg); 27 | } 28 | 29 | .header-left { 30 | flex-grow: 1; 31 | } 32 | 33 | h1 { 34 | margin: 0; 35 | font-weight: normal; 36 | } 37 | 38 | p.sub { 39 | opacity: .6; 40 | font-size: .8em; 41 | margin: 0; 42 | margin-top: .8em; 43 | } 44 | 45 | input, 46 | textarea, .p-heights { 47 | font-size: 1em; 48 | border: 0; 49 | outline: 0; 50 | padding: 0; 51 | font-family: 'Barlow', system-ui, sans-serif; 52 | background: transparent; 53 | color: var(--fg); 54 | } 55 | 56 | main.app { 57 | max-width: 800px; 58 | padding: 2em 0; 59 | margin: 0 auto; 60 | width: calc(100% - 2em); 61 | } 62 | 63 | .block, 64 | .block-heading, 65 | .block-body { 66 | width: 100%; 67 | } 68 | 69 | header, 70 | .block-heading, 71 | .button-bar { 72 | display: flex; 73 | flex-direction: row; 74 | align-items: center; 75 | } 76 | 77 | .block-heading { 78 | justify-content: space-between; 79 | margin-top: 1em; 80 | margin-bottom: .4em; 81 | } 82 | 83 | .block-heading input { 84 | flex-grow: 1; 85 | font-size: 1.2em; 86 | line-height: 1.5em; 87 | font-weight: bold; 88 | } 89 | 90 | .block-body { 91 | position: relative; 92 | margin-bottom: 2em; 93 | } 94 | 95 | button { 96 | border: 0; 97 | outline: 0; 98 | padding: .3em .5em; 99 | border-radius: 4px; 100 | background: 0; 101 | cursor: pointer; 102 | font-size: 1em; 103 | color: var(--fg); 104 | margin-left: .2em; 105 | outline: none; 106 | } 107 | 108 | button:hover { 109 | background: var(--fg); 110 | color: var(--bg); 111 | } 112 | 113 | button:active { 114 | opacity: .6; 115 | } 116 | 117 | textarea, .p-heights { 118 | box-sizing: border-box; 119 | width: 100%; 120 | min-height: 2em; 121 | line-height: 1.5em; 122 | margin: 0; 123 | word-wrap: break-word; 124 | white-space: pre-wrap; 125 | } 126 | 127 | .p-heights { 128 | visibility: hidden; 129 | } 130 | 131 | .p-heights.end-line { 132 | padding-bottom: 27px; 133 | } 134 | 135 | textarea { 136 | resize: none; 137 | position: absolute; 138 | top: 0; 139 | left: 0; 140 | right: 0; 141 | bottom: 0; 142 | overflow: hidden; 143 | } 144 | 145 | footer .sub { 146 | margin-top: 3em; 147 | margin-bottom: 2em; 148 | } -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | const { 2 | Record, 3 | StoreOf, 4 | Component, 5 | ListOf, 6 | } = window.Torus; 7 | 8 | const MONTHS = [ 9 | 'January', 10 | 'February', 11 | 'March', 12 | 'April', 13 | 'May', 14 | 'June', 15 | 'July', 16 | 'August', 17 | 'September', 18 | 'October', 19 | 'November', 20 | 'December', 21 | ]; 22 | 23 | function fmtDate(date) { 24 | return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; 25 | } 26 | 27 | function relativeDate(date) { 28 | const delta = (new Date() - date) / 1000; 29 | if (delta < 60) { 30 | return '< 1 min ago'; 31 | } else if (delta < 3600) { 32 | return `${~~(delta / 60)} min ago`; 33 | } else if (delta < 86400) { 34 | return `${~~(delta / 3600)} hr ago`; 35 | } else if (delta < 86400 * 2) { 36 | return 'yesterday'; 37 | } else if (delta < 86400 * 3) { 38 | return '2 days ago'; 39 | } else { 40 | return date.toLocaleDateString() + ' ' + formatTime(date); 41 | } 42 | } 43 | 44 | // only fire fn once it hasn't been called in delay ms 45 | const bounce = (fn, delay) => { 46 | let to = null; 47 | return (...args) => { 48 | const bfn = () => fn(...args); 49 | clearTimeout(to); 50 | to = setTimeout(bfn, delay); 51 | } 52 | } 53 | 54 | class Block extends Record { } 55 | 56 | class BlockStore extends StoreOf(Block) { 57 | fetch() { 58 | return fetch('/data') 59 | .then(r => r.json()) 60 | .then(data => this.reset(data.map(d => new Block(d)))); 61 | } 62 | save() { 63 | return fetch('/data', { 64 | method: 'POST', 65 | body: JSON.stringify(this.serialize()), 66 | }); 67 | } 68 | } 69 | 70 | class BlockItem extends Component { 71 | init(record, removeCallback) { 72 | this.removeCallback = removeCallback; 73 | this._collapsed = false; 74 | 75 | this.handleHeadingInput = evt => this.handleInput('h', evt); 76 | this.handleBodyInput = evt => this.handleInput('b', evt); 77 | this.handleKeydown = this.handleKeydown.bind(this); 78 | this.handleToggleCollapse = this.handleToggleCollapse.bind(this); 79 | this.handleRemove = this.handleRemove.bind(this); 80 | 81 | this.bind(record, data => this.render(data)); 82 | } 83 | isCollapsed() { 84 | return this._collapsed; 85 | } 86 | setCollapsed(c) { 87 | this._collapsed = c; 88 | this.render(); 89 | } 90 | handleInput(prop, evt) { 91 | this.record.update({[prop]: evt.target.value}) 92 | } 93 | handleKeydown(evt) { 94 | if (evt.key === 'Tab') { 95 | evt.preventDefault(); 96 | const idx = evt.target.selectionStart; 97 | if (idx !== null) { 98 | const front = this.record.get('b').substr(0, idx); 99 | const back = this.record.get('b').substr(idx); 100 | this.record.update({b: front + ' ' + back}); 101 | this.render(); 102 | evt.target.setSelectionRange(idx + 4, idx + 4); 103 | } 104 | } 105 | } 106 | handleToggleCollapse() { 107 | this.setCollapsed(!this._collapsed); 108 | } 109 | handleRemove() { 110 | const isEmpty = !(this.record.get('h').trim() + this.record.get('b').trim()); 111 | const result = isEmpty ? true : confirm('Remove?'); 112 | if (!result) { 113 | return; 114 | } 115 | 116 | this.removeCallback(); 117 | } 118 | compose({h, b}) { 119 | return jdom`
120 |
121 | 124 |
125 | 129 | 131 |
132 |
133 | ${this._collapsed ? null : jdom`
134 |