├── .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 | [](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 | 
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``;
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``;
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`` : null}
258 | `;
259 | }
260 |
261 | }
262 |
263 | class ContactList extends ListOf(ContactItem) {
264 |
265 | compose(items) {
266 | return jdom`