├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── index.html ├── install_deta.png ├── main.py ├── requirements.txt ├── screenshot.png └── static ├── css └── main.css ├── install_deta.png └── js ├── main.js └── torus.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | mira 15 | 16 | # Mira data files 17 | data/*.txt 18 | .deta -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | @echo "hello :)" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mira ☎️ (on Deta) 2 | 3 | Mira is a personal contacts manager written by [Linus](https://github.com/thesephist/mira). It is a place for keeping in touch with people. 4 | 5 | [![Deploy](https://button.deta.dev/1/svg)](https://go.deta.dev/deploy) 6 | 7 | Mira is designed for quick actions and references to people I've met, and things I want to remember about those people. It looks like this in action: 8 | 9 | ![Mira on desktop](screenshot.png) 10 | 11 | In particular, in addition to normal contact info you see in every app, Mira is capable of storing a few specific kinds of data I like to remember about people: 12 | 13 | - past meetings ("mtg"): where are all the places I've met this person, in which occasion, and what did we talk about that I want to remember? 14 | - last met ("last"): when did we last talk? This is useful when I want to follow up with people I haven't talked to in too long. 15 | - place: what city do they live in? This is useful for remembering to visit people I haven't seen in a while whenever I visit a city for the first time in some time. 16 | - unstructured notes: things like extracurricular or volunteering involvements, event attendance, hobbies, and other extraneous information that's useful to know, but not trivial. 17 | 18 | This repo is modified to run on [Deta](https://www.deta.sh/). 19 | 20 | 21 | ## Run on Deta 22 | 23 | Deta provides a platform for hosting apps like Mira. Mira uses a Deta Micro and a Deta Base. Deta has built in authentication, only you can access your app and data. 24 | 25 | To run your own Mira instance, follow these instructions: 26 | 27 | - Get a [free account](https://www.deta.sh/) 28 | - [Install the CLI](https://docs.deta.sh/docs/cli/install) and login 29 | - Clone this repo and run `deta new` 30 | - Click on the domain in the output 31 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mira 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /install_deta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdelhai/mira/aaf5eb6aef3a0e74d34cbd7bd55e772cbc0ef5e4/install_deta.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from deta import App, Deta 2 | from fastapi import FastAPI, Request, responses 3 | from fastapi.staticfiles import StaticFiles 4 | 5 | deta = Deta() 6 | db = deta.Base("people") # init the DB 7 | 8 | # We are wrapping FastAPI with a Deta wrapper to be able to use `deta run` 9 | # See line 31. It's optional. 10 | app = App(FastAPI()) 11 | app.mount("/static", StaticFiles(directory="static"), name="static") 12 | 13 | 14 | @app.get("/") 15 | def index(): 16 | return responses.HTMLResponse(open("./index.html").read()) 17 | 18 | 19 | @app.post("/data") 20 | async def post(r: Request): 21 | items = await r.json() 22 | for item in items: 23 | db.put(item) 24 | return item 25 | 26 | 27 | @app.get("/data") 28 | async def get(): 29 | return next(db.fetch()) 30 | 31 | 32 | # This command removes all the data frm the DB 33 | # To trigger it, run `deta run` in the project's root 34 | @app.lib.run() 35 | def reset_db(event): 36 | for item in next(db.fetch()): 37 | db.delete(item["key"]) 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | aiofiles -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdelhai/mira/aaf5eb6aef3a0e74d34cbd7bd55e772cbc0ef5e4/screenshot.png -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | /* polyx */ 2 | 3 | body, 4 | html, 5 | form { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | body { 11 | --ff: 'Barlow', system-ui, 'Helvetica', sans-serif; 12 | 13 | --bg: #d9d9d9; 14 | --frost: #e9e9e9; 15 | --paper: #F9F9F9; 16 | --fg: #222; 17 | --light: #777; 18 | } 19 | 20 | .card { 21 | display: block; 22 | margin: 12px; 23 | padding: 0; 24 | border-radius: 6px; 25 | box-shadow: 0 2px 4px rgba(0, 0, 0, .08); 26 | overflow: hidden; 27 | } 28 | 29 | .block { 30 | padding: 8px 12px; 31 | } 32 | 33 | .light { 34 | color: var(--light); 35 | font-size: .825rem; 36 | } 37 | 38 | body { 39 | background: var(--bg); 40 | } 41 | 42 | html { 43 | font-size: 18px; 44 | } 45 | 46 | body, 47 | button, 48 | input[type="text"], 49 | input[type="tel"], 50 | input[type="email"], 51 | input[type="submit"], 52 | textarea { 53 | font-size: 1rem; 54 | color: var(--fg); 55 | font-family: var(--ff); 56 | border: 0; 57 | margin: 0; 58 | outline: 0; 59 | box-sizing: border-box; 60 | } 61 | 62 | input[type="text"], 63 | input[type="tel"], 64 | input[type="email"], 65 | input[type="submit"], 66 | textarea { 67 | transition: background-color .2s; 68 | } 69 | 70 | textarea { 71 | height: 100%; 72 | flex-grow: 1; 73 | } 74 | 75 | ul, 76 | ol { 77 | margin: 0; 78 | padding-left: 0; 79 | list-style: none; 80 | } 81 | 82 | input[type="submit"] { 83 | -webkit-appearance: none; 84 | } 85 | 86 | a, 87 | button, 88 | input[type="submit"] { 89 | color: var(--fg); 90 | text-decoration: none; 91 | cursor: pointer; 92 | } 93 | 94 | button:hover, 95 | input[type="submit"].card:hover { 96 | cursor: pointer; 97 | opacity: .75; 98 | } 99 | 100 | .bg { 101 | background: var(--bg); 102 | } 103 | 104 | .frost { 105 | background: var(--frost); 106 | } 107 | 108 | .paper { 109 | background: var(--paper); 110 | } 111 | 112 | /* polyx/mira */ 113 | 114 | body { 115 | max-width: 840px; 116 | margin: 0 auto; 117 | } 118 | 119 | header { 120 | display: flex; 121 | flex-direction: row; 122 | justify-content: space-between; 123 | align-items: center; 124 | padding: 0 12px; 125 | } 126 | 127 | .title { 128 | font-weight: bold; 129 | } 130 | 131 | .inputGroup { 132 | display: flex; 133 | flex-direction: row; 134 | width: 100%; 135 | margin-bottom: .4em; 136 | } 137 | 138 | .inputGroup:last-child { 139 | margin-bottom: 0; 140 | } 141 | 142 | .inputGroup .entries { 143 | display: flex; 144 | flex-direction: column; 145 | width: 100%; 146 | } 147 | 148 | .searchBar { 149 | flex-grow: 1; 150 | display: flex; 151 | flex-direction: row; 152 | justify-content: space-between; 153 | align-items: center; 154 | } 155 | 156 | .searchInput { 157 | /* webkit inserts weird margin */ 158 | margin-right: -3px; 159 | width: 0; 160 | flex-grow: 1; 161 | } 162 | 163 | .searchButton, 164 | .addButton { 165 | margin: 0; 166 | } 167 | 168 | .split-h { 169 | display: flex; 170 | flex-direction: row; 171 | align-items: flex-start; 172 | justify-content: space-between; 173 | width: 100%; 174 | } 175 | 176 | .split-v { 177 | display: flex; 178 | flex-direction: column; 179 | align-items: flex-start; 180 | justify-content: flex-start; 181 | } 182 | 183 | .buttonFooter { 184 | padding: 8px 12px; 185 | margin: 12px -12px -8px -12px; 186 | } 187 | 188 | .buttonArea { 189 | display: flex; 190 | flex-direction: row; 191 | align-items: center; 192 | justify-content: flex-start; 193 | } 194 | 195 | .contact-button { 196 | background: var(--paper); 197 | padding: 4px 6px; 198 | } 199 | 200 | .left .contact-button { 201 | margin-right: 8px; 202 | } 203 | 204 | .right .contact-button { 205 | margin-left: 8px; 206 | } 207 | 208 | .contact-list { 209 | margin-top: -12px; 210 | } 211 | 212 | .contact-item { 213 | cursor: pointer; 214 | } 215 | 216 | .contact-item.notEditing:hover { 217 | opacity: .75; 218 | } 219 | 220 | .contact-item .card { 221 | background: var(--bg); 222 | } 223 | 224 | .contact-single-items, 225 | .contact-multi-items { 226 | box-sizing: border-box; 227 | width: 100%; 228 | } 229 | 230 | .contact-label { 231 | min-width: 2.5em; 232 | margin-right: .5em; 233 | color: var(--light); 234 | text-align: right; 235 | } 236 | 237 | .isEditing .contact-label { 238 | padding: 4px 6px; 239 | } 240 | 241 | .contact-input, 242 | .contact-add-button { 243 | background: var(--frost); 244 | padding: 4px 6px; 245 | display: block; 246 | margin-bottom: .25em !important; 247 | } 248 | 249 | .contact-input:focus, 250 | .contact-add-button:hover { 251 | background: var(--bg); 252 | } 253 | 254 | textarea.contact-input { 255 | height: 6em; 256 | resize: none; 257 | } 258 | 259 | footer { 260 | margin: 12px 0; 261 | text-align: center; 262 | width: 100%; 263 | } 264 | 265 | .m0 { 266 | margin: 0; 267 | } 268 | 269 | .p0 { 270 | padding: 0; 271 | } 272 | 273 | @media only screen and (min-width: 42em) { 274 | .contact-single-items { 275 | padding-right: 1em; 276 | } 277 | } 278 | @media only screen and (max-width: 42em) { 279 | .editArea { 280 | flex-direction: column; 281 | } 282 | .contact-single-items { 283 | margin-bottom: .4em; 284 | } 285 | } 286 | 287 | @keyframes loader-slide { 288 | 0% { 289 | transform: scaleX(1) translateX(-100%); 290 | } 291 | 50% { 292 | transform: translateX(0); 293 | } 294 | 100% { 295 | transform: scaleX(1) translateX(100%); 296 | } 297 | } 298 | 299 | .loader { 300 | position: fixed; 301 | top: 0; 302 | left: 0; 303 | right: 0; 304 | width: 100%; 305 | display: block; 306 | background: var(--light); 307 | height: 4px; 308 | z-index: 10; 309 | 310 | animation: loader-slide linear 1s infinite; 311 | animation-direction: alternate; 312 | } 313 | -------------------------------------------------------------------------------- /static/install_deta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdelhai/mira/aaf5eb6aef3a0e74d34cbd7bd55e772cbc0ef5e4/static/install_deta.png -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | const { 2 | Record, 3 | StoreOf, 4 | Component, 5 | ListOf, 6 | } = window.Torus; 7 | 8 | const DATA_ORIGIN = '/data'; 9 | 10 | const PAGIATE_BY = 20; 11 | 12 | const TODAY_ISO = (new Date()).toISOString().slice(0, 10); 13 | 14 | class Contact extends Record { 15 | 16 | singleProperties() { 17 | return [ 18 | ['name', 'name', 'name'], 19 | ['place', 'place', 'place'], 20 | ['work', 'work', 'work'], 21 | ['twttr', 'twttr', '@username'], 22 | ['last', 'last', 'last met...'], 23 | ['notes', 'notes', 'notes', true], 24 | ]; 25 | } 26 | 27 | multiProperties() { 28 | return [ 29 | ['tel', 'tel', 'tel'], 30 | ['email', 'email', 'email'], 31 | ['mtg', 'mtg', 'meeting', true], 32 | ] 33 | } 34 | 35 | } 36 | 37 | class ContactStore extends StoreOf(Contact) { 38 | 39 | init(...args) { 40 | this.super.init(...args); 41 | } 42 | 43 | get comparator() { 44 | return contact => { 45 | // ? is a special sentinel value that belongs at top of list 46 | if (contact.get('name') === '?') { 47 | return -Infinity; 48 | } 49 | 50 | const last = contact.get('last'); 51 | if (!last) { 52 | return 0; 53 | } 54 | 55 | const lastDate = new Date(last); 56 | return -lastDate.getTime(); 57 | } 58 | } 59 | 60 | async fetch() { 61 | const data = await fetch(DATA_ORIGIN).then(resp => resp.json()); 62 | if (!Array.isArray(data)) { 63 | throw new Error(`Expected data to be an array, got ${data}`); 64 | } 65 | 66 | this.reset(data.map(rec => new this.recordClass({ 67 | ...rec, 68 | id: rec.id, 69 | }))); 70 | } 71 | 72 | async persist() { 73 | return fetch(DATA_ORIGIN, { 74 | method: 'POST', 75 | body: JSON.stringify(this.serialize()), 76 | }); 77 | } 78 | 79 | } 80 | 81 | class ContactItem extends Component { 82 | 83 | init(record, remover, {persister, sorter}) { 84 | this.isEditing = false; 85 | 86 | this.inputs = {}; 87 | 88 | this.toggleIsEditing = this.toggleIsEditing.bind(this); 89 | this.toggleIsEditingSilently = this.toggleIsEditingSilently.bind(this); 90 | this.handleDeleteClick = this.handleDeleteClick.bind(this); 91 | this.fillToday = this.fillToday.bind(this); 92 | this.handleInput = this.handleInput.bind(this); 93 | this.persistIfEnter = this.persistIfEnter.bind(this); 94 | 95 | this.remover = () => { 96 | remover(); 97 | persister(); 98 | } 99 | this.persister = persister; 100 | this.sorter = sorter; 101 | 102 | this.bind(record, data => this.render(data)); 103 | } 104 | 105 | addMultiItem(label) { 106 | this.inputs[label] = (this.inputs[label] || []).concat(''); 107 | this.render(); 108 | } 109 | 110 | toggleIsEditing(evt) { 111 | if (evt) { 112 | evt.stopPropagation(); 113 | } 114 | 115 | if (this.isEditing) { 116 | // remove empty items 117 | for (const prop of Object.keys(this.inputs)) { 118 | const item = this.inputs[prop]; 119 | if (item == null) { 120 | continue; 121 | } 122 | 123 | if (Array.isArray(item)) { 124 | this.inputs[prop] = item.map(it => it.trim()).filter(it => it !== ''); 125 | } else { 126 | this.inputs[prop] = item.toString().trim(); 127 | } 128 | } 129 | 130 | this.record.update(this.inputs); 131 | this.persister(); 132 | this.sorter(); 133 | } else { 134 | this.inputs = this.record.serialize(); 135 | } 136 | 137 | this.toggleIsEditingSilently(); 138 | } 139 | 140 | toggleIsEditingSilently(evt) { 141 | if (evt) { 142 | evt.stopPropagation(); 143 | } 144 | 145 | this.isEditing = !this.isEditing; 146 | this.render(); 147 | } 148 | 149 | handleDeleteClick(evt) { 150 | if (window.confirm(`Delete ${this.record.get('name')}?`)) { 151 | this.remover(); 152 | } 153 | } 154 | 155 | fillToday(evt) { 156 | this.inputs.last = TODAY_ISO; 157 | this.render(); 158 | } 159 | 160 | handleInput(evt) { 161 | const propIdx = evt.target.getAttribute('name'); 162 | if (propIdx.includes('-')) { 163 | // multi style prop 164 | const [prop, idx] = propIdx.split('-'); 165 | this.inputs[prop][idx] = evt.target.value; 166 | } else { 167 | // single style prop 168 | this.inputs[propIdx] = evt.target.value; 169 | } 170 | this.render(); 171 | } 172 | 173 | persistIfEnter(evt) { 174 | if (evt.key === 'Enter' && (evt.ctrlKey || evt.metaKey)) { 175 | this.toggleIsEditing(); 176 | } 177 | } 178 | 179 | compose(data) { 180 | const inputGroup = (label, prop, placeholder, isMultiline = false) => { 181 | const val = this.isEditing ? this.inputs[prop] : data[prop]; 182 | 183 | if (!this.isEditing && !val) { 184 | return null; 185 | } 186 | 187 | const tag = isMultiline ? 'textarea' : 'input'; 188 | 189 | return jdom`
190 | 191 |
192 | ${this.isEditing ? ( 193 | jdom`<${tag} type="text" name="${prop}" value="${val || ''}" 194 | class="contact-input" 195 | autocomplete="none" 196 | onkeydown="${this.persistIfEnter}" 197 | oninput="${this.handleInput}" 198 | placeholder="${placeholder}" />` 199 | ) : ( 200 | jdom`
${val}
` 201 | )} 202 |
203 |
`; 204 | } 205 | 206 | const inputMultiGroup = (label, prop, placeholder, isMultiline = false) => { 207 | const vals = (this.isEditing ? this.inputs[prop] : data[prop]) || []; 208 | 209 | if (!this.isEditing && vals.length === 0) { 210 | return null; 211 | } 212 | 213 | const tag = isMultiline ? 'textarea' : 'input'; 214 | 215 | return jdom`
216 | 217 |
218 | ${this.isEditing ? ( 219 | vals.map((t, idx) => jdom`<${tag} type="text" name="${prop}-${idx}" value="${t || ''}" 220 | class="contact-input" 221 | autocomplete="none" 222 | onkeydown="${this.persistIfEnter}" 223 | oninput="${this.handleInput}" 224 | placeholder="${placeholder}" />`) 225 | .concat(jdom``) 227 | ) : ( 228 | vals.map(t => jdom`${t.substr(0, 256)}`) 229 | )} 230 |
231 |
`; 232 | } 233 | 234 | return jdom`
  • 236 |
    237 |
    238 | ${this.record.singleProperties().map(args => { 239 | return inputGroup(...args) 240 | })} 241 |
    242 |
    243 | ${this.record.multiProperties().map(args => { 244 | return inputMultiGroup(...args) 245 | })} 246 |
    247 |
    248 | ${this.isEditing ? jdom`
    249 |
    250 | 251 |
    252 |
    253 | 254 | 255 | 256 |
    257 |
    ` : null} 258 |
  • `; 259 | } 260 | 261 | } 262 | 263 | class ContactList extends ListOf(ContactItem) { 264 | 265 | compose(items) { 266 | return jdom`