├── .gitignore ├── .prettierrc ├── README.md ├── bin └── unbuffered-elm-make ├── config ├── development.ts └── production.ts ├── docs ├── media │ └── demo_frontpage.png ├── notes │ ├── views.md │ └── weird_map.md └── worklog.md ├── elm-package.json ├── license.md ├── now.json ├── package-lock.json ├── package.json ├── src ├── Data │ ├── Entry.elm │ ├── Session.elm │ └── User.elm ├── Leaflet │ ├── Ports.elm │ ├── Types.elm │ └── index.ts ├── Main.elm ├── Map.elm ├── Page │ ├── Entry.elm │ ├── Errored.elm │ ├── FullMap.elm │ ├── Home.elm │ ├── Login.elm │ └── NotFound.elm ├── Pouch │ ├── Ports.elm │ ├── index.ts │ └── types.ts ├── Request │ ├── Entry.elm │ └── Helpers.elm ├── Route.elm ├── Util.elm ├── Views │ ├── General.elm │ ├── Icons.elm │ └── Page.elm ├── assets │ ├── css │ │ └── styles.scss │ ├── icon.png │ └── logo.svg ├── export │ ├── export.ts │ └── types.ts ├── index.ejs ├── index.ts ├── types.d.ts └── util │ └── util.ts ├── todo.md ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff/ 2 | repl-temp-* 3 | node_modules/ 4 | yarn-error.log 5 | .vscode 6 | 7 | elm.js 8 | dist 9 | 10 | # PouchDB 11 | /db 12 | /pouchdb-server 13 | log.txt 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "semi": true, 4 | "singleQuote": true, 5 | "useTabs": false, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ephemeral 🍃 2 | 3 | :warning: This version of Ephemeral is deprecated and archived. You can find the new repository at [https://github.com/fpapado/ephemeralnotes](https://github.com/fpapado/ephemeralnotes) and the new app at [https://ephemeralnotes.app](https://ephemeralnotes.app) :warning: 4 | 5 | Ephemeral is a progressive web app for writing down cards, tracking time added and location. 6 | The original motivation was writing down words and their translations as I encounter them, when travelling or moving to a new city. 7 | I also wanted to be able to access my notes easily, see relevant data, and be able to extend visualisations programatically. 8 | 9 | Ephemeral is mostly written in [Elm](http://elm-lang.org), with maps from [Leaflet](http://http://leafletjs.com/) and storage via [PouchDB](https://pouchdb.com/). 10 | It works offline and can synchronise your data! 11 | 12 | ![Ephemeral Demo](docs/media/demo_frontpage.png) 13 | 14 | ## Features 15 | - [X] Write down notes 16 | - [X] Works offline 17 | - [X] You can add it to your home screen on Android 18 | - [X] Notes can be synchronised to a remote database, and across devices 19 | - [X] Export to Anki deck 20 | 21 | ## Development: Frontend 22 | Webpack is used to bundle everything on the front-end. You can run it with: 23 | ```shell 24 | npm install 25 | npm run dev 26 | ``` 27 | 28 | Change files in `src/` as appropriate 29 | 30 | Generally, if you need to handle PouchDB and Leaflet maps, use the Javascript side. 31 | For UI rendering and logic, prefer Elm and synchronise over ports as appropriate 32 | There is a mix of JS (used to handle PouchDB, Leaflet maps) in `src/index.js`, which should probably be restructured soon. 33 | 34 | ## Production: Frontend 35 | ```shell 36 | npm run prod 37 | ``` 38 | ... and your files should be bundled in `dist/`. 39 | Deploy `dist/` with your favourite static vendor. 40 | 41 | Webpack PWA and Webpack offline customisation 42 | 43 | ## PWA, Offline Data 44 | A Progressive Web App works offline via a Service Worker, which caches assets and scripts. 45 | - Currently, Service Workers do not work in Safari, so the app won't load offline :/ 46 | - If you are on Chrome or Firefox, then the app will load offline 47 | - Furthermore, if you are on Android, you can add the app to your home screen, and it will launch independently 48 | 49 | For storage, Ephemeral uses PouchDB, which is a local, persisted database in your browser. 50 | PouchDB can sync to a remote database for backup, since the local IndexedDB/WebSQL (which PouchDB uses) can still be cleared on occasion. 51 | **By default, if a user is not logged in, the local PouchDB does not synchronise anywhere.** 52 | 53 | Service workers are generated with the [webpack-offline plugin](https://github.com/NekR/offline-plugin) 54 | 55 | An app manifest is generated with [webpack-manifest-plugin](https://github.com/danethurber/webpack-manifest-plugin) 56 | 57 | If you want to learn more about Progressive Webapps, [here is an intro by 58 | Google](https://developers.google.com/web/progressive-web-apps/). 59 | 60 | ## Development DB 61 | You can either use a local CouchDB, but perhaps it is *simpler if you use the node `pouchdb-server` in development*. 62 | 63 | ```shell 64 | npm i -g pouchdb-server 65 | npm run pouchdb-server 66 | ``` 67 | 68 | By default, pouchdb-server will be run on port 5984 (a default), but you can specify which one it is in `package.json`. 69 | Similarly, you can specify the url and database in `config/development.js` and `config/production.js`. 70 | For production, you only need specify the URL, since the database changes per user (See `Production DB` below) 71 | 72 | ### Dev users 73 | #### Via Config 74 | You can manually create an admin user by creating and editing `pouchdb-server/config.json` 75 | 76 | ```json 77 | { 78 | "admins": { 79 | "dev": "123" 80 | } 81 | } 82 | ``` 83 | Note that the file will be overwritten with a hash by the server once run. 84 | 85 | #### Via Fauxton 86 | Alternatively (perhaps easier), you can navigate to `http://localhost:5984/_utils` (or whatever the pouchdb-server URL is), to access the management interface. 87 | - Click on `Admin Party` 88 | - You will be prompted to create an admin user 89 | - You can then add users as you wish via the `_users` table. 90 | 91 | ## Production DB 92 | It is best if you use CouchDB for production. You have the option of self-installing or using a service like Cloudant. I went with self-hosting; see below for pointers. 93 | 94 | ### Couch-per-user 95 | A common strategy in CouchDB is to isolate databases per user, such that they can access and replicate only their documents. 96 | This might sound daunting, but databases in CouchDB are more lightweight document collections than you'd think. 97 | 98 | To achieve this, we use the `couch-per-user` erlang plugin, which is simple to install. 99 | If you can't locate your CouchDB plugin folder, then check `/etc/couchdb/default.ini`. 100 | 101 | Couch-per-user creates datbases of the kind userdb-{hex of username}, so `index.js` has `initDB()` to make this transformation. 102 | If you use a different scheme, change the code in `initDB()` as appropriate! 103 | 104 | [More info on authentication recipes](https://github.com/pouchdb-community/pouchdb-authentication#couchdb-authentication-recipes) 105 | 106 | ### Self-hosting CouchDB 107 | #### Nutshell 108 | In case you want to self-host with SSL, here are the basic steps: 109 | 110 | [Basics Guide](https://www.digitalocean.com/community/tutorials/how-to-install-couchdb-and-futon-on-ubuntu-14-04) 111 | - Get a VPS, I went with Ubuntu 16.04 on Digital Ocean 112 | - Install CouchDB (Ubuntu has a ppa) 113 | - Secure installation with custom user 114 | - Disable Admin-Party 115 | 116 | Open to the world 117 | - Access `local.ini` or config via the web interface (forwarded over SSL per guide above), and change the bind address to the server's IP. 118 | - You can no longer access over the tunnel but `http://ipaddress:5984/_utils` should do it 119 | - Try `http://ipaddress:5984/_utils/fauxton/` if you want a cleaner interface 120 | 121 | DNS, [Letsencrypt, SSL listening](http://verbally.flimzy.com/configuring-couchdb-1-6-1-letsencrypt-free-ssl-certificate-debian-8-jessie/) (you will have to change the addresses as appropriate) 122 | - Configure DNS to point to your server 123 | - Provision certificate; letsEncrypt needs a web server to verify ownership 124 | - Set up CouchDB's SSL termination 125 | - (Optionally) disable http access 126 | - Try `https://domain:6984/_utils/` and see if you're good 127 | 128 | [Couchperuser setup](https://github.com/etrepum/couchperuser) 129 | - If you can't locate your CouchDB plugin folder, then check `/etc/couchdb/default.ini`. 130 | 131 | [Docker](https://github.com/apache/couchdb-docker) 132 | - There is also a docker option, which I haven't tried, but seems good 133 | - ... also comes with couch-per-user installed! 134 | 135 | 136 | ## License 137 | 138 | MIT © Fotis Papadogeorgopoulos 139 | -------------------------------------------------------------------------------- /bin/unbuffered-elm-make: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Fakes elm-make into printing colors when 3 | # run by elm-webpack-loader 4 | # To install unbuffer on macOS: 5 | # $ brew install expect 6 | unbuffer node_modules/.bin/elm-make $@ 7 | -------------------------------------------------------------------------------- /config/development.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | name: 'development', 3 | environment: 'development', 4 | 5 | couchUrl: 'http://localhost:5984/', 6 | dbName: 'ephemeral' 7 | }; 8 | -------------------------------------------------------------------------------- /config/production.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | name: 'production', 3 | environment: 'production', 4 | 5 | couchUrl: 'https://ephemeral.fltbx.xyz:6984/', 6 | dbName: '' 7 | }; 8 | -------------------------------------------------------------------------------- /docs/media/demo_frontpage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fpapado/ephemeral/3c3984546e316ee36dbc1895cb1e4d766794eabe/docs/media/demo_frontpage.png -------------------------------------------------------------------------------- /docs/notes/views.md: -------------------------------------------------------------------------------- 1 | # The following changes were made to our views 2 | 3 | ## Html.lazy for most things 4 | No need to perform calculations if our lists of items have not changed. 5 | 6 | ## Html.Keyed.node for the entry list 7 | This was advised online, and used wherever things are added/removed. 8 | We already have unique ids for the entries, so it seemed natural. 9 | Deletion does seem faster on the profile. 10 | TODO: Potentially fixes a visual bug where deletion button would remain highlighted on non-deleted node 11 | 12 | ## Using Dict instead of List 13 | I am not sure if this is a huge difference, but it used to be that Entries were a list and we'd manually List.filter when there was an update. On insertions, we'd just append to the start. 14 | This will likely reduce performance when adding items, but access and updates are faster. 15 | Does it actually make a difference? Eh, probably not so much. 16 | It does seem to affect the startup time slightly. 17 | 18 | ## TODO 19 | There is currently a big drop when adding all the markers to the map, partly because there is a message over a Port for each one. 20 | This is an issue when starting up the UI, where we need to fetch from the DB and initialise both the list and the markers. Similarly for sync updates. 21 | [] Should make it such that there is a single Port message that goes out, with a list of markers to add 22 | [] Compare 23 | 24 | ## Benchmarks? 25 | I would really like to see a side-by side of the commit with these changes, and the previous one. 26 | -------------------------------------------------------------------------------- /docs/notes/weird_map.md: -------------------------------------------------------------------------------- 1 | I ran into an issue with the map, where locally on dev mode, starting directly 2 | at /map (the fullscreen map page), Leaflet fails to initalise the correct size. 3 | In fact, this also happens in Home, but ameliorated by the slower SetMarkers 4 | which comes after and also calls invalidateSize(). This looks like a race 5 | condition, but here's the kicker: it is fine in production!. I wonder if it 6 | has to do with hot-loading / how webpack loads things locally, since the 7 | Port Cmd is passed correctly and ostensibly after the map has initialised, 8 | and yet also fails to invalidateSize. The map does not have the correct size 9 | in home if I remove the invalidateSize() from SetMarkers, which confirms 10 | my suspicion that it is not a map FullscreenToggle issue. 11 | -------------------------------------------------------------------------------- /docs/worklog.md: -------------------------------------------------------------------------------- 1 | # Next 2 | - [X] Full screen map 3 | - [X] "Hide map" Msg & port command 4 | - [X] Navigation in map view 5 | - [ ] ZoomPanOptions overhaul 6 | 7 | - [ ] Export location in CSV 8 | - [ ] Not loading markers and items on initial load in production 9 | - [ ] Check loggedIn state consistently, esp. initial load 10 | 11 | - [ ] Separate MapState 12 | - [ ] Separate EntryState 13 | - [ ] Load Entries on initial visit 14 | - Might need some "loaded" signal via port 15 | - [ ] Port merge for PouchDB 16 | - [ ] Search 17 | - [ ] Move some of the Home stuff to a Pouch module 18 | - [ ] Use Pouch as source of truth, only remove / update items based on Pouch sub messages 19 | - [ ] Rename Login module -> Session 20 | - [ ] InterUI font 21 | 22 | # Other 23 | More Navigation / routing stuff 24 | - [ ] Hide map where not needed (load dynamically, even?) 25 | - [ ] Investigate Save/Commit and Geolocation 26 | 27 | - [ ] Fix session check 28 | - Currently, if logged in and page reloads, shown as logged out 29 | 30 | - [ ] Port branch 31 | - [ ] DB helpers 32 | 33 | - [] Entry Page: Confirmation on success 34 | - [] In general, messaging service 35 | 36 | - [ ] Make map additions directly in JS from the DB stream? 37 | - As in, make DB the single source of truth 38 | 39 | # Port branch 40 | - [X] TypeScript 41 | - [~] Union Types for messages 42 | - [~] Split into modules, taking the app as argument 43 | - [~] Everything a stream 44 | - [~] DB helpers? 45 | 46 | - [ ] Port architecture, merging ports 47 | - [ ] e.g. could have: 48 | translatePouchUpdate : (Result String Entry -> msg) -> (Result Sting String -> msg) -> Value -> msg 49 | decodePouchUpdate updateMsg deleteMsg fromPort = ... 50 | 51 | - [ ] pouchToElm, pouchFromElm 52 | -> API with cases etc there. 53 | - [X] toLeaflet; would require encoders for Leaflet.Types 54 | - [?] move port encoders to Port? 55 | - [ ] Debatable whether to propagate error in Request.Entry or return empty Dict 56 | - [ ] Generally, errors from Ports 57 | 58 | # Settings Page 59 | - [ ] DB url 60 | - [ ] Default location 61 | 62 | # Other ideas 63 | - [ ] Fonts, font loading, caching 64 | - Page.initData Cmd convention? Would avoid having to send Request.listEntries directly on Main.elm etc. 65 | - Style-elements? 66 | - offline indicator 67 | - Friendly 404 68 | 69 | - [ ] Full(er) CRUD 70 | - [ ] Delete message 71 | - [ ] with confirmation message (initDelete, confirmDelete); modal? 72 | - [ ] Check marker removal in batch updates 73 | 74 | - [ ] Add Edit back 75 | - [ ] Scroll up on edit 76 | - [ ] Cancel button on edit 77 | - [ ] Popup for editing? 78 | - [ ] Edit location 79 | - [ ] Edit time added 80 | 81 | - Export: 82 | - [X] CSV (in-browser) 83 | - [X] Anki (remote/micro) 84 | - [ ] Specify deck name? 85 | - [ ] `micro-anki` extension 86 | - [ ] deck name would be important for import syncing, I think 87 | 88 | - [ ] Factor things out of Page.Login into Request.Session or something (esp. login/logout and decoders) 89 | 90 | - [ ] UX 91 | - [ ] Loading, disabled button 92 | - [ ] Basically RemoteData modelling 93 | - [ ] Offline/online status 94 | - [ ] Custom 404 and offline page 95 | - [ ] Message on successful login 96 | - [ ] Ditto on failed 97 | - [ ] Place get errors 98 | - [ ] Validations 99 | - [ ] Redirect on Login/Out 100 | - [ ] Replication status 101 | 102 | - [ ] Signup? 103 | 104 | - [ ] CheckAuth periodically? 105 | - [ ] Or, send "LogOut" over port if unauthorized/unauthenticated error? 106 | 107 | 108 | - [ ] Organise JS 109 | - [ ] Clean console logs 110 | - [X] Prettier for JS 111 | - [ ] Split/organise JS 112 | - [ ] Add XO 113 | 114 | 115 | - [ ] Better Pouch 116 | - [ ] Port architecture 117 | - [ ] API for Pouch access in JS 118 | - [ ] DateTime or custom based id? https://pouchdb.com/2014/06/17/12-pro-tips-for-better-code-with-pouchdb.html 119 | - [ ] Upsert https://pouchdb.com/guides/conflicts.html 120 | - [ ] Conflict resolution 121 | - [ ] Upsert? 122 | - [ ] Error handling for Pouch 123 | - [ ] Particularly, replication errors over ports 124 | - [ ] On JS filter, send on port 125 | - [ ] On Elm accept { error: String } on port, display 126 | - [ ] Dismissable 127 | 128 | 129 | - [ ] Handle errors from ports (Entry changes, Login) 130 | - [ ] How? 131 | 132 | - [ ] update README 133 | 134 | - [X] Merge NewEntry and UpdatedEntry (same functionality, since they both remove the entry with the id) 135 | - [ ] Eventually use Dict for entries 136 | 137 | - [ ] Port architecture 138 | - [ ] Single port per responsibility, parse on either side? 139 | - [ ] For instance, log in /out 140 | - [ ] Full CRUD 141 | 142 | - [ ] Errors over ports when creation/deletion fails? 143 | 144 | # More UI/UX 145 | - [ ] UI/UX Pass 146 | - [ ] Error view, English 147 | - [ ] Show message on Geolocation error, that a default position was used 148 | - [ ] Dismiss errors 149 | - [ ] "Success" message 150 | - [ ] Flexbox for flight buttons 151 | - [ ] Packery with 4 items and 3 columns can be wonky 152 | - [ ] Later: Grid and fallbacks 153 | 154 | - [ ] Refresh/cleanup 155 | - [X] 'Card' view for cards 156 | - [X] Grid? 157 | - [ ] Horizontal scroll? 158 | - [LATER] Flip view for cards 159 | - [LATER] Show/collapse information for cards etc. 160 | 161 | - [ ] About page 162 | - [ ] Spider spread for map 163 | - [ ] Html.lazy check further 164 | 165 | # Later 166 | - [ ] Bring-your-own DB 167 | - [ ] Generally, host-your-own option 168 | - [ ] Guide for this 169 | - [ ] "Deploy micro-anki to now" guide 170 | - [ ] Configurable micro-anki url 171 | - [X] Export to Anki 172 | - [ ] Translation Helper 173 | - [ ] Large sync updates; markers? 174 | - [ ] Move other marker operations to toLeaflet 175 | - Quite the effort atm, especially encoding everything manually 176 | - [X] Own CouchDB? 177 | - [ ] Bug in pouchdb-authentication? if only url is provided (no path after) 178 | then the xhr request goes to http://_users instead of http://dburl/_users 179 | - [ ] More info on User, getUser() when checking auth, with id from getAuth() 180 | - [X] PouchDB Auth 181 | - [ ] migrations? 182 | - [ ] Search 183 | - [ ] Translation Helper 184 | - [ ] Filtering on PouchDB messages 185 | - [ ] Save revision of Entry; used for updates 186 | - [ ] Timestamp for ID? 187 | - [ ] Full CRUD 188 | - [ ] Authorization & Authentication 189 | - [ ] Location picker 190 | - [ ] Merge markers and Entries? 191 | - [ ] Filter based on date, range 192 | - [ ] Critical CSS 193 | - [ ] PurifyCSS and Webpack 194 | - [X] Leaflet integration 195 | - [ ] Set up pouchdb-server locally and automatically with dev account 196 | 197 | 198 | # Bundle Stuff 199 | - [ ] Remove console (there is a babel plugin for this) 200 | - [ ] Scope hoisting 201 | - [ ] Try babily? 202 | - [ ] Serve font async / non-blocking 203 | - locally? 204 | - currently import(..) in critical CSS blocks 205 | - https://github.com/bramstein/fontfaceobserver 206 | - https://www.filamentgroup.com/lab/font-events.html 207 | - NOTE: Do this once fonts are settled 208 | 209 | - [ ] Cursive font fallback 210 | - [ ] lazy-load leaflet on shouldshowmap? 211 | - [ ] Remove console logs with babel loader transform? 212 | 213 | - [ ] New Penthouse 214 | 215 | # Probably not important 216 | - [ ] babel-polyfill and runtime? 217 | - [ ] Promise polyfill (or use lie, since pouchdb uses it)? 218 | - [ ] whatwg-fetch polyfill check 219 | - [ ] Update readme with new npm scripts 220 | - [ ] Caching considerations 221 | - [ ] Delayed loading spinner? 222 | 223 | - [X] SW script compression 224 | - [X] Add anki export to additional cache instead of main 225 | - [X] Webpack separate runtime 226 | - [X] Vendor bundle 227 | - [X] lazy-load anki stuff 228 | - [X] Compile separate modules and nomodules 229 | - Tested, not much difference (I guess leaflet and Pouchdb-browser already compiled) 230 | - [] Check later if we can recompile them 231 | 232 | # Moonshot 233 | - [ ] Have shared "channels" (db with group-write/group-red) 234 | 235 | # Dev 236 | - [X] NPM scripts for building, starting elm-live 237 | 238 | # Refactoring 239 | - [ ] View.elm signatures 240 | - [ ] Figure out where to put encodeEntry, especially b/c of "config" construct (duplicated atm) 241 | NOTE: A bit redundant to have "pages" atm. It is more like separating the updates, views etc. rather than routes (hence sharing a view in Main) 242 | - [ ] When doing put(), I disregard the rev from the Elm side, since the get() has the latest already 243 | 244 | // TODO: get info from cancelReplication Port (or listen for logout event), pause replication 245 | // syncHandler.cancel(); 246 | 247 | # How to work offline 248 | ## Manual / optimistic 249 | Always try to save online, add to queue and sync otherwise 250 | |> Would this be too much to track? We'd need ports etc. anyway, and to handle IndexedDB 251 | |> At that point, might as well use Pouch? 252 | 253 | ## PouchDB 254 | I could "just" wrap PouchDB and treat it as my store, letting it do its thing 255 | 256 | # Request module 257 | I keeping the Requests for decoding the subs from ports in the Request, accepting a toMsg that will be triggered on the caller. 258 | This allows some separation of concerns, and isn't unlike how Request keeps the Http requests without doing the actual sending, but allowing to specify which Msg will be generated. 259 | 260 | # Setting up CouchDB 261 | Links: 262 | 263 | https://github.com/pouchdb-community/pouchdb-authentication#couchdb-authentication-recipes 264 | http://docs.couchdb.org/en/latest/intro/security.html#authentication-database 265 | https://www.digitalocean.com/community/tutorials/how-to-install-couchdb-and-futon-on-ubuntu-14-04 266 | http://verbally.flimzy.com/configuring-couchdb-1-6-1-letsencrypt-free-ssl-certificate-debian-8-jessie/ 267 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "summary": "Ephemeral", 4 | "repository": "https://github.com/fpapado/ephemeral.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "./src" 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", 12 | "elm-community/json-extra": "2.3.0 <= v < 3.0.0", 13 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 14 | "elm-lang/dom": "1.1.1 <= v < 2.0.0", 15 | "elm-lang/geolocation": "1.0.2 <= v < 2.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 18 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 19 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0", 22 | "rluiten/elm-date-extra": "9.0.1 <= v < 10.0.0", 23 | "rtfeldman/elm-validate": "1.1.3 <= v < 2.0.0" 24 | }, 25 | "elm-version": "0.18.0 <= v < 0.19.0" 26 | } 27 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Fotis Papadogeorgopoulos 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ephemeral", 3 | "alias": "ephemeral", 4 | "region": "eu-west-1", 5 | "type": "static" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ephemeral-offline", 3 | "version": "1.0.0", 4 | "description": "An Elm front-end for note-taking", 5 | "dependencies": { 6 | "@typed/either": "^3.3.0", 7 | "@types/csv-stringify": "^1.4.1", 8 | "@types/file-saver": "^1.3.0", 9 | "@types/leaflet": "^1.2.3", 10 | "@types/pouchdb": "^6.3.2", 11 | "csv-stringify": "^1.0.4", 12 | "file-saver": "^1.3.3", 13 | "leaflet": "^1.2.0", 14 | "pouchdb-authentication": "^1.0.0", 15 | "pouchdb-browser": "^6.4.1", 16 | "promise-polyfill": "6.0.2", 17 | "tachyons": "^4.9.0", 18 | "whatwg-fetch": "^2.0.3", 19 | "xstream": "^11.0.0" 20 | }, 21 | "devDependencies": { 22 | "babel-core": "^6.25.0", 23 | "babel-loader": "^7.1.0", 24 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 25 | "babel-plugin-transform-remove-console": "^6.8.5", 26 | "babel-preset-env": "^1.5.2", 27 | "bootstrap-loader": "^2.1.0", 28 | "chokidar-cli": "^1.2.0", 29 | "clean-webpack-plugin": "^0.1.17", 30 | "copy-webpack-plugin": "^4.0.1", 31 | "critical": "^0.9.1", 32 | "css-loader": "^0.28.4", 33 | "elm-hot-loader": "^0.5.4", 34 | "elm-webpack-loader": "^4.3.1", 35 | "extract-text-webpack-plugin": "^3.0.0", 36 | "file-loader": "^0.11.2", 37 | "html-webpack-plugin": "^2.29.0", 38 | "md5": "^2.2.1", 39 | "name-all-modules-plugin": "^1.0.1", 40 | "node-sass": "^4.5.3", 41 | "offline-plugin": "^4.8.3", 42 | "postcss-loader": "^2.0.6", 43 | "pouchdb-server": "^2.3.7", 44 | "prettier": "^1.9.2", 45 | "resolve-url-loader": "^2.0.3", 46 | "sass-lint": "^1.12.1", 47 | "sass-loader": "^6.0.6", 48 | "style-loader": "^0.18.2", 49 | "svg-inline-loader": "^0.8.0", 50 | "svg-url-loader": "^2.2.0", 51 | "ts-loader": "^3.0.5", 52 | "typescript": "^2.5.3", 53 | "uglifyjs-webpack-plugin": "^1.0.0-beta.2", 54 | "url-loader": "^0.5.9", 55 | "webpack": "^3.3.0", 56 | "webpack-bundle-analyzer": "^2.9.0", 57 | "webpack-dashboard": "^0.4.0", 58 | "webpack-dev-server": "^2.5.1", 59 | "webpack-merge": "^4.1.0", 60 | "webpack-pwa-manifest": "^3.1.7" 61 | }, 62 | "scripts": { 63 | "test": "echo \"Error: no test specified\" && exit 1", 64 | "dev": "NODE_PATH=. webpack-dashboard -- webpack-dev-server --hot --inline --port 3000", 65 | "production:build": "NODE_PATH=. NODE_ENV=production webpack --progress --display-optimization-bailout && npm run inline-css", 66 | "inline-css": "critical dist/index.html --base dist/ --inline --minify > dist/index-critical.html && mv dist/index-critical.html dist/index.html", 67 | "postinstall": "elm-package install -y", 68 | "pouchdb-server": "pouchdb-server --config pouchdb-server/config.json --dir pouchdb-server", 69 | "deploy": "cd dist && now -A ../now.json && now alias -A ../now.json", 70 | "release": "npm run production:build && npm run deploy" 71 | }, 72 | "author": "fpapado ", 73 | "license": "ISC", 74 | "xo": { 75 | "space": true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Data/Entry.elm: -------------------------------------------------------------------------------- 1 | module Data.Entry exposing (..) 2 | 3 | import Date exposing (Date) 4 | import Date.Extra.Format exposing (utcIsoString) 5 | import Json.Encode 6 | import Json.Decode as Decode exposing (Decoder) 7 | import Json.Decode.Extra 8 | import Json.Decode.Pipeline as Pipeline exposing (decode, required) 9 | 10 | 11 | type alias Entry = 12 | { addedAt : Date 13 | , content : String 14 | , location : EntryLocation 15 | , translation : String 16 | , type_ : String 17 | , id : EntryId 18 | , rev : String 19 | } 20 | 21 | 22 | type alias EntryLocation = 23 | { latitude : Float 24 | , longitude : Float 25 | , accuracy : Float 26 | } 27 | 28 | 29 | 30 | -- SERIALISATION -- 31 | 32 | 33 | decodeEntry : Decoder Entry 34 | decodeEntry = 35 | -- PouchDB has these things sorted alphabetically 36 | decode Entry 37 | |> required "added_at" (Json.Decode.Extra.date) 38 | |> required "content" (Decode.string) 39 | |> required "location" (decodeEntryLocation) 40 | |> required "translation" (Decode.string) 41 | |> required "type" (Decode.string) 42 | |> required "_id" entryIdDecoder 43 | |> required "_rev" (Decode.string) 44 | 45 | 46 | decodeEntryLocation : Decoder EntryLocation 47 | decodeEntryLocation = 48 | decode EntryLocation 49 | |> required "latitude" (Json.Decode.Extra.parseFloat) 50 | |> required "longitude" (Json.Decode.Extra.parseFloat) 51 | |> required "accuracy" (Json.Decode.Extra.parseFloat) 52 | 53 | 54 | encodeEntry : Entry -> Json.Encode.Value 55 | encodeEntry record = 56 | Json.Encode.object 57 | [ ( "content", Json.Encode.string <| record.content ) 58 | , ( "translation", Json.Encode.string <| record.translation ) 59 | , ( "added_at", Json.Encode.string <| utcIsoString record.addedAt ) 60 | , ( "location", encodeEntryLocation <| record.location ) 61 | ] 62 | 63 | 64 | encodeEntryLocation : EntryLocation -> Json.Encode.Value 65 | encodeEntryLocation record = 66 | Json.Encode.object 67 | [ ( "latitude", Json.Encode.string <| toString record.latitude ) 68 | , ( "longitude", Json.Encode.string <| toString record.longitude ) 69 | , ( "accuracy", Json.Encode.string <| toString record.accuracy ) 70 | ] 71 | 72 | 73 | 74 | -- IDENTIFIERS -- 75 | 76 | 77 | type EntryId 78 | = EntryId String 79 | 80 | 81 | idToString : EntryId -> String 82 | idToString (EntryId id) = 83 | id 84 | 85 | 86 | entryIdDecoder : Decoder EntryId 87 | entryIdDecoder = 88 | Decode.map EntryId Decode.string 89 | -------------------------------------------------------------------------------- /src/Data/Session.elm: -------------------------------------------------------------------------------- 1 | module Data.Session exposing (Session) 2 | 3 | import Data.User as User exposing (User) 4 | 5 | 6 | type alias Session = 7 | { user : Maybe User } 8 | -------------------------------------------------------------------------------- /src/Data/User.elm: -------------------------------------------------------------------------------- 1 | module Data.User exposing (User) 2 | 3 | 4 | type alias User = 5 | { username : String } 6 | -------------------------------------------------------------------------------- /src/Leaflet/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Leaflet.Ports exposing (setView, toLeaflet) 2 | 3 | import Leaflet.Types exposing (LatLng, ZoomPanOptions, MarkerOptions) 4 | import Json.Encode exposing (Value) 5 | 6 | 7 | port setView : ( LatLng, Int, ZoomPanOptions ) -> Cmd msg 8 | 9 | 10 | port toLeaflet : Value -> Cmd msg 11 | -------------------------------------------------------------------------------- /src/Leaflet/Types.elm: -------------------------------------------------------------------------------- 1 | module Leaflet.Types 2 | exposing 3 | ( LatLng 4 | , ZoomPanOptions 5 | , defaultZoomPanOptions 6 | , MarkerOptions 7 | , defaultMarkerOptions 8 | , encodeMarkerOptions 9 | , encodeIconOptions 10 | , encodePoint 11 | , encodeLatLng 12 | , encodeZoomPanOptions 13 | ) 14 | 15 | import Json.Encode as E 16 | 17 | 18 | type alias LatLng = 19 | ( Float, Float ) 20 | 21 | 22 | encodeLatLng : LatLng -> E.Value 23 | encodeLatLng ( lat, lng ) = 24 | E.list 25 | [ E.float lat 26 | , E.float lng 27 | ] 28 | 29 | 30 | {-| Reference: 31 | -} 32 | type alias MarkerOptions = 33 | { iconOptions : IconOptions 34 | , clickable : Bool 35 | , draggable : Bool 36 | , keyboard : Bool 37 | , title : String 38 | , alt : String 39 | , zIndexOffset : Int 40 | , opacity : Float 41 | , riseOnHover : Bool 42 | , riseOffset : Int 43 | } 44 | 45 | 46 | encodeMarkerOptions : MarkerOptions -> E.Value 47 | encodeMarkerOptions opts = 48 | E.object 49 | [ ( "iconOptions", encodeIconOptions opts.iconOptions ) 50 | , ( "clickable", E.bool opts.clickable ) 51 | , ( "draggable", E.bool opts.draggable ) 52 | , ( "keyboard", E.bool opts.keyboard ) 53 | , ( "title", E.string opts.title ) 54 | , ( "alt", E.string opts.alt ) 55 | , ( "zIndexOffset", E.int opts.zIndexOffset ) 56 | , ( "opacity", E.float opts.opacity ) 57 | , ( "riseOnHover", E.bool opts.riseOnHover ) 58 | , ( "riseOffset", E.int opts.riseOffset ) 59 | ] 60 | 61 | 62 | defaultMarkerOptions : MarkerOptions 63 | defaultMarkerOptions = 64 | { iconOptions = defaultIconOptions 65 | , clickable = True 66 | , draggable = False 67 | , keyboard = True 68 | , title = "" 69 | , alt = "" 70 | , zIndexOffset = 0 71 | , opacity = 1.0 72 | , riseOnHover = False 73 | , riseOffset = 250 74 | } 75 | 76 | 77 | {-| Reference: 78 | -} 79 | type alias IconOptions = 80 | { iconUrl : String 81 | , iconRetinaUrl : String 82 | , iconSize : Point 83 | , iconAnchor : Point 84 | , shadowUrl : String 85 | , shadowRetinaUrl : String 86 | , shadowSize : Point 87 | , shadowAnchor : Point 88 | , popupAnchor : Point 89 | , className : String 90 | } 91 | 92 | 93 | encodeIconOptions : IconOptions -> E.Value 94 | encodeIconOptions opts = 95 | E.object 96 | [ ( "iconUrl", E.string opts.iconUrl ) 97 | , ( "iconRetinaUrl", E.string opts.iconRetinaUrl ) 98 | , ( "iconSize", encodePoint opts.iconSize ) 99 | , ( "iconAnchor", encodePoint opts.iconAnchor ) 100 | , ( "shadowUrl", E.string opts.shadowUrl ) 101 | , ( "shadowRetinaUrl", E.string opts.shadowRetinaUrl ) 102 | , ( "shadowSize", encodePoint opts.shadowSize ) 103 | , ( "shadowAnchor", encodePoint opts.shadowAnchor ) 104 | , ( "popupAnchor", encodePoint opts.popupAnchor ) 105 | , ( "className", E.string opts.className ) 106 | ] 107 | 108 | 109 | leafletDistributionBase : String 110 | leafletDistributionBase = 111 | "https://unpkg.com/leaflet@1.2.0/dist/images/" 112 | 113 | 114 | iconUrl : String -> String 115 | iconUrl filename = 116 | leafletDistributionBase ++ filename 117 | 118 | 119 | defaultIconOptions : IconOptions 120 | defaultIconOptions = 121 | { iconUrl = iconUrl "marker-icon.png" 122 | , iconRetinaUrl = iconUrl "marker-icon-2x.png" 123 | , iconSize = ( 25, 41 ) 124 | , iconAnchor = ( 12, 41 ) 125 | , shadowUrl = iconUrl "marker-shadow.png" 126 | , shadowRetinaUrl = 127 | iconUrl "marker-shadow.png" 128 | 129 | -- Really just guessing here, doesn't appear to be set by default? 130 | , shadowSize = ( 41, 41 ) 131 | , shadowAnchor = ( 12, 41 ) 132 | , popupAnchor = ( 1, -34 ) 133 | , className = "" 134 | } 135 | 136 | 137 | type alias Point = 138 | ( Int, Int ) 139 | 140 | 141 | encodePoint : Point -> E.Value 142 | encodePoint ( x, y ) = 143 | E.list 144 | [ E.int x 145 | , E.int y 146 | ] 147 | 148 | 149 | type alias ZoomOptions = 150 | { animate : Bool } 151 | 152 | 153 | encodeZoomOptions : ZoomOptions -> E.Value 154 | encodeZoomOptions opts = 155 | E.object 156 | [ ( "animate", E.bool opts.animate ) 157 | ] 158 | 159 | 160 | type alias PanOptions = 161 | { animate : Bool 162 | , duration : Float 163 | , easeLinearity : Float 164 | , noMoveStart : Bool 165 | } 166 | 167 | 168 | encodePanOptions : PanOptions -> E.Value 169 | encodePanOptions opts = 170 | E.object 171 | [ ( "animate", E.bool opts.animate ) 172 | , ( "duration", E.float opts.duration ) 173 | , ( "easeLinearity", E.float opts.easeLinearity ) 174 | , ( "noMoveStart", E.bool opts.noMoveStart ) 175 | ] 176 | 177 | 178 | type alias ZoomPanOptions = 179 | { reset : Bool 180 | , pan : PanOptions 181 | , zoom : ZoomOptions 182 | , animate : Bool 183 | } 184 | 185 | 186 | encodeZoomPanOptions : ZoomPanOptions -> E.Value 187 | encodeZoomPanOptions opts = 188 | E.object 189 | [ ( "reset", E.bool opts.reset ) 190 | , ( "pan", encodePanOptions opts.pan ) 191 | , ( "zoom", encodeZoomOptions opts.zoom ) 192 | , ( "animate", E.bool opts.animate ) 193 | ] 194 | 195 | 196 | defaultZoomPanOptions : ZoomPanOptions 197 | defaultZoomPanOptions = 198 | { reset = False 199 | , pan = defaultPanOptions 200 | , zoom = defaultZoomOptions 201 | , animate = True 202 | } 203 | 204 | 205 | defaultPanOptions : PanOptions 206 | defaultPanOptions = 207 | { animate = True 208 | , duration = 0.25 209 | , easeLinearity = 0.25 210 | , noMoveStart = False 211 | } 212 | 213 | 214 | defaultZoomOptions : ZoomOptions 215 | defaultZoomOptions = 216 | { animate = True } 217 | -------------------------------------------------------------------------------- /src/Leaflet/index.ts: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | import xs, { Stream } from 'xstream'; 3 | 4 | // TYPES 5 | interface MarkerData { 6 | id: string; 7 | latLng: L.LatLngExpression; 8 | // Extra attribute iconOptions since Elm only does JSON 9 | markerOptions: L.MarkerOptions & { iconOptions: L.IconOptions }; 10 | popupText: string; 11 | } 12 | 13 | interface ViewData { 14 | center: L.LatLngExpression; 15 | zoom: number; 16 | options: L.ZoomPanOptions; 17 | } 18 | 19 | // Silly alias, but TS has equally silly boilerplate for proper simple types 20 | type MarkerID = string; 21 | 22 | // MODEL 23 | interface Model { 24 | leafletMap: L.Map; 25 | markers: any; // TODO; could use a set 26 | } 27 | 28 | let model: Model; 29 | 30 | // INIT 31 | export function initLeaflet(msg$: Stream): void { 32 | // Set the initial model 33 | model = initModel(); 34 | 35 | // Launch Subscriptions 36 | // TODO: Could we be passing the stream in here? 37 | msg$.debug().addListener({ 38 | next: msg => update(msg), 39 | error: err => console.error(err), 40 | complete: () => console.log('completed') 41 | }); 42 | } 43 | 44 | function initModel(): Model { 45 | const leafletMap = L.map('mapid', { 46 | preferCanvas: true 47 | }).setView([60.1719, 24.9414], 12); 48 | 49 | L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo( 50 | leafletMap 51 | ); 52 | 53 | // Return initModel 54 | return { leafletMap: leafletMap, markers: {} }; 55 | } 56 | 57 | // MSG 58 | // TODO: use existing Msg constructor 59 | export type LeafletMsg = 60 | | SetView 61 | | FullScreenToggle 62 | | SetMarkers 63 | | RemoveMarker 64 | | OnInit; 65 | type SetView = { type: 'SetView'; data: ViewData }; 66 | type FullScreenToggle = { type: 'FullScreenToggle'; data: MapToggleDir }; 67 | type SetMarkers = { type: 'SetMarkers'; data: MarkerData[] }; 68 | type RemoveMarker = { type: 'RemoveMarker'; data: MarkerID }; 69 | type OnInit = { type: 'OnInit' }; 70 | 71 | // UPDATE 72 | // NOTE: these mutate things in place, since we're dealing with Leaflet. 73 | // Perhaps, then, update is a misnomer. 74 | // We could things immmutably (copy map, change things, return map, set 75 | // model. That would be mostly pointless, imo. 76 | const update = (msg: LeafletMsg) => { 77 | switch (msg.type) { 78 | case 'SetView': 79 | setView(msg.data); 80 | break; 81 | 82 | case 'SetMarkers': 83 | setMarkers(msg.data); 84 | break; 85 | 86 | case 'RemoveMarker': 87 | removeMarker(msg.data); 88 | break; 89 | 90 | case 'FullScreenToggle': 91 | setFullScreen(msg.data); 92 | model.leafletMap.invalidateSize(); 93 | break; 94 | 95 | case 'OnInit': 96 | model.leafletMap.invalidateSize(); 97 | break; 98 | 99 | default: 100 | console.warn('Leaflet Port command not recognised'); 101 | } 102 | }; 103 | 104 | // Update functions 105 | function setMarkers(markers: MarkerData[]): void { 106 | markers.forEach(({ id, latLng, markerOptions, popupText }, index) => { 107 | // Reconstruct icon from iconOptions 108 | markerOptions.icon = new L.Icon(markerOptions.iconOptions); 109 | let marker = L.marker(latLng, markerOptions); 110 | 111 | marker.bindPopup(popupText); 112 | 113 | if (!model.markers.hasOwnProperty(id)) { 114 | marker.addTo(model.leafletMap); 115 | model.markers[id] = marker; 116 | } else { 117 | Object.assign(model.markers[id], marker); 118 | } 119 | }); 120 | } 121 | 122 | function removeMarker(id: MarkerID): void { 123 | if (model.markers.hasOwnProperty(id)) { 124 | let marker = model.markers[id]; 125 | model.leafletMap.removeLayer(marker); 126 | } 127 | } 128 | 129 | function setView({ center, zoom, options }: ViewData): void { 130 | model.leafletMap.setView(center, zoom, options); 131 | } 132 | 133 | type MapToggleDir = 'OnFullscreen' | 'OnNoFullscreen' | 'Off'; 134 | 135 | function setFullScreen(dir: MapToggleDir): void { 136 | let map = document.getElementById('mapid') as HTMLElement; 137 | 138 | let mapOn = () => map.classList.remove('dn'); 139 | 140 | switch (dir) { 141 | case 'OnFullscreen': 142 | mapOn(); 143 | map.classList.remove('h5', 'h6-ns'); 144 | map.classList.add('h-fullmap', 'h-fullmap-ns'); 145 | break; 146 | case 'OnNoFullscreen': 147 | mapOn(); 148 | map.classList.remove('h-fullmap', 'h-fullmap-ns'); 149 | map.classList.add('h5', 'h6-ns'); 150 | break; 151 | case 'Off': 152 | map.classList.add('dn'); 153 | break; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Task 4 | import Html exposing (..) 5 | import Data.Session exposing (Session) 6 | import Request.Entry exposing (decodePouchEntries, decodePouchEntry, decodeDeletedEntry) 7 | import Route exposing (Route) 8 | import Views.Page as Page exposing (ActivePage) 9 | import Page.Entry as Entry 10 | import Page.Home as Home 11 | import Page.Login as Login exposing (logout) 12 | import Page.FullMap as FullMap 13 | import Page.NotFound as NotFound 14 | import Page.Errored as Errored exposing (PageLoadError) 15 | import Util exposing ((=>)) 16 | import Navigation exposing (Location) 17 | import Map exposing (setMapView, MapToggleDir(..), MapFullscreenState(..)) 18 | import Pouch.Ports 19 | 20 | 21 | type Page 22 | = Blank 23 | | NotFound 24 | | Errored PageLoadError 25 | | Home Home.Model 26 | | Entry Entry.Model 27 | | Login Login.Model 28 | | FullMap FullMap.Model 29 | 30 | 31 | 32 | -- | Settings User 33 | -- MODEL -- 34 | 35 | 36 | type alias Model = 37 | { session : Session 38 | , pageState : PageState 39 | } 40 | 41 | 42 | type PageState 43 | = Loaded Page 44 | | TransitioningFrom Page 45 | 46 | 47 | init : Location -> ( Model, Cmd Msg ) 48 | init location = 49 | setRoute (Route.fromLocation location) initModel 50 | 51 | 52 | initModel : Model 53 | initModel = 54 | -- TODO: check 55 | { session = { user = Nothing } 56 | , pageState = Loaded initialPage 57 | } 58 | 59 | 60 | initialPage : Page 61 | initialPage = 62 | Blank 63 | 64 | 65 | 66 | -- VIEW 67 | 68 | 69 | view : Model -> Html Msg 70 | view model = 71 | case model.pageState of 72 | Loaded page -> 73 | viewPage model.session False page 74 | 75 | TransitioningFrom page -> 76 | viewPage model.session True page 77 | 78 | 79 | viewPage : Session -> Bool -> Page -> Html Msg 80 | viewPage session isLoading page = 81 | let 82 | frame = 83 | Page.frame session.user 84 | 85 | fullFrame = 86 | Page.fullFrame session.user 87 | in 88 | case page of 89 | Blank -> 90 | Html.text "" 91 | |> frame Page.Other 92 | 93 | NotFound -> 94 | NotFound.view session 95 | |> frame Page.Other 96 | 97 | Errored subModel -> 98 | Errored.view session subModel 99 | |> frame Page.Other 100 | 101 | FullMap subModel -> 102 | FullMap.view 103 | |> fullFrame Page.FullMap 104 | |> Html.map FullMapMsg 105 | 106 | Home subModel -> 107 | Home.view subModel 108 | |> frame Page.Home 109 | |> Html.map HomeMsg 110 | 111 | Entry subModel -> 112 | Entry.view subModel 113 | |> frame Page.NewEntry 114 | |> Html.map EntryMsg 115 | 116 | -- Settings subModel -> 117 | -- Errored.view session subModel 118 | -- |> frame Page.Other 119 | -- TODO: Add once Page.Settings implemented 120 | -- Entry.view subModel 121 | -- |> frame Page.Settings 122 | -- |> Html.map SettingsMsg 123 | Login subModel -> 124 | Login.view subModel 125 | |> frame Page.Login 126 | |> Html.map LoginMsg 127 | 128 | 129 | getPage : PageState -> Page 130 | getPage pageState = 131 | case pageState of 132 | Loaded page -> 133 | page 134 | 135 | TransitioningFrom page -> 136 | page 137 | 138 | 139 | 140 | -- SUBSCRIPTIONS -- 141 | 142 | 143 | subscriptions : Model -> Sub Msg 144 | subscriptions model = 145 | -- Combine page-specific subs plus global ones (namely, session) 146 | Sub.batch 147 | [ pageSubscriptions (getPage model.pageState) 148 | , Pouch.Ports.logOut (Login.decodeLogout LogOutCompleted) 149 | ] 150 | 151 | 152 | pageSubscriptions : Page -> Sub Msg 153 | pageSubscriptions page = 154 | case page of 155 | Blank -> 156 | Sub.none 157 | 158 | Errored _ -> 159 | Sub.none 160 | 161 | NotFound -> 162 | Sub.none 163 | 164 | Home subModel -> 165 | Sub.map (\msg -> HomeMsg msg) (Home.subscriptions subModel) 166 | 167 | -- Settings _ -> 168 | -- Sub.none 169 | Entry _ -> 170 | Sub.none 171 | 172 | FullMap subModel -> 173 | Sub.none 174 | 175 | Login subModel -> 176 | Sub.map (\msg -> LoginMsg msg) (Login.subscriptions subModel) 177 | 178 | 179 | 180 | -- MSG -- 181 | 182 | 183 | type Msg 184 | = SetRoute (Maybe Route) 185 | | HomeLoaded (Result PageLoadError Home.Model) 186 | | LogOutCompleted (Result String Bool) 187 | | HomeMsg Home.Msg 188 | | EntryMsg Entry.Msg 189 | | LoginMsg Login.Msg 190 | | FullMapMsg FullMap.Msg 191 | 192 | 193 | 194 | -- ROUTE MAPS -- 195 | 196 | 197 | setRoute : Maybe Route -> Model -> ( Model, Cmd Msg ) 198 | setRoute maybeRoute model = 199 | let 200 | transition toMsg task = 201 | { model | pageState = TransitioningFrom (getPage model.pageState) } 202 | => Task.attempt toMsg task 203 | 204 | errored = 205 | pageErrored model 206 | in 207 | case maybeRoute of 208 | Nothing -> 209 | { model | pageState = Loaded NotFound } => Cmd.none 210 | 211 | Just Route.Home -> 212 | -- transition HomeLoaded (Home.init model.session) 213 | { model | pageState = Loaded (Home Home.init) } => Cmd.batch [ Request.Entry.list, setMapView <| On NoFullscreen ] 214 | 215 | Just Route.NewEntry -> 216 | { model | pageState = Loaded (Entry Entry.init) } => setMapView Off 217 | 218 | Just Route.FullMap -> 219 | { model | pageState = Loaded (FullMap {}) } => Cmd.batch [ Request.Entry.list, setMapView <| On Fullscreen ] 220 | 221 | Just Route.Settings -> 222 | errored Page.Other "Settings WIP" 223 | 224 | Just Route.Login -> 225 | { model | pageState = Loaded (Login Login.initialModel) } => setMapView Off 226 | 227 | Just Route.Logout -> 228 | -- Login.logout gets back on a port in subscriptions 229 | -- Handling of it (deleting user, routing home) is handled in 230 | -- Main.update 231 | model 232 | => Cmd.batch [ Login.logout ] 233 | 234 | 235 | pageErrored : Model -> ActivePage -> String -> ( Model, Cmd msg ) 236 | pageErrored model activePage errorMessage = 237 | let 238 | error = 239 | Errored.pageLoadError activePage errorMessage 240 | in 241 | { model | pageState = Loaded (Errored error) } => Cmd.batch [ setMapView Off ] 242 | 243 | 244 | 245 | -- UPDATE 246 | 247 | 248 | update : Msg -> Model -> ( Model, Cmd Msg ) 249 | update msg model = 250 | updatePage (getPage model.pageState) msg model 251 | 252 | 253 | updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg ) 254 | updatePage page msg model = 255 | let 256 | session = 257 | model.session 258 | 259 | toPage toModel toMsg subUpdate subMsg subModel = 260 | let 261 | ( newModel, newCmd ) = 262 | subUpdate subMsg subModel 263 | in 264 | ( { model | pageState = Loaded (toModel newModel) }, Cmd.map toMsg newCmd ) 265 | 266 | errored = 267 | pageErrored model 268 | in 269 | case ( msg, page ) of 270 | ( SetRoute route, _ ) -> 271 | setRoute route model 272 | 273 | ( LogOutCompleted (Ok user), _ ) -> 274 | let 275 | session = 276 | model.session 277 | in 278 | { model | session = { session | user = Nothing } } 279 | => Cmd.batch [ Route.modifyUrl Route.Home ] 280 | 281 | ( LogOutCompleted (Err error), _ ) -> 282 | model => Cmd.none 283 | 284 | ( HomeLoaded (Ok subModel), _ ) -> 285 | { model | pageState = Loaded (Home subModel) } => Request.Entry.list 286 | 287 | ( HomeLoaded (Err error), _ ) -> 288 | { model | pageState = Loaded (Errored error) } => Cmd.none 289 | 290 | ( HomeMsg subMsg, Home subModel ) -> 291 | toPage Home HomeMsg (Home.update session) subMsg subModel 292 | 293 | ( EntryMsg subMsg, Entry subModel ) -> 294 | toPage Entry EntryMsg (Entry.update session) subMsg subModel 295 | 296 | ( FullMapMsg subMsg, FullMap subModel ) -> 297 | toPage FullMap FullMapMsg FullMap.update subMsg subModel 298 | 299 | ( LoginMsg subMsg, Login subModel ) -> 300 | let 301 | ( ( pageModel, cmd ), msgFromPage ) = 302 | Login.update subMsg subModel 303 | 304 | newModel = 305 | case msgFromPage of 306 | Login.NoOp -> 307 | model 308 | 309 | Login.SetUser user -> 310 | let 311 | session = 312 | model.session 313 | 314 | newSession = 315 | { session | user = Just user } 316 | in 317 | { model | session = newSession } 318 | in 319 | { newModel | pageState = Loaded (Login pageModel) } 320 | => Cmd.map LoginMsg cmd 321 | 322 | ( _, NotFound ) -> 323 | -- Disregard messages when on NotFound page 324 | model => Cmd.none 325 | 326 | ( _, _ ) -> 327 | -- Disregard messages for wrong page 328 | model => Cmd.none 329 | 330 | 331 | main : Program Never Model Msg 332 | main = 333 | Navigation.program (Route.fromLocation >> SetRoute) 334 | { init = init 335 | , view = view 336 | , update = update 337 | , subscriptions = subscriptions 338 | } 339 | -------------------------------------------------------------------------------- /src/Map.elm: -------------------------------------------------------------------------------- 1 | module Map 2 | exposing 3 | ( Model 4 | , Msg 5 | , update 6 | , addMarkers 7 | , initModel 8 | , Msg(..) 9 | , helsinkiLatLng 10 | , worldLatLng 11 | , setMapView 12 | , setView 13 | , MapToggleDir(..) 14 | , MapFullscreenState(..) 15 | ) 16 | 17 | import Geolocation exposing (Location) 18 | import Dict exposing (Dict) 19 | import Data.Entry exposing (Entry, EntryId, idToString) 20 | import Task 21 | import Json.Encode 22 | import Util exposing (viewDate) 23 | import Leaflet.Types 24 | exposing 25 | ( LatLng 26 | , ZoomPanOptions 27 | , defaultZoomPanOptions 28 | , MarkerOptions 29 | , defaultMarkerOptions 30 | , encodeMarkerOptions 31 | , encodeZoomPanOptions 32 | , encodeLatLng 33 | ) 34 | import Leaflet.Ports 35 | 36 | 37 | type alias Model = 38 | { latLng : LatLng 39 | , zoomPanOptions : ZoomPanOptions 40 | , markers : Dict String ( LatLng, String ) 41 | } 42 | 43 | 44 | initModel : Model 45 | initModel = 46 | { latLng = helsinkiLatLng 47 | , zoomPanOptions = defaultZoomPanOptions 48 | , markers = Dict.empty 49 | } 50 | 51 | 52 | helsinkiLatLng : LatLng 53 | helsinkiLatLng = 54 | ( 60.1719, 24.9414 ) 55 | 56 | 57 | worldLatLng : LatLng 58 | worldLatLng = 59 | ( 0.0, 0.0 ) 60 | 61 | 62 | type Msg 63 | = SetLatLng ( LatLng, Int ) 64 | | SetToCurrent (Result Geolocation.Error Location) 65 | | GoToCurrentLocation 66 | | GetCenter LatLng 67 | | AddMarkers (List ( String, LatLng, String )) 68 | | RemoveMarker EntryId 69 | 70 | 71 | update : Msg -> Model -> ( Model, Cmd Msg ) 72 | update msg model = 73 | case msg of 74 | SetLatLng ( latLng, zoom ) -> 75 | ( { model | latLng = latLng } 76 | , setView ( latLng, zoom ) 77 | ) 78 | 79 | AddMarkers markers -> 80 | let 81 | newModel = 82 | addMarkersToModel markers model 83 | in 84 | ( newModel, encodeMarkers newModel.markers |> encodeSetMarkers |> Leaflet.Ports.toLeaflet ) 85 | 86 | RemoveMarker entryId -> 87 | let 88 | newMarkers = 89 | Dict.remove (idToString entryId) model.markers 90 | in 91 | ( { model | markers = newMarkers }, encodeRemoveMarker entryId |> Leaflet.Ports.toLeaflet ) 92 | 93 | GetCenter latLng -> 94 | { model | latLng = latLng } ! [] 95 | 96 | SetToCurrent (Ok { latitude, longitude }) -> 97 | update (SetLatLng ( ( latitude, longitude ), 14 )) model 98 | 99 | SetToCurrent (Err error) -> 100 | update (SetLatLng ( ( 0, 0 ), 1 )) model 101 | 102 | GoToCurrentLocation -> 103 | let 104 | geoTask = 105 | Geolocation.now 106 | |> Task.attempt SetToCurrent 107 | in 108 | model ! [ geoTask ] 109 | 110 | 111 | addMarkersToModel : List ( String, LatLng, String ) -> Model -> Model 112 | addMarkersToModel markers model = 113 | let 114 | addMarker ( id, latLng, popupText ) dict = 115 | Dict.insert id ( latLng, popupText ) dict 116 | 117 | newMarkers = 118 | List.foldl addMarker model.markers markers 119 | in 120 | { model | markers = newMarkers } 121 | 122 | 123 | 124 | -- Exposed Commands -- 125 | 126 | 127 | addMarkers : List Entry -> Cmd Msg 128 | addMarkers entries = 129 | let 130 | markers = 131 | List.map entryToMarker entries 132 | in 133 | Task.perform 134 | (always 135 | (AddMarkers markers) 136 | ) 137 | (Task.succeed ()) 138 | 139 | 140 | type MapToggleDir 141 | = On MapFullscreenState 142 | | Off 143 | 144 | 145 | type MapFullscreenState 146 | = Fullscreen 147 | | NoFullscreen 148 | 149 | 150 | setMapView : MapToggleDir -> Cmd msg 151 | setMapView dir = 152 | encodeMapToggle dir |> Leaflet.Ports.toLeaflet 153 | 154 | 155 | setView : ( LatLng, Int ) -> Cmd msg 156 | setView ( latLng, zoom ) = 157 | encodeSetView ( latLng, zoom, defaultZoomPanOptions ) |> Leaflet.Ports.toLeaflet 158 | 159 | 160 | 161 | -- Utils 162 | 163 | 164 | entryToMarker : Entry -> ( String, LatLng, String ) 165 | entryToMarker entry = 166 | let 167 | { latitude, longitude } = 168 | entry.location 169 | 170 | popupText = 171 | makePopup entry 172 | in 173 | ( idToString entry.id 174 | , ( latitude, longitude ) 175 | , popupText 176 | ) 177 | 178 | 179 | makePopup : Entry -> String 180 | makePopup entry = 181 | "
" 182 | ++ entry.content 183 | ++ "
" 184 | ++ "
" 185 | ++ entry.translation 186 | ++ "
" 187 | ++ "
" 188 | ++ viewDate entry.addedAt 189 | ++ "
" 190 | 191 | 192 | encodeMarkers : Dict String ( LatLng, String ) -> List Json.Encode.Value 193 | encodeMarkers markers = 194 | Dict.toList markers 195 | |> List.map 196 | (\( id, ( latLng, popupText ) ) -> 197 | Json.Encode.object 198 | [ ( "id", Json.Encode.string id ) 199 | , ( "latLng", encodeLatLng latLng ) 200 | , ( "markerOptions", encodeMarkerOptions defaultMarkerOptions ) 201 | , ( "popupText", Json.Encode.string popupText ) 202 | ] 203 | ) 204 | 205 | 206 | encodeSetView : ( LatLng, Int, ZoomPanOptions ) -> Json.Encode.Value 207 | encodeSetView ( latLng, zoom, opts ) = 208 | Json.Encode.object 209 | [ ( "type", Json.Encode.string "SetView" ) 210 | , ( "data" 211 | , Json.Encode.object 212 | [ ( "center", encodeLatLng latLng ) 213 | , ( "zoom", Json.Encode.int zoom ) 214 | , ( "options", encodeZoomPanOptions opts ) 215 | ] 216 | ) 217 | ] 218 | 219 | 220 | encodeMapToggle : MapToggleDir -> Json.Encode.Value 221 | encodeMapToggle dir = 222 | let 223 | dirToString dir = 224 | case dir of 225 | On Fullscreen -> 226 | "OnFullscreen" 227 | 228 | On NoFullscreen -> 229 | "OnNoFullscreen" 230 | 231 | Off -> 232 | "Off" 233 | in 234 | Json.Encode.object 235 | [ ( "type", Json.Encode.string "FullScreenToggle" ) 236 | , ( "data", Json.Encode.string <| dirToString dir ) 237 | ] 238 | 239 | 240 | encodeSetMarkers : List Json.Encode.Value -> Json.Encode.Value 241 | encodeSetMarkers markers = 242 | Json.Encode.object 243 | [ ( "type", Json.Encode.string "SetMarkers" ) 244 | , ( "data", Json.Encode.list markers ) 245 | ] 246 | 247 | 248 | encodeRemoveMarker : EntryId -> Json.Encode.Value 249 | encodeRemoveMarker entryId = 250 | Json.Encode.object 251 | [ ( "type", Json.Encode.string "RemoveMarker" ) 252 | , ( "data", Json.Encode.string <| idToString entryId ) 253 | ] 254 | -------------------------------------------------------------------------------- /src/Page/Entry.elm: -------------------------------------------------------------------------------- 1 | module Page.Entry exposing (Model, init, update, view, Msg(..)) 2 | 3 | import Data.Entry as Entry exposing (Entry, EntryLocation, EntryId) 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Html.Events exposing (onInput, onSubmit) 7 | import Http 8 | import Views.General exposing (formField, epButton) 9 | import Request.Entry 10 | import Date exposing (Date) 11 | import Task 12 | import Geolocation exposing (Location) 13 | import Util exposing (viewIf) 14 | import Data.Session exposing (Session) 15 | 16 | 17 | -- MODEL -- 18 | 19 | 20 | type alias Model = 21 | { errors : List Error 22 | , content : String 23 | , translation : String 24 | , location : EntryLocation 25 | , addedAt : Date 26 | , editingEntry : Maybe ( EntryId, String ) 27 | } 28 | 29 | 30 | init : Model 31 | init = 32 | { errors = [] 33 | , content = "" 34 | , translation = "" 35 | , location = initLocation 36 | , addedAt = Date.fromTime 0 37 | , editingEntry = Nothing 38 | } 39 | 40 | 41 | initLocation : EntryLocation 42 | initLocation = 43 | { longitude = 0.0 44 | , latitude = 0.0 45 | , accuracy = 0 46 | } 47 | 48 | 49 | initGeoLocation : Location 50 | initGeoLocation = 51 | { accuracy = 0.0 52 | , altitude = Just { value = 0.0, accuracy = 0.0 } 53 | , latitude = 0.0 54 | , longitude = 0.0 55 | , movement = Just Geolocation.Static 56 | , timestamp = 0 57 | } 58 | 59 | 60 | 61 | -- UPDATE -- 62 | 63 | 64 | type Msg 65 | = Save 66 | | Commit 67 | | Edit Entry 68 | | SetContent String 69 | | SetTranslation String 70 | | SetLocationTime (Result Geolocation.Error ( Location, Date )) 71 | | SetWithNullLoc ( EntryLocation, Date ) 72 | | LocationFound (Result Geolocation.Error Location) 73 | | CreateCompleted (Result Http.Error Entry) 74 | | EditCompleted (Result Http.Error Entry) 75 | 76 | 77 | update : Session -> Msg -> Model -> ( Model, Cmd Msg ) 78 | update session msg model = 79 | case msg of 80 | Save -> 81 | case model.editingEntry of 82 | Nothing -> 83 | let 84 | getLocation = 85 | Geolocation.now 86 | |> Task.onError (\err -> Task.succeed initGeoLocation) 87 | 88 | getTime a = 89 | Date.now 90 | |> Task.andThen (\b -> Task.succeed ( a, b )) 91 | 92 | seq = 93 | getLocation 94 | |> Task.andThen getTime 95 | |> Task.attempt SetLocationTime 96 | in 97 | ( model, seq ) 98 | 99 | Just ( entryId, rev ) -> 100 | update session Commit model 101 | 102 | Commit -> 103 | case model.editingEntry of 104 | Nothing -> 105 | ( init, Request.Entry.create model ) 106 | 107 | Just ( eid, rev ) -> 108 | ( init, Request.Entry.update eid rev model ) 109 | 110 | Edit entry -> 111 | { model 112 | | content = entry.content 113 | , translation = entry.translation 114 | , editingEntry = Just ( entry.id, entry.rev ) 115 | } 116 | ! [] 117 | 118 | SetContent content -> 119 | { model | content = content } ! [] 120 | 121 | SetTranslation translation -> 122 | { model | translation = translation } ! [] 123 | 124 | LocationFound (Ok location) -> 125 | let 126 | entryLocation = 127 | geoToEntryLocation location 128 | in 129 | { model | location = entryLocation } ! [] 130 | 131 | LocationFound (Err error) -> 132 | { model | errors = model.errors ++ [ ( Form, "Geolocation error" ) ] } ! [] 133 | 134 | SetLocationTime (Ok ( location, addedAt )) -> 135 | let 136 | entryLocation = 137 | geoToEntryLocation location 138 | 139 | newModel = 140 | { model 141 | | location = entryLocation 142 | , addedAt = addedAt 143 | } 144 | in 145 | update session Commit newModel 146 | 147 | SetLocationTime (Err error) -> 148 | { model | errors = model.errors ++ [ ( Form, viewGeoError error ) ] } ! [] 149 | 150 | SetWithNullLoc ( location, addedAt ) -> 151 | let 152 | newModel = 153 | { model 154 | | location = location 155 | , addedAt = addedAt 156 | } 157 | in 158 | update session Commit newModel 159 | 160 | CreateCompleted (Ok entry) -> 161 | init ! [] 162 | 163 | CreateCompleted (Err error) -> 164 | { model | errors = model.errors ++ [ ( Form, "Server error while attempting to save note" ) ] } ! [] 165 | 166 | EditCompleted (Ok entry) -> 167 | init ! [] 168 | 169 | EditCompleted (Err error) -> 170 | { model | errors = model.errors ++ [ ( Form, "Server error while attempting to edit note" ) ] } ! [] 171 | 172 | 173 | 174 | -- VIEW -- 175 | 176 | 177 | view : Model -> Html Msg 178 | view model = 179 | let 180 | isEditing = 181 | model.editingEntry /= Nothing 182 | 183 | saveButtonText = 184 | if isEditing then 185 | "Update" 186 | else 187 | "Save" 188 | in 189 | Html.form [ class "black-80", onSubmit Save ] 190 | [ fieldset [ class "measure ba b--transparent pa0 ma0 center" ] 191 | [ formField model.content SetContent "word" "Word" "text" "The word to save." 192 | , formField model.translation SetTranslation "translation" "Translation" "text" "The translation for the word." 193 | , epButton [ class "w-100 white bg-deep-blue" ] [ text saveButtonText ] 194 | , viewIf (model.errors /= []) (viewErrors model.errors) 195 | ] 196 | ] 197 | 198 | 199 | viewErrors : List Error -> Html Msg 200 | viewErrors errors = 201 | let 202 | viewError ( field, err ) = 203 | span [ class "db mb2" ] [ text err ] 204 | in 205 | div [ class "mt2 pa3 f5 bg-light-red white" ] <| 206 | List.map viewError errors 207 | 208 | 209 | 210 | -- VALIDATION -- 211 | 212 | 213 | type Field 214 | = Form 215 | 216 | 217 | type alias Error = 218 | ( Field, String ) 219 | 220 | 221 | 222 | -- UTIL -- 223 | 224 | 225 | geoToEntryLocation : Location -> EntryLocation 226 | geoToEntryLocation { latitude, longitude, accuracy } = 227 | EntryLocation latitude longitude accuracy 228 | 229 | 230 | viewGeoError : Geolocation.Error -> String 231 | viewGeoError error = 232 | case error of 233 | Geolocation.PermissionDenied string -> 234 | "Permission Denied" 235 | 236 | Geolocation.LocationUnavailable string -> 237 | "Location Unavailable" 238 | 239 | Geolocation.Timeout string -> 240 | "Location Search Timed Out" 241 | -------------------------------------------------------------------------------- /src/Page/Errored.elm: -------------------------------------------------------------------------------- 1 | module Page.Errored exposing (PageLoadError, pageLoadError, view) 2 | 3 | import Data.Session as Session exposing (Session) 4 | import Html exposing (Html, div, h1, img, main_, p, text) 5 | import Html.Attributes exposing (alt, class, id, tabindex) 6 | import Views.Page as Page exposing (ActivePage) 7 | 8 | 9 | -- MODEL -- 10 | 11 | 12 | type PageLoadError 13 | = PageLoadError Model 14 | 15 | 16 | type alias Model = 17 | { activePage : ActivePage 18 | , errorMessage : String 19 | } 20 | 21 | 22 | pageLoadError : ActivePage -> String -> PageLoadError 23 | pageLoadError activePage errorMessage = 24 | PageLoadError { activePage = activePage, errorMessage = errorMessage } 25 | 26 | 27 | 28 | -- VIEW -- 29 | 30 | 31 | view : Session -> PageLoadError -> Html msg 32 | view session (PageLoadError model) = 33 | main_ [ id "content", class "container", tabindex -1 ] 34 | [ h1 [] [ text "Error Loading Page" ] 35 | , div [ class "row" ] 36 | [ p [] [ text model.errorMessage ] ] 37 | ] 38 | -------------------------------------------------------------------------------- /src/Page/FullMap.elm: -------------------------------------------------------------------------------- 1 | module Page.FullMap exposing (Model, Msg, update, view) 2 | 3 | import Geolocation exposing (Location) 4 | import Task 5 | import Map exposing (setView) 6 | import Html exposing (Html, main_, div, text) 7 | import Html.Attributes exposing (class) 8 | import Html.Events exposing (onClick) 9 | import Views.General exposing (epButton) 10 | import Leaflet.Types exposing (LatLng) 11 | import Util exposing ((=>)) 12 | 13 | 14 | -- VIEW -- 15 | 16 | 17 | type Msg 18 | = SetView ( LatLng, Int ) 19 | | GoToCurrentLocation 20 | | SetToCurrent (Result Geolocation.Error Location) 21 | 22 | 23 | type alias Model = 24 | {} 25 | 26 | 27 | view : Html Msg 28 | view = 29 | main_ [ class "absolute top-0 left-0 w-100 h-100 z-9999 events-none" ] 30 | [ div [ class "mw7-ns pb5 center h-100 flex flex-column justify-end" ] [ viewDestinationButtons ] 31 | ] 32 | 33 | 34 | 35 | -- TODO: This is kind of silly, and should be fixed once we make Map top-level 36 | 37 | 38 | update : Msg -> Model -> ( Model, Cmd Msg ) 39 | update msg model = 40 | case msg of 41 | SetView ( latLng, zoom ) -> 42 | model => setView ( latLng, zoom ) 43 | 44 | SetToCurrent (Ok { latitude, longitude }) -> 45 | model => setView ( ( latitude, longitude ), 14 ) 46 | 47 | SetToCurrent (Err error) -> 48 | model => Cmd.none 49 | 50 | GoToCurrentLocation -> 51 | let 52 | geoTask = 53 | Geolocation.now 54 | |> Task.attempt SetToCurrent 55 | in 56 | model ! [ geoTask ] 57 | 58 | 59 | viewDestinationButtons : Html Msg 60 | viewDestinationButtons = 61 | let 62 | classNames = 63 | "mr3 bg-beige-gray deep-blue pointer fw6 shadow-button events-auto" 64 | in 65 | div [ class "pt1 tc" ] 66 | [ epButton [ class classNames, onClick <| SetView ( Map.helsinkiLatLng, 12 ) ] 67 | [ text "Helsinki" ] 68 | , epButton [ class classNames, onClick <| GoToCurrentLocation ] 69 | [ text "Current" ] 70 | , epButton [ class classNames, onClick <| SetView ( Map.worldLatLng, 1 ) ] 71 | [ text "World" ] 72 | ] 73 | -------------------------------------------------------------------------------- /src/Page/Home.elm: -------------------------------------------------------------------------------- 1 | module Page.Home exposing (Model, init, update, view, Msg(..), subscriptions) 2 | 3 | import Html exposing (..) 4 | import Html.Keyed 5 | import Html.Lazy exposing (lazy, lazy2) 6 | import Html.Events exposing (onClick, onInput) 7 | import Html.Attributes exposing (..) 8 | import Views.General exposing (epButton, avatar) 9 | import Util exposing (viewDate, viewIf) 10 | import Dict exposing (Dict) 11 | import Map as Map 12 | import Pouch.Ports 13 | import Data.Entry exposing (Entry, EntryId, idToString) 14 | import Data.Session exposing (Session) 15 | import Request.Entry exposing (decodePouchEntries, decodePouchEntry, decodeDeletedEntry) 16 | 17 | 18 | type alias Model = 19 | { entries : Dict String Entry 20 | , mapState : Map.Model 21 | } 22 | 23 | 24 | init : Model 25 | init = 26 | { entries = Dict.empty 27 | , mapState = Map.initModel 28 | } 29 | 30 | 31 | subscriptions : Model -> Sub Msg 32 | subscriptions model = 33 | Sub.batch 34 | [ Pouch.Ports.getEntries (decodePouchEntries NewEntries) 35 | , Pouch.Ports.updatedEntry (decodePouchEntry NewEntry) 36 | , Pouch.Ports.deletedEntry (decodeDeletedEntry DeletedEntry) 37 | ] 38 | 39 | 40 | type Msg 41 | = LoadEntries 42 | | ExportCardsCsv 43 | | ExportCardsAnki 44 | | DeleteEntry EntryId 45 | | NewEntries (List Entry) 46 | | NewEntry (Result String Entry) 47 | | DeletedEntry (Result String EntryId) 48 | | MapMsg Map.Msg 49 | 50 | 51 | update : Session -> Msg -> Model -> ( Model, Cmd Msg ) 52 | update session msg model = 53 | case msg of 54 | LoadEntries -> 55 | ( model, Pouch.Ports.listEntries "entry" ) 56 | 57 | ExportCardsCsv -> 58 | model ! [ Pouch.Ports.exportCards "CSV" ] 59 | 60 | ExportCardsAnki -> 61 | model ! [ Pouch.Ports.exportCards "ANKI" ] 62 | 63 | DeleteEntry entryId -> 64 | model ! [ Request.Entry.delete entryId ] 65 | 66 | NewEntries entries -> 67 | let 68 | assocList = 69 | List.map (\e -> ( idToString e.id, e )) entries 70 | 71 | newEntries = 72 | Dict.fromList (assocList) 73 | in 74 | { model | entries = newEntries } ! [ Cmd.map MapMsg (Map.addMarkers entries) ] 75 | 76 | NewEntry (Err err) -> 77 | model ! [] 78 | 79 | NewEntry (Ok entry) -> 80 | let 81 | newEntries = 82 | Dict.insert (idToString entry.id) entry model.entries 83 | in 84 | { model | entries = newEntries } ! [ Cmd.map MapMsg (Map.addMarkers [ entry ]) ] 85 | 86 | DeletedEntry (Err err) -> 87 | model ! [] 88 | 89 | DeletedEntry (Ok entryId) -> 90 | let 91 | newEntries = 92 | Dict.remove (idToString entryId) model.entries 93 | 94 | ( newMapState, newMapCmd ) = 95 | Map.update (Map.RemoveMarker entryId) model.mapState 96 | in 97 | ( { model 98 | | entries = newEntries 99 | , mapState = newMapState 100 | } 101 | , Cmd.map MapMsg newMapCmd 102 | ) 103 | 104 | MapMsg msg -> 105 | -- more ad-hoc for Map messages, since we might want map to be co-located 106 | let 107 | ( newModel, newCmd ) = 108 | Map.update msg model.mapState 109 | in 110 | ( { model | mapState = newModel }, Cmd.map MapMsg newCmd ) 111 | 112 | 113 | view : Model -> Html Msg 114 | view model = 115 | div [] 116 | [ div [ class "mb2 mb4-ns" ] 117 | [ viewFlight 118 | , div [ class "measure center mt3" ] 119 | [ epButton [ class "db mb3 w-100 white bg-deep-blue", onClick ExportCardsCsv ] [ text "Export CSV (offline)" ] 120 | , epButton [ class "db w-100 white bg-deep-blue", onClick ExportCardsAnki ] [ text "Export Anki (online)" ] 121 | ] 122 | ] 123 | , div [ class "pt3" ] 124 | [ lazy viewEntries (Dict.toList model.entries) 125 | ] 126 | ] 127 | 128 | 129 | viewFlight : Html Msg 130 | viewFlight = 131 | let 132 | classNames = 133 | "mr3 bg-beige-gray deep-blue pointer fw6 shadow-button" 134 | in 135 | div [ class "mb4 tc" ] 136 | [ epButton [ class classNames, onClick <| MapMsg (Map.SetLatLng ( Map.helsinkiLatLng, 12 )) ] 137 | [ text "Helsinki" ] 138 | , epButton [ class classNames, onClick <| MapMsg (Map.GoToCurrentLocation) ] 139 | [ text "Current" ] 140 | , epButton [ class classNames, onClick <| MapMsg (Map.SetLatLng ( Map.worldLatLng, 1 )) ] 141 | [ text "World" ] 142 | ] 143 | 144 | 145 | viewEntries : List ( String, Entry ) -> Html Msg 146 | viewEntries keyedEntries = 147 | if keyedEntries /= [] then 148 | Html.Keyed.node "div" [ class "dw" ] <| List.map (\( id, entry ) -> ( id, lazy viewEntry entry )) keyedEntries 149 | else 150 | div [ class "pa4 mb3 bg-main-blue br1 tc f5" ] 151 | [ p [ class "dark-gray lh-copy" ] 152 | [ text "Looks like you don't have any entries yet. Why don't you add one? :)" 153 | ] 154 | ] 155 | 156 | 157 | viewEntry : Entry -> Html Msg 158 | viewEntry entry = 159 | div 160 | [ class "dw-panel" ] 161 | [ div 162 | [ class "dw-panel__content bg-muted-blue mw5 center br4 pa4 shadow-card" ] 163 | [ a [ onClick <| DeleteEntry entry.id, class "close handwriting black-70 hover-white" ] [ text "×" ] 164 | , div [ class "white tl" ] 165 | [ h2 166 | [ class "mt0 mb2 f5 f4-ns fw6 overflow-hidden" ] 167 | [ text entry.content ] 168 | , h2 169 | [ class "mt0 f5 f4-ns fw6 overflow-hidden" ] 170 | [ text entry.translation ] 171 | ] 172 | , hr 173 | [ class "w-100 mt4 mb3 bb bw1 b--black-10" ] 174 | [] 175 | , div [ class "white f6 f5-ns" ] 176 | [ span [ class "db mb2 tr truncate" ] [ text <| viewDate entry.addedAt ] 177 | , span [ class "db mb1 tr truncate" ] [ text <| toString entry.location.latitude ++ ", " ] 178 | , span [ class "db tr truncate" ] [ text <| toString entry.location.longitude ] 179 | ] 180 | ] 181 | ] 182 | -------------------------------------------------------------------------------- /src/Page/Login.elm: -------------------------------------------------------------------------------- 1 | module Page.Login exposing (ExternalMsg(..), Model, Msg, initialModel, update, view, subscriptions, decodeLogin, decodeLogout, logout) 2 | 3 | import Data.User as User exposing (User) 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Html.Events exposing (..) 7 | import Views.General as Views exposing (formField, epButton, paragraph) 8 | import Json.Decode as Decode exposing (Decoder) 9 | import Json.Encode as Encode exposing (Value) 10 | import Json.Decode.Pipeline as P exposing (decode, required) 11 | import Route exposing (Route) 12 | import Util exposing ((=>), viewIf) 13 | import Validate exposing (..) 14 | import Pouch.Ports 15 | 16 | 17 | -- MODEL -- 18 | -- Could also have a Data.User and Data.Session later on 19 | 20 | 21 | type alias Model = 22 | { errors : List Error 23 | , username : String 24 | , password : String 25 | } 26 | 27 | 28 | initialModel : Model 29 | initialModel = 30 | { errors = [] 31 | , username = "" 32 | , password = "" 33 | } 34 | 35 | 36 | 37 | -- Could factor out 38 | 39 | 40 | type alias LoginConfig record = 41 | { record 42 | | username : String 43 | , password : String 44 | } 45 | 46 | 47 | login : LoginConfig record -> Cmd msg 48 | login config = 49 | let 50 | login = 51 | Encode.object 52 | [ ( "username", Encode.string config.username ) 53 | , ( "password", Encode.string config.password ) 54 | ] 55 | in 56 | Pouch.Ports.sendLogin login 57 | 58 | 59 | logout : Cmd msg 60 | logout = 61 | Pouch.Ports.sendLogout "" 62 | 63 | 64 | decodeLogin : (Result String User -> msg) -> Value -> msg 65 | decodeLogin toMsg user = 66 | let 67 | result = 68 | Decode.decodeValue decodeUser user 69 | in 70 | toMsg result 71 | 72 | 73 | decodeLogout : (Result String Bool -> msg) -> Value -> msg 74 | decodeLogout toMsg res = 75 | let 76 | decodeRes = 77 | decode identity 78 | |> P.required "ok" Decode.bool 79 | 80 | result = 81 | Decode.decodeValue decodeRes res 82 | in 83 | toMsg result 84 | 85 | 86 | decodeUser : Decoder User 87 | decodeUser = 88 | decode User 89 | |> P.required "username" Decode.string 90 | 91 | 92 | 93 | -- UPDATE -- 94 | 95 | 96 | type Msg 97 | = SubmitForm 98 | | SetUsername String 99 | | SetPassword String 100 | | LoginCompleted (Result String User) 101 | 102 | 103 | type ExternalMsg 104 | = NoOp 105 | | SetUser User 106 | 107 | 108 | update : Msg -> Model -> ( ( Model, Cmd Msg ), ExternalMsg ) 109 | update msg model = 110 | case msg of 111 | SubmitForm -> 112 | case validate model of 113 | [] -> 114 | { model | errors = [] } 115 | => login model 116 | => NoOp 117 | 118 | errors -> 119 | { model | errors = errors } 120 | => Cmd.none 121 | => NoOp 122 | 123 | SetUsername username -> 124 | { model | username = username } 125 | => Cmd.none 126 | => NoOp 127 | 128 | SetPassword password -> 129 | { model | password = password } 130 | => Cmd.none 131 | => NoOp 132 | 133 | LoginCompleted (Err error) -> 134 | { model | errors = [ ( Form, error ) ] } 135 | => Cmd.none 136 | => NoOp 137 | 138 | LoginCompleted (Ok user) -> 139 | model 140 | => Cmd.batch [ Route.modifyUrl Route.Home ] 141 | => SetUser user 142 | 143 | 144 | 145 | -- SUBSCRIPTIONS -- 146 | 147 | 148 | subscriptions : Model -> Sub Msg 149 | subscriptions model = 150 | Sub.batch 151 | [ Pouch.Ports.logIn (decodeLogin LoginCompleted) 152 | ] 153 | 154 | 155 | 156 | -- VIEW -- 157 | 158 | 159 | view : Model -> Html Msg 160 | view model = 161 | Html.form [ class "black-80", onSubmit SubmitForm ] 162 | [ fieldset [ class "measure ba b--transparent pa0 ma0 center" ] 163 | [ formField model.username SetUsername "username" "Username" "text" "Your username." 164 | , formField model.password SetPassword "password" "Password" "password" "Your password." 165 | , epButton [ class "w-100 white bg-deep-blue" ] [ text "Log In" ] 166 | , viewIf (model.errors /= []) (viewErrors model.errors) 167 | , paragraph [ class "mt4 pa3 bg-light-gray tj" ] [ text "Logging in allows automatic syncing of your files to a remote database. Files on your device are persisted between visits even without logging in, but you might want the backup. Currently, signups are not active, but we're working on it!" ] 168 | ] 169 | ] 170 | 171 | 172 | viewErrors : List Error -> Html Msg 173 | viewErrors errors = 174 | let 175 | viewError ( field, err ) = 176 | span [ class "db mb2" ] [ text err ] 177 | in 178 | div [ class "mt2 pa3 f5 bg-deep-red white" ] <| 179 | List.map viewError errors 180 | 181 | 182 | 183 | -- VALIDATION -- 184 | 185 | 186 | type Field 187 | = Form 188 | | Username 189 | | Password 190 | 191 | 192 | type alias Error = 193 | ( Field, String ) 194 | 195 | 196 | validate : Model -> List Error 197 | validate = 198 | Validate.all 199 | [ .username >> ifBlank ( Username, "Username can't be blank." ) 200 | , .password >> ifBlank ( Password, "Password can't be blank." ) 201 | ] 202 | -------------------------------------------------------------------------------- /src/Page/NotFound.elm: -------------------------------------------------------------------------------- 1 | module Page.NotFound exposing (view) 2 | 3 | import Data.Session as Session exposing (Session) 4 | import Html exposing (Html, div, h1, img, main_, text) 5 | import Html.Attributes exposing (alt, class, id, src, tabindex) 6 | 7 | 8 | -- VIEW -- 9 | -- TODO: ball of hay img 10 | 11 | 12 | view : Session -> Html msg 13 | view session = 14 | main_ [ id "content", class "container", tabindex -1 ] 15 | [ h1 [] [ text "Not Found :(" ] 16 | ] 17 | -------------------------------------------------------------------------------- /src/Pouch/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Pouch.Ports 2 | exposing 3 | ( saveEntry 4 | , updateEntry 5 | , deleteEntry 6 | , listEntries 7 | , sendLogin 8 | , sendLogout 9 | , exportCards 10 | , getEntries 11 | , updatedEntry 12 | , deletedEntry 13 | , logIn 14 | , logOut 15 | , checkAuthState 16 | ) 17 | 18 | import Json.Encode exposing (Value) 19 | 20 | 21 | port listEntries : String -> Cmd msg 22 | 23 | 24 | port exportCards : String -> Cmd msg 25 | 26 | 27 | port saveEntry : Json.Encode.Value -> Cmd msg 28 | 29 | 30 | port updateEntry : Json.Encode.Value -> Cmd msg 31 | 32 | 33 | port deleteEntry : String -> Cmd msg 34 | 35 | 36 | port sendLogin : Json.Encode.Value -> Cmd msg 37 | 38 | 39 | port sendLogout : String -> Cmd msg 40 | 41 | 42 | port checkAuthState : String -> Cmd msg 43 | 44 | 45 | port getEntries : (Value -> msg) -> Sub msg 46 | 47 | 48 | port updatedEntry : (Value -> msg) -> Sub msg 49 | 50 | 51 | port deletedEntry : (Value -> msg) -> Sub msg 52 | 53 | 54 | port logIn : (Value -> msg) -> Sub msg 55 | 56 | 57 | port logOut : (Value -> msg) -> Sub msg 58 | -------------------------------------------------------------------------------- /src/Pouch/index.ts: -------------------------------------------------------------------------------- 1 | import xs, { Stream } from 'xstream'; 2 | import PouchDB from 'pouchdb-browser'; 3 | import PouchAuth from 'pouchdb-authentication'; 4 | import { app } from 'ephemeral'; 5 | import { config } from 'config'; 6 | import { string2Hex } from '../util/util'; 7 | import { Either, unpack } from '@typed/either'; 8 | import { 9 | NewDocument, 10 | Document, 11 | EntryContent, 12 | Entry, 13 | ExistingDocument, 14 | DocumentID, 15 | EphemeralDB, 16 | DBDoc, 17 | ExportMethod, 18 | LoginUser, 19 | isEntry 20 | } from './types'; 21 | import { Card } from '../export/types'; 22 | 23 | /* Module responsible for PouchDB init and access */ 24 | 25 | // TYPES 26 | // NOTE: Need to add this s.t TS typechecks correctly 27 | // and allows for PouchDB devtools integration 28 | declare global { 29 | interface Window { 30 | PouchDB: typeof PouchDB; 31 | } 32 | } 33 | 34 | // MODEL 35 | // NOTE: 'Model' is a misnomer given the rampant mutation happening inside 36 | // the database, but I will take the symmetry over strict semantics. 37 | // TODO: if there are other possible types of docs in DB, add to types.ts 38 | // and create a union to hold them 39 | interface Model { 40 | localDB: EphemeralDB; 41 | remoteDB: EphemeralDB; 42 | syncHandler?: PouchDB.Replication.Sync; 43 | } 44 | 45 | let model: Model; 46 | 47 | // INIT 48 | export function initPouch(msg$: Stream): void { 49 | /* Set model, launch subscriptions */ 50 | initModel().then(m => { 51 | console.log(m); 52 | model = m; 53 | 54 | // Command subscriptions 55 | msg$.debug().addListener({ 56 | next: msg => update(msg), 57 | error: err => console.error(err), 58 | complete: () => console.log('completed') 59 | }); 60 | 61 | // DB change subscriptions 62 | model.localDB 63 | .changes({ live: true, include_docs: true, since: 'now' }) 64 | .on('change', info => { 65 | console.log('Something changed!', info); 66 | const { doc } = info; 67 | 68 | // Send all updates to the elm side, as appropriate 69 | // TODO: might want to batch things into one big updatedEntries 70 | if (doc && doc._deleted) { 71 | console.log('Deleted doc'); 72 | app.ports.deletedEntry.send({ _id: doc._id }); 73 | } else { 74 | console.log('Updated doc'); 75 | app.ports.updatedEntry.send(doc); 76 | } 77 | }) 78 | .on('complete', info => { 79 | console.log('Replication complete'); 80 | }) 81 | .on('error', (err: { error?: string; message?: string }) => { 82 | if (err.error === 'unauthorized') { 83 | console.error(err.message); 84 | } else { 85 | console.error('Unhandled error', err); 86 | } 87 | }); 88 | }); 89 | } 90 | 91 | function initModel(): Promise { 92 | // PouchDB Devtools integration 93 | window.PouchDB = PouchDB; 94 | 95 | // Set up PouchDB 96 | PouchDB.plugin(PouchAuth); 97 | let localDB = new PouchDB('ephemeral'); 98 | 99 | // Before checking logins, we use the base url to check _users and _sessions 100 | // After that, we customise using initRemoteDB() to include the user's db suffix 101 | // NOTE: there seems to be a bug(?), where leaving the naked URL in will 102 | // result in an error. Thus passing a doc path after (_users for convenience) 103 | // is required. This is fine because the DB url is overwritten afterwards. 104 | let url = `${config.couchUrl}_users`; 105 | let tempRemote = new PouchDB(url, { skip_setup: true }); 106 | 107 | return getUserIfLoggedIn(tempRemote).then(res => { 108 | let remoteDB: EphemeralDB, syncHandler: PouchDB.Replication.Sync; 109 | 110 | return unpack( 111 | function(err) { 112 | console.warn(err, 'Will not sync.'); 113 | return { localDB, remoteDB: tempRemote }; 114 | }, 115 | function(username) { 116 | console.info('User is logged in, will sync.'); 117 | 118 | // Configure remote as appropriate for each environment 119 | if (config.environment == 'production') { 120 | remoteDB = initRemoteDB(config.couchUrl, { 121 | method: 'dbPerUser', 122 | username 123 | }); 124 | } else { 125 | remoteDB = initRemoteDB(config.couchUrl, { 126 | method: 'direct', 127 | dbName: config.dbName 128 | }); 129 | } 130 | // Set the sync handler and start sync 131 | syncHandler = syncRemote(localDB, remoteDB); 132 | return { localDB, remoteDB, syncHandler }; 133 | }, 134 | res 135 | ); 136 | }); 137 | } 138 | 139 | // MSG 140 | type Msg = { msgType: MsgType; data: DataType }; 141 | 142 | export function Msg(type: A, data: B): Msg { 143 | return { 144 | msgType: type, 145 | data: data 146 | }; 147 | } 148 | 149 | export type PouchMsg = 150 | | Msg<'LoginUser', LoginUser> // User 151 | | Msg<'LogoutUser'> 152 | | Msg<'CheckAuth'> 153 | | Msg<'UpdateEntry', ExistingDocument<{}>> // Entry 154 | | Msg<'SaveEntry', NewDocument> // Entry 155 | | Msg<'DeleteEntry', DocumentID> // EntryId 156 | | Msg<'ListEntries'> 157 | | Msg<'ExportCards', ExportMethod>; 158 | 159 | // UPDATE 160 | function update(msg: PouchMsg) { 161 | switch (msg.msgType) { 162 | case 'LoginUser': 163 | loginUser(model.remoteDB, msg.data).then(name => { 164 | // TODO: send error if failed 165 | if (name) { 166 | let newModel = startSync(name); 167 | // TODO: send error if failed 168 | // Update references to remoteDB and syncHandler 169 | model = { ...model, ...newModel }; 170 | } 171 | }); 172 | break; 173 | case 'LogoutUser': 174 | logOut(model.remoteDB); 175 | break; 176 | case 'CheckAuth': 177 | checkAuth(model.remoteDB); 178 | break; 179 | case 'UpdateEntry': 180 | updateEntry(model.localDB, msg.data); 181 | break; 182 | case 'SaveEntry': 183 | saveEntry(model.localDB, msg.data); 184 | break; 185 | case 'DeleteEntry': 186 | deleteEntry(model.localDB, msg.data); 187 | break; 188 | case 'ListEntries': 189 | listEntries(model.localDB); 190 | break; 191 | case 'ExportCards': 192 | exportCards(model.localDB, msg.data); 193 | break; 194 | default: 195 | console.warn('Pouch command not recognised'); 196 | } 197 | } 198 | 199 | // Update functions 200 | function loginUser(remoteDB: EphemeralDB, user: LoginUser) { 201 | console.log('Got user to log in', user.username); 202 | 203 | let { username, password } = user; 204 | 205 | return remoteDB.logIn(username, password).then(res => { 206 | console.log('Logged in!', res); 207 | 208 | if (res.ok === true) { 209 | let { name } = res; 210 | 211 | // Report that we logged in successfully 212 | app.ports.logIn.send({ username: name }); 213 | return name; 214 | } else { 215 | // TODO: send to Elm / show toast 216 | console.error('Something went wrong when logging in'); 217 | } 218 | }); 219 | } 220 | 221 | function startSync(username: string) { 222 | let remoteDB: EphemeralDB, syncHandler: PouchDB.Replication.Sync; 223 | 224 | // Pick the correct remote database 225 | if (config.environment == 'production') { 226 | remoteDB = initRemoteDB(config.couchUrl, { 227 | method: 'dbPerUser', 228 | username: name 229 | }); 230 | } else { 231 | remoteDB = initRemoteDB(config.couchUrl, { 232 | method: 'direct', 233 | dbName: config.dbName 234 | }); 235 | } 236 | 237 | try { 238 | // Return a sync handler and the configured remoteDB 239 | syncHandler = syncRemote(model.localDB, remoteDB); 240 | return { remoteDB, syncHandler }; 241 | } catch (err) { 242 | // TODO: send error over port 243 | if (err.name === 'unauthorized') { 244 | console.warn('Unauthorized'); 245 | } else { 246 | console.error('Other error', err); 247 | } 248 | } 249 | } 250 | 251 | function logOut(remoteDB: EphemeralDB) { 252 | console.log('Got message to log out'); 253 | 254 | // Cancel sync before logging out, otherwise we don't have auth 255 | console.info('Stopping sync'); 256 | // TODO: decide where syncHandler should live 257 | cancelSync(model.syncHandler); 258 | 259 | remoteDB.logOut().then(res => { 260 | // res: {"ok": true} 261 | console.log('Logging user out'); 262 | if (res.ok) { 263 | app.ports.logOut.send(res); 264 | } else { 265 | console.error('Something went wrong logging user out'); 266 | } 267 | }); 268 | } 269 | 270 | function checkAuth(remoteDB: EphemeralDB) { 271 | console.log('Checking Auth'); 272 | 273 | remoteDB 274 | .getSession() 275 | .then(res => { 276 | if (!res.userCtx.name) { 277 | // res: {"ok": true} 278 | console.log('No user logged in, logging user out', res); 279 | let { ok } = res; 280 | app.ports.logOut.send({ ok: ok }); 281 | } else { 282 | console.log('User is logged in', res); 283 | let { name } = res.userCtx; 284 | // TODO: in the future, will need to add more info 285 | app.ports.logIn.send({ username: name }); 286 | } 287 | }) 288 | .catch(err => { 289 | console.error('Error checking Auth', err); 290 | }); 291 | } 292 | 293 | function updateEntry(db: EphemeralDB, data: ExistingDocument<{}>) { 294 | console.log('Got entry to update', data); 295 | 296 | let { _id } = data; 297 | console.log(_id); 298 | 299 | db 300 | .get(_id) 301 | .then(doc => { 302 | // NOTE: We disregard the _rev from Elm, to be safe 303 | let { _rev } = doc; 304 | 305 | let newDoc = Object.assign(doc, data); 306 | newDoc._rev = _rev; 307 | 308 | return db.put(newDoc); 309 | }) 310 | .then(res => { 311 | db.get(res.id).then(doc => { 312 | console.log('Successfully updated', doc); 313 | app.ports.updatedEntry.send(doc); 314 | }); 315 | }) 316 | .catch(err => { 317 | console.error('Failed to update', err); 318 | // TODO: Send back over port that Err error 319 | }); 320 | } 321 | 322 | function saveEntry(db: EphemeralDB, data: NewDocument) { 323 | console.log('Got entry to create', data); 324 | // TODO: play with this 325 | let meta = { type: 'entry' as 'entry' }; 326 | let doc: Entry = Object.assign(data, meta); 327 | 328 | db 329 | .post(doc) 330 | .then(res => { 331 | db.get(res.id).then(doc => { 332 | console.log('Successfully created', doc); 333 | app.ports.updatedEntry.send(doc); 334 | }); 335 | }) 336 | .catch(err => { 337 | console.error('Failed to create', err); 338 | // TODO: Send back over port that Err error? 339 | }); 340 | } 341 | 342 | function deleteEntry(db: EphemeralDB, id: DocumentID) { 343 | console.log('Got entry to delete', id); 344 | 345 | db 346 | .get(id) 347 | .then(doc => { 348 | return db.remove(doc); 349 | }) 350 | .then(res => { 351 | console.log('Successfully deleted', id); 352 | app.ports.deletedEntry.send({ _id: id }); 353 | }) 354 | .catch(err => { 355 | console.error('Failed to delete', err); 356 | // TODO: Send back over port that Err error 357 | }); 358 | } 359 | 360 | function listEntries(db: EphemeralDB) { 361 | console.log('Will list entries'); 362 | let docs = db.allDocs({ include_docs: true }).then(docs => { 363 | let entries = docs.rows 364 | .map(row => row.doc) 365 | .filter(doc => doc && isEntry(doc)) as Entry[]; 366 | console.log('Listing entries', entries); 367 | 368 | app.ports.getEntries.send(entries); 369 | }); 370 | } 371 | 372 | function exportCards(db: EphemeralDB, method: ExportMethod) { 373 | import(/* webpackChunkName: "export-chunk" */ '../export/export').then( 374 | ({ exportCardsCSV, exportCardsAnki }) => { 375 | db.allDocs({ include_docs: true }).then(docs => { 376 | let entries = docs.rows 377 | .map(row => row.doc) 378 | .filter(row => row !== undefined && isEntry(row)) as Entry[]; // assertion required to convince of undefined removal 379 | if (method === 'CSV') { 380 | exportCardsCSV(entries); 381 | } else if (method === 'ANKI') { 382 | exportCardsAnki(entries); 383 | } 384 | }); 385 | } 386 | ); 387 | } 388 | 389 | // Utils 390 | type UserResult = Either; 391 | type UserError = 'NOT_LOGGED_IN' | 'ERROR_CONNECTING'; 392 | function getUserIfLoggedIn(remoteDB: EphemeralDB): Promise { 393 | let loggedIn = remoteDB 394 | .getSession() 395 | .then(res => { 396 | if (!res.userCtx.name) { 397 | return Either.left('NOT_LOGGED_IN' as 'NOT_LOGGED_IN'); 398 | } else { 399 | return Either.of(res.userCtx.name); 400 | } 401 | }) 402 | .catch(err => { 403 | return Either.left('ERROR_CONNECTING' as 'ERROR_CONNECTING'); 404 | }); 405 | return loggedIn; 406 | } 407 | 408 | function syncRemote( 409 | localDB: EphemeralDB, 410 | remoteDB: EphemeralDB 411 | ): PouchDB.Replication.Sync { 412 | console.info('Starting sync'); 413 | 414 | let syncHandler = localDB.sync(remoteDB, { 415 | live: true, 416 | retry: true 417 | }); 418 | return syncHandler; 419 | } 420 | 421 | function cancelSync(handler?: PouchDB.Replication.Sync) { 422 | if (!!handler) { 423 | // TODO: handle errors (lol) 424 | handler.cancel(); 425 | } 426 | return true; 427 | } 428 | 429 | // Utils 430 | type DBInitParams = 431 | | { method: 'direct'; dbName: string } 432 | | { method: 'dbPerUser'; username: string }; 433 | 434 | function initRemoteDB(couchUrl: string, initParams: DBInitParams): EphemeralDB { 435 | let dbName; 436 | 437 | /* Using db-per-user in production, so we must figure out the user's db. 438 | The couch-per-user plugin makes a DB of the form: 439 | userdb-{hex username} 440 | */ 441 | if (initParams.method === 'dbPerUser') { 442 | dbName = 'userdb-' + string2Hex(initParams.username); 443 | } else { 444 | dbName = initParams.dbName; 445 | } 446 | 447 | let url = couchUrl + dbName; 448 | let remoteDB = new PouchDB(url, { skip_setup: true }); 449 | 450 | return remoteDB; 451 | } 452 | -------------------------------------------------------------------------------- /src/Pouch/types.ts: -------------------------------------------------------------------------------- 1 | // Entry 2 | export interface EntryContent { 3 | // TODO: play with this 4 | content: string; 5 | translation: string; 6 | } 7 | 8 | export interface Entry extends EntryContent { 9 | type: 'entry'; 10 | } 11 | 12 | export function isEntry(doc: {}): doc is Entry { 13 | return (doc).type === 'entry'; 14 | } 15 | 16 | // Database Contents 17 | // Add things to the union as appropriate 18 | // Leaving {} is desirable, in case we want some arbitrary data and 19 | // we already check isEntry() when needed 20 | export type DBDoc = Entry | {}; 21 | export type EphemeralDB = PouchDB.Database; 22 | 23 | // User Login types 24 | export interface LoginUser { 25 | username: string; 26 | password: string; 27 | } 28 | 29 | // Taken from PouchDB types 30 | export interface IdMeta { 31 | _id: string; 32 | } 33 | export interface RevisionIdMeta { 34 | _rev: string; 35 | } 36 | export type NewDocument = Content; 37 | export type Document = Content & IdMeta; 38 | export type ExistingDocument = Document & 39 | RevisionIdMeta; 40 | 41 | // Convenience alias 42 | export type DocumentID = string; // this is silly, but TS simple types are meh 43 | export type ExportMethod = 'CSV' | 'ANKI'; 44 | -------------------------------------------------------------------------------- /src/Request/Entry.elm: -------------------------------------------------------------------------------- 1 | module Request.Entry 2 | exposing 3 | ( list 4 | , create 5 | , update 6 | , delete 7 | , decodeEntryList 8 | , decodePouchEntries 9 | , decodePouchEntry 10 | , decodeDeletedEntry 11 | ) 12 | 13 | import Data.Entry as Entry 14 | exposing 15 | ( Entry 16 | , EntryId 17 | , EntryLocation 18 | , encodeEntry 19 | , encodeEntryLocation 20 | , idToString 21 | , decodeEntry 22 | ) 23 | import Date exposing (Date) 24 | import Date.Extra.Format exposing (utcIsoString) 25 | import Dict exposing (Dict) 26 | import Json.Decode as Decode 27 | import Json.Decode.Pipeline as P exposing (decode, required) 28 | import Json.Encode as Encode exposing (Value) 29 | import Pouch.Ports 30 | 31 | 32 | -- LIST -- 33 | 34 | 35 | list : Cmd msg 36 | list = 37 | Pouch.Ports.listEntries "list" 38 | 39 | 40 | 41 | -- CREATE -- 42 | 43 | 44 | type alias CreateConfig record = 45 | { record 46 | | content : String 47 | , translation : String 48 | , addedAt : Date 49 | , location : EntryLocation 50 | } 51 | 52 | 53 | type alias EditConfig record = 54 | { record 55 | | content : String 56 | , translation : String 57 | } 58 | 59 | 60 | create : CreateConfig record -> Cmd msg 61 | create config = 62 | let 63 | entry = 64 | Encode.object 65 | [ ( "content", Encode.string config.content ) 66 | , ( "translation", Encode.string config.translation ) 67 | , ( "added_at", Encode.string <| utcIsoString config.addedAt ) 68 | , ( "location", encodeEntryLocation config.location ) 69 | ] 70 | in 71 | Pouch.Ports.saveEntry entry 72 | 73 | 74 | update : EntryId -> String -> EditConfig record -> Cmd msg 75 | update entryId rev config = 76 | let 77 | id_ = 78 | idToString entryId 79 | 80 | entry = 81 | Encode.object 82 | [ ( "content", Encode.string config.content ) 83 | , ( "translation", Encode.string config.translation ) 84 | , ( "_id", Encode.string id_ ) 85 | , ( "_rev", Encode.string rev ) 86 | ] 87 | in 88 | Pouch.Ports.updateEntry entry 89 | 90 | 91 | delete : EntryId -> Cmd msg 92 | delete entryId = 93 | let 94 | id_ = 95 | idToString entryId 96 | in 97 | Pouch.Ports.deleteEntry id_ 98 | 99 | 100 | 101 | -- Called from subscriptions -- 102 | 103 | 104 | decodePouchEntries : (List Entry -> msg) -> Value -> msg 105 | decodePouchEntries toMsg val = 106 | let 107 | result = 108 | Decode.decodeValue decodeEntryList val 109 | 110 | entries = 111 | case result of 112 | Err err -> 113 | [] 114 | 115 | Ok entryList -> 116 | entryList 117 | in 118 | toMsg entries 119 | 120 | 121 | decodePouchEntry : (Result String Entry -> msg) -> Value -> msg 122 | decodePouchEntry toMsg entry = 123 | let 124 | result = 125 | Decode.decodeValue decodeEntry entry 126 | in 127 | toMsg result 128 | 129 | 130 | decodeEntryList : Decode.Decoder (List Entry) 131 | decodeEntryList = 132 | Decode.list (Entry.decodeEntry) 133 | 134 | 135 | decodeDeletedEntry : (Result String EntryId -> msg) -> Value -> msg 136 | decodeDeletedEntry toMsg val = 137 | let 138 | decodeVal = 139 | decode identity 140 | |> P.required "_id" Decode.string 141 | 142 | result = 143 | Decode.decodeValue decodeVal val 144 | in 145 | toMsg (Result.map (Entry.EntryId) result) 146 | -------------------------------------------------------------------------------- /src/Request/Helpers.elm: -------------------------------------------------------------------------------- 1 | module Request.Helpers exposing (apiUrl) 2 | 3 | 4 | apiUrl : String -> String 5 | apiUrl str = 6 | "http://localhost:3000/api" ++ str 7 | -------------------------------------------------------------------------------- /src/Route.elm: -------------------------------------------------------------------------------- 1 | module Route exposing (Route(..), fromLocation, href, modifyUrl) 2 | 3 | import Html exposing (Attribute) 4 | import Html.Attributes as Attr 5 | import Navigation exposing (Location) 6 | import UrlParser as Url exposing ((), Parser, oneOf, parseHash, s, string) 7 | 8 | 9 | -- ROUTING -- 10 | 11 | 12 | type Route 13 | = Home 14 | | Login 15 | | FullMap 16 | | Logout 17 | | Settings 18 | | NewEntry 19 | 20 | 21 | 22 | -- | EditEntry Entry.EntryId 23 | 24 | 25 | route : Parser (Route -> a) a 26 | route = 27 | oneOf 28 | [ Url.map Home (s "") 29 | , Url.map Login (s "login") 30 | , Url.map Logout (s "logout") 31 | , Url.map Settings (s "settings") 32 | , Url.map FullMap (s "map") 33 | , Url.map NewEntry (s "entry") 34 | ] 35 | 36 | 37 | 38 | -- INTERNAL -- 39 | 40 | 41 | routeToString : Route -> String 42 | routeToString page = 43 | let 44 | pieces = 45 | case page of 46 | Home -> 47 | [] 48 | 49 | Login -> 50 | [ "login" ] 51 | 52 | Logout -> 53 | [ "logout" ] 54 | 55 | Settings -> 56 | [ "settings" ] 57 | 58 | NewEntry -> 59 | [ "entry" ] 60 | 61 | FullMap -> 62 | [ "map" ] 63 | in 64 | "#/" ++ String.join "/" pieces 65 | 66 | 67 | 68 | -- PUBLIC HELPERS -- 69 | 70 | 71 | href : Route -> Attribute msg 72 | href route = 73 | Attr.href (routeToString route) 74 | 75 | 76 | modifyUrl : Route -> Cmd msg 77 | modifyUrl = 78 | routeToString >> Navigation.modifyUrl 79 | 80 | 81 | fromLocation : Location -> Maybe Route 82 | fromLocation location = 83 | if String.isEmpty location.hash then 84 | Just Home 85 | else 86 | parseHash route location 87 | -------------------------------------------------------------------------------- /src/Util.elm: -------------------------------------------------------------------------------- 1 | module Util exposing ((=>), pair, viewDate, viewIf) 2 | 3 | import Html exposing (Html) 4 | import Date exposing (Date) 5 | import Date.Extra.Config.Config_en_gb exposing (config) 6 | import Date.Extra.Format exposing (format) 7 | 8 | 9 | (=>) : a -> b -> ( a, b ) 10 | (=>) = 11 | (,) 12 | 13 | 14 | {-| infixl 0 means the (=>) operator has the same precedence as (<|) and (|>), 15 | meaning you can use it at the end of a pipeline and have the precedence work out. 16 | -} 17 | infixl 0 => 18 | 19 | 20 | {-| Useful when building up a Cmd via a pipeline, and then pairing it with 21 | a model at the end. 22 | session.user 23 | |> User.Request.foo 24 | |> Task.attempt Foo 25 | |> pair { model | something = blah } 26 | -} 27 | pair : a -> b -> ( a, b ) 28 | pair first second = 29 | first => second 30 | 31 | 32 | viewDate : Date -> String 33 | viewDate date = 34 | format config config.format.dateTime date 35 | 36 | 37 | viewIf : Bool -> Html msg -> Html msg 38 | viewIf condition content = 39 | if condition then 40 | content 41 | else 42 | Html.text "" 43 | -------------------------------------------------------------------------------- /src/Views/General.elm: -------------------------------------------------------------------------------- 1 | module Views.General 2 | exposing 3 | ( formField 4 | , epButton 5 | , avatar 6 | , paragraph 7 | ) 8 | 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (onInput) 12 | 13 | 14 | formField : String -> (String -> msg) -> String -> String -> String -> String -> Html msg 15 | formField inputValue msg inputId labelText inputType descText = 16 | -- TODO: That signature... consider a config record of some kind 17 | -- TODO: allow extra attributes 18 | div [ class "mb3" ] 19 | [ label [ class "f6 b db mv2", for inputId ] [ text labelText ] 20 | , input 21 | [ attribute "aria-describedby" <| inputId ++ "-desc" 22 | , value inputValue 23 | , class "input-reset ba b--black-20 pa2 mb2 db w-100 br1" 24 | , id inputId 25 | , type_ inputType 26 | , onInput msg 27 | ] 28 | [] 29 | , small [ class "f6 black-60 db mb2", id <| inputId ++ "-desc" ] 30 | [ text descText ] 31 | ] 32 | 33 | 34 | epButton : List (Attribute msg) -> List (Html msg) -> Html msg 35 | epButton attributes children = 36 | button 37 | ((class "f6 link dim pa3 dib bg-dark-blue bw0 br1 pointer") 38 | :: attributes 39 | ) 40 | children 41 | 42 | 43 | avatar : String -> List (Attribute msg) -> Html msg 44 | avatar name attributes = 45 | div 46 | ((class "flex items-center justify-center") :: attributes) 47 | [ img 48 | [ src "icon.png", class "br-100 h2 w2 mr2 bg-main-blue", alt "avatar" ] 49 | [] 50 | , span [ class "db fw6 f6 black-80" ] [ text name ] 51 | ] 52 | 53 | 54 | paragraph : List (Html.Attribute msg) -> List (Html.Html msg) -> Html msg 55 | paragraph attributes children = 56 | p ((class "lh-copy f5 mb3 black-80") :: attributes) 57 | children 58 | -------------------------------------------------------------------------------- /src/Views/Icons.elm: -------------------------------------------------------------------------------- 1 | module Views.Icons 2 | exposing 3 | ( edit 4 | , list 5 | , logIn 6 | , logOut 7 | , settings 8 | , map 9 | ) 10 | 11 | import Html exposing (Html) 12 | import Svg exposing (Svg, svg) 13 | import Svg.Attributes exposing (..) 14 | 15 | 16 | svgFeatherIcon : String -> List (Svg msg) -> Html msg 17 | svgFeatherIcon className = 18 | svg 19 | [ class <| "feather feather-" ++ className 20 | , fill "none" 21 | , height "24" 22 | , stroke "currentColor" 23 | , strokeLinecap "round" 24 | , strokeLinejoin "round" 25 | , strokeWidth "2" 26 | , viewBox "0 0 24 24" 27 | , width "24" 28 | ] 29 | 30 | 31 | edit : Html msg 32 | edit = 33 | svgFeatherIcon "edit" 34 | [ Svg.path [ d "M20 14.66V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.34" ] [] 35 | , Svg.polygon [ points "18 2 22 6 12 16 8 16 8 12 18 2" ] [] 36 | ] 37 | 38 | 39 | list : Html msg 40 | list = 41 | svgFeatherIcon "list" 42 | [ Svg.line [ x1 "8", y1 "6", x2 "21", y2 "6" ] [] 43 | , Svg.line [ x1 "8", y1 "12", x2 "21", y2 "12" ] [] 44 | , Svg.line [ x1 "8", y1 "18", x2 "21", y2 "18" ] [] 45 | , Svg.line [ x1 "3", y1 "6", x2 "3", y2 "6" ] [] 46 | , Svg.line [ x1 "3", y1 "12", x2 "3", y2 "12" ] [] 47 | , Svg.line [ x1 "3", y1 "18", x2 "3", y2 "18" ] [] 48 | ] 49 | 50 | 51 | logIn : Html msg 52 | logIn = 53 | svgFeatherIcon "log-in" 54 | [ Svg.path [ d "M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" ] [] 55 | , Svg.polyline [ points "10 17 15 12 10 7" ] [] 56 | , Svg.line [ x1 "15", y1 "12", x2 "3", y2 "12" ] [] 57 | ] 58 | 59 | 60 | logOut : Html msg 61 | logOut = 62 | svgFeatherIcon "log-out" 63 | [ Svg.path [ d "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" ] [] 64 | , Svg.polyline [ points "16 17 21 12 16 7" ] [] 65 | , Svg.line [ x1 "21", y1 "12", x2 "9", y2 "12" ] [] 66 | ] 67 | 68 | 69 | settings : Html msg 70 | settings = 71 | svgFeatherIcon "settings" 72 | [ Svg.circle [ cx "12", cy "12", r "3" ] [] 73 | , Svg.path [ d "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" ] [] 74 | ] 75 | 76 | 77 | map : Html msg 78 | map = 79 | svgFeatherIcon "map" 80 | [ Svg.polygon [ points "1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" ] [] 81 | , Svg.line [ x1 "8", y1 "2", x2 "8", y2 "18" ] [] 82 | , Svg.line [ x1 "16", y1 "6", x2 "16", y2 "22" ] [] 83 | ] 84 | -------------------------------------------------------------------------------- /src/Views/Page.elm: -------------------------------------------------------------------------------- 1 | module Views.Page exposing (ActivePage(..), frame, fullFrame) 2 | 3 | import Data.User exposing (User) 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Route exposing (Route) 7 | import Views.General exposing (avatar, epButton) 8 | import Views.Icons as Icons 9 | 10 | 11 | type ActivePage 12 | = Other 13 | | Home 14 | | Login 15 | | Settings 16 | | NewEntry 17 | | FullMap 18 | 19 | 20 | frame : Maybe User -> ActivePage -> Html msg -> Html msg 21 | frame user page content = 22 | div [] 23 | [ viewMenu page user 24 | 25 | -- , viewHeader user 26 | , div [ class "pa3 pt4 ph5-ns bg-white" ] 27 | [ div [ class "mw7-ns center" ] [ content ] 28 | , viewFooter 29 | ] 30 | ] 31 | 32 | 33 | fullFrame : Maybe User -> ActivePage -> Html msg -> Html msg 34 | fullFrame user page content = 35 | div [] 36 | [ viewMenu page user 37 | , content 38 | ] 39 | 40 | 41 | viewMenu : ActivePage -> Maybe User -> Html msg 42 | viewMenu page user = 43 | div [ class "h-nav fixed bottom-0 left-0 w-100 z-999" ] 44 | [ nav [ class "h-100 mw7-ns center flex flex-row f6 f5-ns black bg-nav bt b--black-20" ] <| 45 | [ navbarLink (page == Home) Route.Home [ iconAndText Icons.list "List" ] 46 | , navbarLink (page == NewEntry) Route.NewEntry [ iconAndText Icons.edit "Add" ] 47 | , navbarLink (page == FullMap) Route.FullMap [ iconAndText Icons.map "Add" ] 48 | , navbarLink (page == Settings) Route.Settings [ iconAndText Icons.settings "Settings" ] 49 | ] 50 | ++ viewSignIn page user 51 | ] 52 | 53 | 54 | viewSignIn : ActivePage -> Maybe User -> List (Html msg) 55 | viewSignIn page user = 56 | case user of 57 | Nothing -> 58 | [ navbarLink (page == Login) Route.Login [ iconAndText Icons.logIn "Login" ] 59 | ] 60 | 61 | Just user -> 62 | [ navbarLink False Route.Logout [ iconAndText Icons.logOut "Logout" ] 63 | ] 64 | 65 | 66 | iconAndText : Html msg -> a -> Html msg 67 | iconAndText icon txt = 68 | -- , p [ class "mt1 mb0" ] [ text txt ] 69 | div [ class "mv0 center relative flex flex-column items-center justify-center" ] [ icon ] 70 | 71 | 72 | viewHeader : Maybe User -> Html msg 73 | viewHeader loggedIn = 74 | let 75 | name = 76 | case loggedIn of 77 | Nothing -> 78 | "Guest" 79 | 80 | Just user -> 81 | user.username 82 | in 83 | div [ class "pa4" ] 84 | [ avatar name [ class "pointer mw4 center" ] 85 | ] 86 | 87 | 88 | viewFooter : Html msg 89 | viewFooter = 90 | div [ class "mt4" ] 91 | [ hr [ class "mv0 w-100 bb bw1 b--black-10" ] [] 92 | , div 93 | [ class "pv3 tc" ] 94 | [ p [ class "f6 lh-copy measure center" ] 95 | [ text "Ephemeral is an app for writing down words and their translations, as you encounter them" 96 | ] 97 | , span [ class "f6 lh-copy measure center" ] 98 | [ text "Made with 😭 by Fotis Papadogeogopoulos" 99 | ] 100 | ] 101 | ] 102 | 103 | 104 | navbarLink : Bool -> Route -> List (Html msg) -> Html msg 105 | navbarLink isActive route linkContent = 106 | div [ class "h-100 flex flex-column flex-grow-1 justify-center items-center" ] 107 | [ a [ classList [ ( "w-100 h-100 flex items-center b", True ), ( "white hover-white", isActive ), ( "dim nav-disabled", not isActive ) ], Route.href route ] linkContent ] 108 | -------------------------------------------------------------------------------- /src/assets/css/styles.scss: -------------------------------------------------------------------------------- 1 | .sticky { 2 | position: sticky; 3 | } 4 | 5 | // @import url('https://fonts.googleapis.com/css?family=Pacifico'); 6 | @import "~tachyons"; 7 | @import "~leaflet/dist/leaflet.css"; 8 | 9 | /* Heights */ 10 | $navheight: 48px; 11 | 12 | // Height that takes into account header and paddings 13 | .h-fullmap { 14 | // 100 vh - Header height - header padding - all padding - navheight 15 | height: calc(100vh - 2rem - 2 * .5rem - #{$navheight}); 16 | } 17 | 18 | .events-none { 19 | pointer-events: none; 20 | } 21 | 22 | .events-auto { 23 | pointer-events: auto; 24 | } 25 | 26 | @media screen and (min-width: 30em) { 27 | .h6-ns { 28 | height: 24rem; 29 | } 30 | 31 | .h-fullmap-ns { 32 | // 100 vh - Header height - header padding - all padding 33 | // NOTE: not sure why 1 * 1rem instead of 2 * 34 | height: calc(100vh - 4rem - 2 * 1rem - 1 * 1rem - #{$navheight}); 35 | } 36 | 37 | .min-h-container-ns { 38 | // 100 vh - all padding on body 39 | min-height: calc(100vh - 2 * 1rem); 40 | } 41 | } 42 | 43 | // Based on the icons being 24px 44 | .h-nav { 45 | height: $navheight; 46 | } 47 | 48 | .logo { 49 | background-image: url("../logo.svg"); 50 | } 51 | 52 | .shadow-card { 53 | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); 54 | } 55 | 56 | .shadow-button { 57 | box-shadow: 0px 4px 0px rgba(0, 0, 0, 0.2); 58 | } 59 | 60 | .shadow-button:focus { 61 | box-shadow: 0px -2px 0px rgba(0, 0, 0, 0.2); 62 | } 63 | 64 | .bg-nav { 65 | background-color: #304b63; 66 | } 67 | 68 | .nav-disabled { 69 | color: #bbb; 70 | // color: #adccc7; 71 | } 72 | 73 | .bg-beige-gray { 74 | background-color: #ede4df; 75 | } 76 | 77 | .bg-beige-gray-2 { 78 | background-color: #e3e2e4; 79 | } 80 | 81 | .bg-pale-red { 82 | background-color: #ef8775; 83 | } 84 | 85 | .bg-deep-red { 86 | background-color: #db450e; 87 | } 88 | 89 | .bg-main-blue { 90 | background-color: #a5dbf7; 91 | } 92 | 93 | .bg-deep-blue { 94 | background-color: #134886; 95 | } 96 | 97 | .bg-sharp-blue { 98 | background-color: #37a4d5; 99 | } 100 | 101 | .bg-muted-blue { 102 | background-color: #6798a4; 103 | } 104 | 105 | .bg-gray-blue { 106 | background-color: #3d7ab2; 107 | } 108 | 109 | .main-blue { 110 | color: #a5dbf7; 111 | } 112 | 113 | .muted-blue { 114 | color: rgb(72, 122, 172); 115 | } 116 | 117 | .sharp-blue { 118 | color: #37a4d5; 119 | } 120 | 121 | .red-brown { 122 | color: #ef8775; 123 | } 124 | 125 | .deep-blue { 126 | color: #1556a3; 127 | } 128 | 129 | .beige-gray { 130 | color: #ede4df; 131 | } 132 | 133 | .beige-gray-2 { 134 | color: #e3e2e4; 135 | } 136 | 137 | /* Quasi-component things */ 138 | a.close { 139 | float: right; 140 | margin-top: -25px; 141 | margin-right: -25px; 142 | text-decoration: none; 143 | height: 25px; 144 | width: 25px; 145 | border-radius: 25px; 146 | font-size: 22px; 147 | line-height: 19px; 148 | background-color: #e3e2e4; 149 | cursor: pointer; 150 | text-align: center; 151 | white-space: nowrap; 152 | } 153 | 154 | a.close:hover { 155 | background-color: #db8972; 156 | } 157 | 158 | /*! driveway CSS | @license MIT | @author jh3y */ 159 | .dw { 160 | -webkit-box-sizing: border-box; 161 | box-sizing: border-box; 162 | -webkit-column-gap: 0; 163 | -moz-column-gap: 0; 164 | column-gap: 0; 165 | position: relative; 166 | } 167 | .dw * { 168 | -webkit-box-sizing: border-box; 169 | box-sizing: border-box; 170 | } 171 | .dw__focus-curtain { 172 | background-color: #000; 173 | bottom: 0; 174 | display: none; 175 | left: 0; 176 | opacity: 0.75; 177 | position: fixed; 178 | right: 0; 179 | top: 0; 180 | z-index: 2; 181 | } 182 | @media (min-width: 768px) { 183 | .dw { 184 | -webkit-column-count: 1; 185 | -moz-column-count: 1; 186 | column-count: 1; 187 | } 188 | } 189 | @media (min-width: 992px) { 190 | .dw { 191 | -webkit-column-count: 2; 192 | -moz-column-count: 2; 193 | column-count: 2; 194 | } 195 | } 196 | @media (min-width: 1500px) { 197 | .dw { 198 | -webkit-column-count: 3; 199 | -moz-column-count: 3; 200 | column-count: 3; 201 | } 202 | } 203 | .dw-panel { 204 | margin: 0; 205 | padding: 5px; 206 | } 207 | .dw-panel--focus { 208 | position: relative; 209 | } 210 | .dw-panel--focus:hover { 211 | z-index: 3; 212 | } 213 | .dw-panel--focus:hover ~ .dw__focus-curtain { 214 | display: block; 215 | } 216 | .dw-panel--pulse { 217 | -webkit-transform-style: preserve-3d; 218 | transform-style: preserve-3d; 219 | -webkit-perspective: 1000; 220 | perspective: 1000; 221 | -webkit-transition: -webkit-transform 0.25s ease 0s; 222 | transition: -webkit-transform 0.25s ease 0s; 223 | transition: transform 0.25s ease 0s; 224 | transition: transform 0.25s ease 0s, -webkit-transform 0.25s ease 0s; 225 | } 226 | .dw-panel--pulse:hover { 227 | -webkit-transform: scale(1.02); 228 | -ms-transform: scale(1.02); 229 | transform: scale(1.02); 230 | } 231 | .dw-panel__content { 232 | border-radius: 10px; 233 | overflow: hidden; 234 | /*padding: 20px;*/ 235 | width: 100%; 236 | } 237 | @media (min-width: 768px) { 238 | .dw-panel { 239 | -webkit-column-break-inside: avoid; 240 | page-break-inside: avoid; 241 | break-inside: avoid; 242 | } 243 | } 244 | .dw-flip { 245 | -webkit-perspective: 1000; 246 | perspective: 1000; 247 | } 248 | .dw-flip:hover .dw-flip__content { 249 | -webkit-transform: rotateY(180deg); 250 | transform: rotateY(180deg); 251 | } 252 | .dw-flip--sm { 253 | height: 200px; 254 | } 255 | .dw-flip--md { 256 | height: 300px; 257 | } 258 | .dw-flip--lg { 259 | height: 400px; 260 | } 261 | .dw-flip__panel { 262 | -webkit-backface-visibility: hidden; 263 | backface-visibility: hidden; 264 | border-radius: 10px; 265 | height: 100%; 266 | left: 0; 267 | overflow: visible; 268 | /*padding: 20px;*/ 269 | position: absolute; 270 | top: 0; 271 | width: 100%; 272 | } 273 | .dw-flip__panel--front { 274 | -webkit-transform: rotateY(0deg); 275 | transform: rotateY(0deg); 276 | z-index: 2; 277 | } 278 | .dw-flip__panel--back { 279 | -webkit-transform: rotateY(180deg); 280 | transform: rotateY(180deg); 281 | } 282 | .dw-flip__content { 283 | height: 100%; 284 | overflow: visible; 285 | position: relative; 286 | -webkit-transform-style: preserve-3d; 287 | transform-style: preserve-3d; 288 | -webkit-transition: 0.25s; 289 | transition: 0.25s; 290 | } 291 | .dw-cluster { 292 | display: -webkit-box; 293 | display: -webkit-flex; 294 | display: -ms-flexbox; 295 | display: flex; 296 | padding: 0; 297 | } 298 | @media (max-width: 430px) { 299 | .dw-cluster--vertical { 300 | -webkit-box-orient: vertical; 301 | -webkit-box-direction: normal; 302 | -webkit-flex-direction: column; 303 | -ms-flex-direction: column; 304 | flex-direction: column; 305 | } 306 | } 307 | .dw-cluster--horizontal { 308 | -webkit-box-orient: vertical; 309 | -webkit-box-direction: normal; 310 | -webkit-flex-direction: column; 311 | -ms-flex-direction: column; 312 | flex-direction: column; 313 | } 314 | .dw-cluster__segment { 315 | display: -webkit-box; 316 | display: -webkit-flex; 317 | display: -ms-flexbox; 318 | display: flex; 319 | -webkit-box-flex: 1; 320 | -webkit-flex: 1 1 auto; 321 | -ms-flex: 1 1 auto; 322 | flex: 1 1 auto; 323 | } 324 | .dw-cluster__segment--row { 325 | display: -webkit-box; 326 | display: -webkit-flex; 327 | display: -ms-flexbox; 328 | display: flex; 329 | } 330 | @media (max-width: 430px) { 331 | .dw-cluster__segment--row { 332 | -webkit-box-orient: vertical; 333 | -webkit-box-direction: normal; 334 | -webkit-flex-direction: column; 335 | -ms-flex-direction: column; 336 | flex-direction: column; 337 | } 338 | } 339 | .dw-cluster__segment--col { 340 | -webkit-box-orient: vertical; 341 | -webkit-box-direction: normal; 342 | -webkit-flex-direction: column; 343 | -ms-flex-direction: column; 344 | flex-direction: column; 345 | } 346 | @media (min-width: 430px) { 347 | .dw-cluster__segment--half { 348 | -webkit-flex-basis: 50%; 349 | -ms-flex-preferred-size: 50%; 350 | flex-basis: 50%; 351 | } 352 | .dw-cluster__segment--quart { 353 | -webkit-flex-basis: 25%; 354 | -ms-flex-preferred-size: 25%; 355 | flex-basis: 25%; 356 | } 357 | } 358 | 359 | .elm-overlay { 360 | z-index: 999; 361 | } 362 | -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fpapado/ephemeral/3c3984546e316ee36dbc1895cb1e4d766794eabe/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/export/export.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver'; 2 | import stringify from 'csv-stringify'; 3 | import 'whatwg-fetch'; 4 | import { Card } from './types'; 5 | 6 | export function exportCardsCSV(cards: Card[]) { 7 | cardsToCsv(cards, (err: any, csv: any) => { 8 | if (err) { 9 | console.warn('Error converting to CSV', err); 10 | } else { 11 | let blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); 12 | saveAs(blob, 'ephemeral.csv'); 13 | } 14 | }); 15 | } 16 | 17 | export function exportCardsAnki(cards: Card[]) { 18 | getAnkiPkg(cards) 19 | .then(res => { 20 | return res.blob(); 21 | }) 22 | .then(blob => { 23 | console.log('Will save'); 24 | saveAs(blob, 'ephemeral.apkg'); 25 | }) 26 | .catch(err => { 27 | console.warn('Error communicating with micro-anki server', err); 28 | }); 29 | } 30 | 31 | function cardsToCsv(cards: Card[], cb: any) { 32 | // "Pick" content, translation from cards 33 | let cardEntries = cards.map(({ content, translation }) => { 34 | return { content, translation }; 35 | }); 36 | 37 | return stringify(cardEntries, { header: true }, cb); 38 | } 39 | 40 | function getAnkiPkg(cards: Card[]) { 41 | let cardEntries = cards.map(({ content, translation }) => { 42 | return { front: content, back: translation }; 43 | }); 44 | 45 | let req = fetch('https://micro-anki.now.sh', { 46 | method: 'POST', 47 | headers: new Headers({ 'Content-Type': 'application/json' }), 48 | body: JSON.stringify({ cards: cardEntries }) 49 | }); 50 | 51 | return req; 52 | } 53 | -------------------------------------------------------------------------------- /src/export/types.ts: -------------------------------------------------------------------------------- 1 | export interface Card { 2 | content: string; 3 | translation: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Ephemeral 10 | 11 | 12 | 13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import xs, { MemoryStream, Stream } from 'xstream'; 2 | import { initLeaflet, LeafletMsg } from './Leaflet/index'; 3 | import { initPouch, PouchMsg, Msg } from './Pouch/index'; 4 | import * as OfflinePluginRuntime from 'offline-plugin/runtime'; 5 | import './assets/css/styles.scss'; 6 | import { 7 | NewDocument, 8 | Document, 9 | ExistingDocument, 10 | EntryContent, 11 | DocumentID, 12 | ExportMethod, 13 | LoginUser 14 | } from './Pouch/types'; 15 | import { Main } from 'ephemeral/elm'; 16 | 17 | // Embed Elm 18 | const root = document.getElementById('root') as HTMLElement; 19 | export const app = Main.embed(root); 20 | 21 | // Embed offline plugin runtime 22 | OfflinePluginRuntime.install({ 23 | onUpdating: () => { 24 | console.info('SW Event:', 'onUpdating'); 25 | }, 26 | onUpdateReady: () => { 27 | console.info('SW Event:', 'onUpdateReady'); 28 | // Tells to new SW to take control immediately 29 | OfflinePluginRuntime.applyUpdate(); 30 | }, 31 | onUpdated: () => { 32 | console.info('SW Event:', 'onUpdated'); 33 | // Reload the webpage to load into the new version 34 | window.location.reload(); 35 | }, 36 | 37 | onUpdateFailed: () => { 38 | console.error('SW Event:', 'onUpdateFailed'); 39 | } 40 | }); 41 | 42 | // -- Port Subscriptions -- 43 | // Initialise Leaflet module with a stream of Leaflet-related Messages from Elm 44 | const leafletMsg$: Stream = xs.create({ 45 | start: function(listener) { 46 | app.ports.toLeaflet.subscribe((msg: LeafletMsg) => { 47 | listener.next(msg); 48 | }); 49 | }, 50 | stop: function() { 51 | app.ports.toLeaflet.unsubscribe(); 52 | } 53 | }); 54 | 55 | initLeaflet(leafletMsg$); 56 | 57 | // Initialise Pouch module with a stream of Pouch-related Messages from Elm 58 | // Currently an aggregagtion of ports, pending migration 59 | const pouchMsg$: MemoryStream = xs.createWithMemory({ 60 | start: function(listener) { 61 | // TODO: find a non-silly way to do these 62 | app.ports.sendLogin.subscribe((user: LoginUser) => 63 | listener.next(Msg('LoginUser' as 'LoginUser', user)) 64 | ); 65 | app.ports.sendLogout.subscribe((_: any) => 66 | listener.next(Msg('LogoutUser' as 'LogoutUser', {})) 67 | ); 68 | app.ports.checkAuthState.subscribe((_: any) => 69 | listener.next(Msg('CheckAuth' as 'CheckAuth', {})) 70 | ); 71 | app.ports.updateEntry.subscribe((entry: ExistingDocument<{}>) => 72 | listener.next(Msg('UpdateEntry' as 'UpdateEntry', entry)) 73 | ); 74 | app.ports.saveEntry.subscribe((entry: NewDocument) => 75 | listener.next(Msg('SaveEntry' as 'SaveEntry', entry)) 76 | ); 77 | app.ports.deleteEntry.subscribe((id: DocumentID) => 78 | listener.next(Msg('DeleteEntry' as 'DeleteEntry', id)) 79 | ); 80 | app.ports.listEntries.subscribe((_: any) => 81 | listener.next(Msg('ListEntries' as 'ListEntries', {})) 82 | ); 83 | app.ports.exportCards.subscribe((version: ExportMethod) => { 84 | listener.next(Msg('ExportCards' as 'ExportCards', version)); 85 | }); 86 | }, 87 | stop: function() { 88 | app.ports.sendLogin.unsubscribe(); 89 | app.ports.sendLogout.unsubscribe(); 90 | app.ports.checkAuthState.unsubscribe(); 91 | app.ports.updateEntry.unsubscribe(); 92 | app.ports.saveEntry.unsubscribe(); 93 | app.ports.deleteEntry.unsubscribe(); 94 | app.ports.listEntries.unsubscribe(); 95 | app.ports.exportCards.unsubscribe(); 96 | } 97 | }); 98 | 99 | initPouch(pouchMsg$); 100 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'ephemeral/elm' { 2 | type ElmApp = { 3 | ports: { 4 | [portName: string]: { 5 | subscribe: (value: any) => void; 6 | unsubscribe: () => void; 7 | send: (value: any) => void; 8 | }; 9 | }; 10 | }; 11 | 12 | export const Main: { 13 | embed(node: HTMLElement): ElmApp; 14 | }; 15 | } 16 | 17 | declare module 'config' { 18 | type Config = { 19 | name: string; 20 | environment: string; 21 | couchUrl: string; 22 | dbName: string; 23 | }; 24 | 25 | export const config: Config; 26 | } 27 | 28 | // Type definitions for pouchdb-authentication 1.0 29 | // Project: https://pouchdb.com/ 30 | // Definitions by: Didier Villevalois 31 | // Definitions: https://github.com/pouchdb-community/pouchdb-authentication 32 | // TypeScript Version: 2.3 33 | 34 | /// 35 | 36 | // TODO: Fixing this lint error will require a large refactor 37 | /* tslint:disable:no-single-declare-module */ 38 | 39 | declare namespace PouchDB { 40 | namespace Authentication { 41 | interface UserContext { 42 | name: string; 43 | roles?: string[]; 44 | } 45 | 46 | interface User extends UserContext {} 47 | 48 | interface LoginResponse extends Core.BasicResponse, UserContext {} 49 | 50 | interface SessionResponse extends Core.BasicResponse { 51 | info: { 52 | authenticated: string; 53 | authentication_db: string; 54 | authentication_handlers: string[]; 55 | }; 56 | userCtx: UserContext; 57 | } 58 | 59 | interface PutUserOptions extends Core.Options { 60 | metadata?: any; 61 | roles?: string[]; 62 | } 63 | } 64 | 65 | interface Database { 66 | /** 67 | * Log in an existing user. 68 | * Throws an error if the user doesn't exist yet, the password is wrong, the HTTP server is unreachable, or a meteor struck your computer. 69 | */ 70 | logIn( 71 | username: string, 72 | password: string, 73 | callback: Core.Callback 74 | ): void; 75 | 76 | logIn( 77 | username: string, 78 | password: string, 79 | options: Core.Options, 80 | callback: Core.Callback 81 | ): void; 82 | 83 | logIn( 84 | username: string, 85 | password: string, 86 | options?: Core.Options 87 | ): Promise; 88 | 89 | /** 90 | * Logs out whichever user is currently logged in. 91 | * If nobody's logged in, it does nothing and just returns `{"ok" : true}`. 92 | */ 93 | logOut(callback: Core.Callback): void; 94 | 95 | logOut(): Promise; 96 | 97 | /** 98 | * Returns information about the current session. 99 | * In other words, this tells you which user is currently logged in. 100 | */ 101 | getSession(callback: Core.Callback): void; 102 | 103 | getSession(): Promise; 104 | 105 | /** 106 | * Sign up a new user who doesn't exist yet. 107 | * Throws an error if the user already exists or if the username is invalid, or if some network error occurred. 108 | * CouchDB has some limitations on user names (e.g. they cannot contain the character `:`). 109 | */ 110 | signUp( 111 | username: string, 112 | password: string, 113 | callback: Core.Callback 114 | ): void; 115 | 116 | signUp( 117 | username: string, 118 | password: string, 119 | options: Authentication.PutUserOptions, 120 | callback: Core.Callback 121 | ): void; 122 | 123 | signUp( 124 | username: string, 125 | password: string, 126 | options?: Authentication.PutUserOptions 127 | ): Promise; 128 | 129 | /** 130 | * Returns the user document associated with a username. 131 | * (CouchDB, in a pleasing show of consistency, stores users as JSON documents in the special `_users` database.) 132 | * This is the primary way to get metadata about a user. 133 | */ 134 | getUser( 135 | username: string, 136 | callback: Core.Callback< 137 | Core.Document & Core.GetMeta 138 | > 139 | ): void; 140 | 141 | getUser( 142 | username: string, 143 | options: PouchDB.Core.Options, 144 | callback: Core.Callback< 145 | Core.Document & Core.GetMeta 146 | > 147 | ): void; 148 | 149 | getUser( 150 | username: string, 151 | options?: PouchDB.Core.Options 152 | ): Promise & Core.GetMeta>; 153 | 154 | /** 155 | * Update the metadata of a user. 156 | */ 157 | putUser(username: string, callback: Core.Callback): void; 158 | 159 | putUser( 160 | username: string, 161 | options: Authentication.PutUserOptions, 162 | callback: Core.Callback 163 | ): void; 164 | 165 | putUser( 166 | username: string, 167 | options?: Authentication.PutUserOptions 168 | ): Promise; 169 | 170 | /** 171 | * Delete a user. 172 | */ 173 | deleteUser(username: string, callback: Core.Callback): void; 174 | 175 | deleteUser( 176 | username: string, 177 | options: Core.Options, 178 | callback: Core.Callback 179 | ): void; 180 | 181 | deleteUser( 182 | username: string, 183 | options?: Core.Options 184 | ): Promise; 185 | 186 | /** 187 | * Set new `password` for user `username`. 188 | */ 189 | changePassword( 190 | username: string, 191 | password: string, 192 | callback: Core.Callback 193 | ): void; 194 | 195 | changePassword( 196 | username: string, 197 | password: string, 198 | options: Core.Options, 199 | callback: Core.Callback 200 | ): void; 201 | 202 | changePassword( 203 | username: string, 204 | password: string, 205 | options?: Core.Options 206 | ): Promise; 207 | 208 | /** 209 | * Renames `oldUsername` to `newUsername`. 210 | */ 211 | changeUsername( 212 | oldUsername: string, 213 | newUsername: string, 214 | callback: Core.Callback 215 | ): void; 216 | 217 | changeUsername( 218 | oldUsername: string, 219 | newUsername: string, 220 | options: Core.Options, 221 | callback: Core.Callback 222 | ): void; 223 | 224 | changeUsername( 225 | oldUsername: string, 226 | newUsername: string, 227 | options?: Core.Options 228 | ): Promise; 229 | 230 | /** 231 | * Sign up a new admin. 232 | */ 233 | signUpAdmin( 234 | username: string, 235 | password: string, 236 | callback: Core.Callback 237 | ): void; 238 | 239 | signUpAdmin( 240 | username: string, 241 | password: string, 242 | options: Authentication.PutUserOptions, 243 | callback: Core.Callback 244 | ): void; 245 | 246 | signUpAdmin( 247 | username: string, 248 | password: string, 249 | options?: Authentication.PutUserOptions 250 | ): Promise; 251 | 252 | /** 253 | * Delete an admin. 254 | */ 255 | deleteAdmin(username: string, callback: Core.Callback): void; 256 | 257 | deleteAdmin( 258 | username: string, 259 | options: Core.Options, 260 | callback: Core.Callback 261 | ): void; 262 | 263 | deleteAdmin(username: string, options?: Core.Options): Promise; 264 | } 265 | } 266 | 267 | declare module 'pouchdb-authentication' { 268 | const plugin: PouchDB.Plugin; 269 | export = plugin; 270 | } 271 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | export function string2Hex(tmp: string) { 2 | let str = ''; 3 | for (let i = 0; i < tmp.length; i++) { 4 | str += tmp[i].charCodeAt(0).toString(16); 5 | } 6 | return str; 7 | } 8 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fpapado/ephemeral/3c3984546e316ee36dbc1895cb1e4d766794eabe/todo.md -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es5", 5 | "module": "esnext", 6 | "baseUrl": ".", 7 | "sourceMap": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ] 18 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const merge = require('webpack-merge'); 4 | const md5 = require('md5'); 5 | 6 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 7 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 9 | const HTMLWebpackPlugin = require('html-webpack-plugin'); 10 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 11 | const NameAllModulesPlugin = require('name-all-modules-plugin'); 12 | const OfflinePlugin = require('offline-plugin'); 13 | const WebpackPwaManifest = require('webpack-pwa-manifest'); 14 | const DashboardPlugin = require('webpack-dashboard/plugin'); 15 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 16 | 17 | var isProd = process.env.NODE_ENV === 'production'; 18 | 19 | // -- Offline Plugin -- 20 | let offlinePlugin = new OfflinePlugin({ 21 | safeToUseOptionalCaches: true, 22 | 23 | caches: { 24 | main: [':rest:'], 25 | additional: [':externals:', '*-chunk-*.js'] 26 | }, 27 | 28 | // externals: ['https://fonts.googleapis.com/css?family=Pacifico'], 29 | 30 | ServiceWorker: { 31 | navigateFallbackURL: '/', 32 | events: true, 33 | minify: true 34 | } 35 | }); 36 | 37 | // -- PWA Manifest -- 38 | let pwaPlugin = new WebpackPwaManifest({ 39 | name: 'Ephemeral', 40 | short_name: 'Ephemeral', 41 | description: 'Save words and translations when you see them!', 42 | background_color: '#A5DBF7', 43 | theme_color: '#A5DBF7', 44 | icons: [ 45 | { 46 | src: path.resolve('src/assets/icon.png'), 47 | sizes: [96, 128, 192, 256, 384, 512] // multiple sizes 48 | } 49 | ] 50 | }); 51 | 52 | // Babel plugins 53 | let babelPluginsProd = [ 54 | 'syntax-dynamic-import', 55 | ['transform-remove-console', { exclude: ['error', 'warn', 'info'] }] 56 | ]; 57 | 58 | let babelPluginsDev = ['syntax-dynamic-import']; 59 | 60 | // Bundle analyzer config 61 | let bundlePlugin = new BundleAnalyzerPlugin({ 62 | analyzerMode: 'static', 63 | openAnalyzer: false, 64 | reportFilename: 'bundle-analysis.html' 65 | }); 66 | 67 | // Extract text 68 | const extractSass = new ExtractTextPlugin({ 69 | filename: '[name].[contenthash].css', 70 | disable: !isProd 71 | }); 72 | 73 | // -- Common Config -- 74 | var common = { 75 | entry: { 76 | main: './src/index.ts', 77 | vendor: ['pouchdb-browser', 'pouchdb-authentication', 'leaflet', 'xstream'] 78 | // styles: './src/assets/css/styles.scss' 79 | }, 80 | output: { 81 | path: path.join(__dirname, 'dist'), 82 | 83 | // Hash as appropriate for production; based on chunks etc. 84 | filename: isProd ? '[name]-[chunkhash].js' : '[name]-[hash].js' 85 | }, 86 | plugins: [ 87 | new CleanWebpackPlugin(['dist']), 88 | 89 | // Give modules a deterministic name for better long-term caching: 90 | // https://github.com/webpack/webpack.js.org/issues/652#issuecomment-273023082 91 | new webpack.NamedModulesPlugin(), 92 | 93 | // Give dynamically `import()`-ed scripts a deterministic name for better 94 | // long-term caching. 95 | // Also append '.chunk' to the name, such that offline-plugin caches it 96 | new webpack.NamedChunksPlugin( 97 | chunk => 98 | chunk.name 99 | ? chunk.name 100 | : md5(chunk.mapModules(m => m.identifier()).join()).slice(0, 10) + 101 | '-chunk' 102 | ), 103 | 104 | new HTMLWebpackPlugin({ 105 | // using .ejs prevents other loaders causing errors 106 | template: './src/index.ejs', 107 | minify: isProd 108 | ? { collapseWhitespace: true, collapseInlineTagWhitespace: true } 109 | : false, 110 | // inject details of output file at end of body 111 | inject: 'body' 112 | }), 113 | 114 | new webpack.optimize.ModuleConcatenationPlugin(), 115 | 116 | new webpack.optimize.CommonsChunkPlugin({ 117 | name: ['vendor'], 118 | minChunks: Infinity 119 | // (with more entries, this ensures that no other module 120 | // goes into the vendor chunk) 121 | // TODO: add a common entry point if needed 122 | }), 123 | 124 | //// Extract runtime code so updates don't affect app-code caching: 125 | // https://webpack.js.org/guides/caching 126 | new webpack.optimize.CommonsChunkPlugin({ 127 | name: 'runtime' 128 | }), 129 | 130 | // Give deterministic names to all webpacks non-"normal" modules 131 | // https://medium.com/webpack/predictable-long-term-caching-with-webpack-d3eee1d3fa31 132 | new NameAllModulesPlugin(), 133 | 134 | pwaPlugin, 135 | 136 | extractSass 137 | ], 138 | resolve: { 139 | extensions: ['.ts', '.js', '.scss'], 140 | alias: [ 141 | { 142 | name: 'ephemeral/elm', 143 | alias: path.resolve(__dirname, 'src', 'Main.elm') 144 | }, 145 | { 146 | name: 'ephemeral', 147 | alias: path.resolve(__dirname, 'src') 148 | } 149 | ] 150 | }, 151 | module: { 152 | rules: [ 153 | { 154 | test: /\.html$/, 155 | exclude: /node_modules/, 156 | loader: 'file-loader?name=[name].[ext]' 157 | }, 158 | { 159 | test: /\.ts$/, 160 | exclude: /node_modules/, 161 | loader: 'ts-loader' 162 | }, 163 | { 164 | test: /\.js$/, 165 | exclude: /node_modules/, 166 | use: { 167 | loader: 'babel-loader', 168 | options: { 169 | cacheDirectory: true, 170 | presets: [ 171 | [ 172 | 'env', 173 | { 174 | debug: true, 175 | modules: false, 176 | useBuiltIns: true, 177 | targets: { 178 | browsers: ['> 1%', 'last 2 versions', 'Firefox ESR'] 179 | } 180 | } 181 | ] 182 | ], 183 | plugins: isProd ? babelPluginsProd : babelPluginsDev 184 | } 185 | } 186 | }, 187 | { 188 | // Transpile and extract scss 189 | test: /\.scss$/, 190 | exclude: [/elm-stuff/, /node_modules/], 191 | use: extractSass.extract({ 192 | use: [ 193 | { 194 | loader: 'css-loader', 195 | options: { 196 | minimize: isProd, 197 | sourceMap: !isProd 198 | } 199 | }, 200 | { 201 | loader: 'resolve-url-loader' 202 | }, 203 | { 204 | loader: 'sass-loader', 205 | options: { 206 | sourceMap: !isProd 207 | } 208 | } 209 | ], 210 | fallback: 'style-loader' 211 | }) 212 | }, 213 | { 214 | test: /\.css$/, 215 | exclude: [/elm-stuff/, /node_modules/], 216 | loaders: ['style-loader', 'css-loader'] 217 | }, 218 | { 219 | test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 220 | exclude: [/elm-stuff/, /node_modules/], 221 | loader: 'url-loader', 222 | options: { 223 | limit: 10000, 224 | mimetype: 'application/font-woff' 225 | } 226 | }, 227 | { 228 | test: /\.svg$/, 229 | loader: 'svg-url-loader' 230 | }, 231 | { 232 | test: /\.(ttf|eot)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 233 | exclude: [/elm-stuff/, /node_modules/], 234 | loader: 'file-loader' 235 | }, 236 | { 237 | test: /\.(jpe?g|png|gif)$/i, 238 | loader: 'file-loader' 239 | } 240 | ] 241 | } 242 | }; 243 | 244 | if (!isProd) { 245 | console.log('Building for dev...'); 246 | module.exports = merge(common, { 247 | devtool: 'cheap-module-eval-source-map', 248 | plugins: [ 249 | // Prevents compilation errors causing the hot loader to lose state 250 | new webpack.NoEmitOnErrorsPlugin(), 251 | new DashboardPlugin() 252 | ], 253 | resolve: { 254 | alias: [ 255 | { 256 | name: 'config', 257 | alias: path.join(__dirname, 'config/development.ts') 258 | } 259 | ] 260 | }, 261 | module: { 262 | rules: [ 263 | { 264 | test: /\.elm$/, 265 | exclude: [/elm-stuff/, /node_modules/], 266 | use: [ 267 | { 268 | loader: 'elm-hot-loader' 269 | }, 270 | { 271 | loader: 'elm-webpack-loader', 272 | // add Elm's debug overlay to output 273 | options: { 274 | debug: true, 275 | pathToMake: './bin/unbuffered-elm-make' 276 | } 277 | } 278 | ] 279 | } 280 | ] 281 | }, 282 | devServer: { 283 | inline: true, 284 | stats: 'errors-only', 285 | contentBase: path.join(__dirname, 'src/assets') 286 | } 287 | }); 288 | } 289 | 290 | if (isProd) { 291 | console.log('Building for prod...'); 292 | module.exports = merge(common, { 293 | devtool: 'source-map', 294 | plugins: [ 295 | new CopyWebpackPlugin([ 296 | { 297 | from: 'src/assets', 298 | ignore: ['*.scss'] 299 | } 300 | ]), 301 | new webpack.DefinePlugin({ 302 | 'process.env': { 303 | NODE_ENV: JSON.stringify('production') 304 | } 305 | }), 306 | new UglifyJsPlugin({ 307 | sourceMap: true, 308 | uglifyOptions: { 309 | compress: { 310 | warnings: false 311 | }, 312 | mangle: { 313 | safari10: true 314 | }, 315 | output: { 316 | comments: false 317 | } 318 | } 319 | }), 320 | offlinePlugin, 321 | bundlePlugin 322 | ], 323 | resolve: { 324 | alias: [ 325 | { 326 | name: 'config', 327 | alias: path.join(__dirname, 'config/production.ts') 328 | } 329 | ] 330 | }, 331 | module: { 332 | rules: [ 333 | { 334 | test: /\.elm$/, 335 | exclude: [/elm-stuff/, /node_modules/], 336 | use: [ 337 | { 338 | loader: 'elm-webpack-loader', 339 | options: { 340 | pathToMake: './bin/unbuffered-elm-make' 341 | } 342 | } 343 | ] 344 | } 345 | ] 346 | } 347 | }); 348 | } 349 | --------------------------------------------------------------------------------