├── .gitignore ├── DEVELOPMENT.md ├── LICENSE ├── NOTES.txt ├── README.md ├── components ├── application.jsx ├── col.jsx ├── flash-list.jsx ├── row.jsx └── spinner.jsx ├── css └── midgard.css ├── fonts ├── titilliumweb-extralight-webfont.eot ├── titilliumweb-extralight-webfont.ttf ├── titilliumweb-extralight-webfont.woff ├── titilliumweb-extralight-webfont.woff2 ├── titilliumweb-light-webfont.eot ├── titilliumweb-light-webfont.ttf ├── titilliumweb-light-webfont.woff ├── titilliumweb-light-webfont.woff2 ├── titilliumweb-lightitalic-webfont.eot ├── titilliumweb-lightitalic-webfont.ttf ├── titilliumweb-lightitalic-webfont.woff ├── titilliumweb-lightitalic-webfont.woff2 ├── titilliumweb-semibold-webfont.eot ├── titilliumweb-semibold-webfont.ttf ├── titilliumweb-semibold-webfont.woff ├── titilliumweb-semibold-webfont.woff2 ├── titilliumweb-semibolditalic-webfont.eot ├── titilliumweb-semibolditalic-webfont.ttf ├── titilliumweb-semibolditalic-webfont.woff └── titilliumweb-semibolditalic-webfont.woff2 ├── img ├── criss-cross.svg ├── error.jpg ├── favicon-updates.png ├── favicon.png ├── honeycomb.svg ├── spinner.svg └── w3c.svg ├── index.html ├── js ├── actions │ ├── configuration.js │ ├── last-seen.js │ ├── mailbox.js │ ├── messages.js │ └── user.js ├── dispatcher.js ├── event-list.jsx ├── filter-list.jsx ├── filter-selector.jsx ├── filter-toggle.jsx ├── login.jsx ├── logout-button.jsx ├── midgard.jsx ├── show-github.jsx ├── stores │ ├── configuration.js │ ├── filter.js │ ├── gh-user.js │ ├── last-seen.js │ ├── login.js │ ├── mailbox.js │ └── message.js ├── toolbar.jsx └── utils.js ├── package-lock.json ├── package.json └── w3c.json /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | config.js 3 | node_modules/ 4 | npm-debug.log 5 | js/*.min.js 6 | css/*.min.js 7 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | 2 | # How to develop and deploy Pheme and Midgard 3 | 4 | [Pheme][Pheme] can be deployed on its own, it can be used for things other than [Midgard][Midgard]. 5 | Midgard, however, requires Pheme. 6 | 7 | This document describes what one needs to know in order to hack on Pheme and Midgard. If you are 8 | familiar with Node, CouchDB, and React you are already on sane territory but I recommend you at 9 | least skim this document as the local specificities are laid out as well. 10 | 11 | ## IMPORTANT WARNING 12 | 13 | If you are rebuilding the Midgard code on a Mac, you are likely to get an incomprehensible 14 | error from Browserify of the type `Error: EMFILE, open '/some/path'`. That is because the number of 15 | simultaneously open files is bizarrely low on OSX, and Browserify opens a bizarrely high number 16 | of resources concurrently. 17 | 18 | In order to do that, in the environment that runs the build, you will need to run: 19 | 20 | ulimit -n 2560 21 | 22 | If you don't know that, you can waste quite some time. 23 | 24 | ## Overall Architecture 25 | 26 | The Pheme repository is a purely server-side application. It exposes a JSON API over the Web but 27 | nothing user-consumable. It is written in Node and uses [Express][Express] as well as the typical 28 | Express middleware for sessions, logging, etc. 29 | 30 | The database system is [CouchDB][CouchDB]. It is also used in a straightforward manner, with no 31 | reliance on CouchDB specificities. If needed, it could be ported to another system. The only thing 32 | that is worth knowing is that the filters that provide views on the data are used to generate actual 33 | CouchDB views. This gives them huge performance (since they're basically pre-indexed), but it means 34 | you have to remember to run the DB updater when you change the filters. If a UI were made to create 35 | filters (which might be a good idea at some point) this could be done live. 36 | 37 | Midgard is, on its side, a purely client-side application. It consumes the JSON API that Pheme 38 | exposes and simply renders it. It can be served by pretty much any Web server. 39 | 40 | It is written using [React][React], making lightweight use of the [Flux][Flux] architecture, and is 41 | built using [Browserify][Browserify]. React is its own way of thinking about Web applications that 42 | has its own learning curve (and can require a little bit of retooling of one's editor for the 43 | [JSX][JSX] part) but once you start using it it is hard to go back. It's the first framework I find 44 | to be worth the hype since jQuery (and for completely different reasons). 45 | 46 | No CSS framework is used; but the CSS does get built too using [cleancss][cleancss] (for modularity 47 | and minification). 48 | 49 | ## Installing Pheme 50 | 51 | It's pretty straightforward: 52 | 53 | git clone https://github.com/w3c/pheme 54 | cd pheme 55 | npm install -d 56 | 57 | You now need to configure the system so that it can find various bits and pieces. For this create a 58 | `config.json` at the root, with the following content: 59 | 60 | ``` 61 | { 62 | // This is the port you want to run on; it can be 80 but I run it behind an nginx proxy. 63 | "port": 3042 64 | // This is the list of data sources. The keys correspond to source modules (under `sources/`). 65 | // Each source module accepts an array of instances each of which can get its own configuration. 66 | , "sources": { 67 | // The "rss" source can take an arbitrary number of RSS/Atom sources. Each of those needs to 68 | // have a `name` (which is has to be unique in the list and ismapped in the event filters), 69 | // a `url` to the RSS/Atom to poll, and an `acl` which can be `public` or `team` (and 70 | // should eventually include `member` too; unless we decide that all that's in the 71 | // dashboard is public). 72 | // Here there are two RSS sources, the official W3C news from W3C Memes, and the party line 73 | // from the W3C itself. 74 | "rss": [ 75 | { 76 | "name": "W3CMemes" 77 | , "url": "http://w3cmemes.tumblr.com/rss" 78 | , "acl": "public" 79 | } 80 | , { 81 | "name": "W3C News" 82 | , "url": "http://www.w3.org/blog/news/feed/atom" 83 | , "acl": "public" 84 | } 85 | ] 86 | // The "github" source can take an arbitrary number of hook locations. The value in having 87 | // more than one is because Web hooks need to have a secret (so that people can't send you 88 | // spurious content), and it's good practice to have different secrets for different places. 89 | // Note that you can set up an organisation-wide hook (there's one for W3C). 90 | // The secret below isn't the real one for W3C. It's probably a good idea to get the real 91 | // one from @darobin if you need it. 92 | // If you have several hooks, they need to have unique names and unique paths. 93 | , "github": [ 94 | { 95 | "name": "GitHub W3C Repositories" 96 | , "secret": "Some magical phrase" 97 | , "path": "/hook" 98 | } 99 | ] 100 | } 101 | // Configuration for the store, probably self-explanatory 102 | , "store": { 103 | "auth": { 104 | "username": "robin" 105 | , "password": "wickEdCo0lPasswr.D" 106 | } 107 | } 108 | // The logging. `console` turns logging to the console on or off (likely off in production); and 109 | // `file` (if present) is the absolute path to, yes, a log file to log logs into. 110 | , "logs": { 111 | "console": true 112 | , "file": "/some/absolute/path/all.log" 113 | } 114 | } 115 | ``` 116 | Now, with CouchDB is already up and running, you want to run: 117 | 118 | node lib/store.js 119 | 120 | This installs all the design documents that Couch needs. Whenever you change the design documents, 121 | or **whenever you update `lib/filters/events.js`** just run `lib/store.js` again. 122 | 123 | Running the server is as simple as: 124 | 125 | node bin/server.js 126 | 127 | If you are going to develop however, that isn't the best way of running the server. When developing 128 | the server code, you want to run: 129 | 130 | npm run watch 131 | 132 | This will start a [nodemon][nodemon] instance that will monitor the changes you make to the Pheme 133 | code, and restart it for you. 134 | 135 | One of the issues with developing on one's box is that it is not typically accessible over the Web 136 | for outside services to interact with. If you are trying to get events from repositories on GitHub, 137 | you will need to expose yourself to the Web. You may already have your preferred way of doing that, 138 | but in case you don't you can use [ngrok][ngrok] (which is what I do). In order to expose your 139 | service through ngrok, just run 140 | 141 | npm run expose 142 | 143 | Note that you don't need that for regular development, you only need to be exposed if you want to 144 | receive GitHub events. 145 | 146 | ## Deploying Pheme in Production 147 | 148 | You will want a slightly different `config.json`; the one in hatchery is serviceable (it notably has 149 | the right secret for the W3C hook). 150 | 151 | You don't want to use `npm run` in production; instead use [pm2][pm2]. A configuration is provided 152 | for it in `pm2-production.json` (it's what's used on hatchery). 153 | 154 | ## Installing Midgard 155 | 156 | It's pretty straightforward: 157 | 158 | git clone https://github.com/w3c/midgard 159 | cd midgard 160 | npm install -d 161 | 162 | Note that even though this is client-side code you *must* install the dependencies *even just to 163 | deploy it*. 164 | 165 | You now need to configure the system so that it can find various bits and pieces. For this create a 166 | `config.json` at the root, with the following content: 167 | 168 | ``` 169 | { 170 | // This is the URL to the root of the API server, with trailing /. 171 | "api": "http://pheme.bast/" 172 | // If Midgard is not running at the root of its host, this is the path to it; otherwise /. 173 | , "pathPrefix": "/" 174 | } 175 | ``` 176 | 177 | You then need to run: 178 | 179 | npm run build 180 | 181 | (Technically, you only need to run `npm run build-js` as the `config.json` only affects that; but 182 | given how the JS build dominates the build time it makes no difference.) 183 | 184 | That's it, you have a working Midgard, ready to be served. ***IMPORTANT NOTE***: whenever you udpate 185 | the code, you need to run the build again. That's because the built version is not under version 186 | control, because it depends on the small configuration. 187 | 188 | When developing the code, you absolutely *do not want to run `npm run build` yourself for every 189 | change*. The reason for that is that a full Browserify build can be quite slow. Instead we have a 190 | [Watchify][Watchify]-based command that does incremental building whenever it detects a change. On 191 | my laptop that's the different between insufferable 5 seconds build time and tolerable 0.2s build 192 | time. Just: 193 | 194 | npm run watch 195 | 196 | This will build both the CSS and JS/JSX whenever needed. 197 | 198 | ## Production deployment 199 | 200 | You will want a slightly different `config.json` as the Pheme server might be elsewhere (that said 201 | if you're doing pure-client changes nothing keeps you from having the client talk to the live 202 | production Pheme instance). 203 | 204 | 205 | ## The CouchDB Design 206 | 207 | Just two design documents are used in CouchDB, they're very simple. They are basic 208 | maps to index the data. You can find them all under `store.js` in `setupDDocs()`. There are: 209 | 210 | * users, that can be queried by username; 211 | * events (with one view per filter), queried by date. 212 | 213 | 214 | ## Pheme Code Layout 215 | 216 | The server makes use of several files. 217 | 218 | ### `bin/server.js` 219 | 220 | This is the executable. All it does is load up the Pheme library and run it. 221 | 222 | ### `lib/pheme.js` 223 | 224 | This is the main library that binds the rest together. 225 | 226 | It sets up the store and server, but the core of its work is to set up the sources correctly based 227 | on the configuration. This involves: 228 | 229 | * Loading the source modules that match the configuration. 230 | * Handling them differently depending on whether they declare being push or poll sources. 231 | * Configuring each source instance. 232 | * Exposing the push sources through the web server, and notifying the poll sources that they need to 233 | poll at their preferred interval. 234 | 235 | ### `lib/store.js` 236 | 237 | This is a very straightforward access point to CouchDB, built atop the [cradle][cradle] library. 238 | When ran directly it creates the DB and sets up the design documents; otherwise it's a library that 239 | can be used to access the content of the DB. 240 | 241 | It has simple setup methods that are just used to configure the database. `setupDDocs` can look a 242 | little confusing because it is generating view filters based on what's specified in 243 | `filters/events.js`, but the resulting code is all pretty simple (filtering views on type and 244 | source, and indexing them by date). 245 | 246 | It has a few simple methods to access the data that should be self-explanatory. 247 | 248 | ### `lib/server.js` 249 | 250 | A basic set of Express endpoints. It manages sessions, CORS, and logging. 251 | 252 | You can get the current user (if logged in) and update her preferences for the filters. The login 253 | endpoint is of some interest: it receives genuine W3C credentials and does a `HEAD` against a 254 | Member and a Team endpoint to determine if the login works and which ACL level to grant the user 255 | (note that at this point there is no content in the DB that isn't public, so this isn't all that 256 | useful). This should probably be replaced by LDAP at some point. You can also logout. 257 | 258 | Other endpoints have to do with the event filters. You can list all those that are available (to 259 | use in the configuration dialog); you can get a bunch of recent events for a given filter (right now 260 | there is no paging and it's limited to the more recent 30, but that would be easy to add — there are 261 | comments to that effect in the store code). Finally you can `POST` a JSON object with filter names 262 | as its keys and date specifications for values being the date of the most recent event seen for 263 | that filter. The server will respond with the same keys and for each a number of events more recent 264 | than the given date. The client uses that by tracking which most recent documents have been seen 265 | for each filter, and uses the response to mark the event mailboxes as having new content (the client 266 | polls for that every minute). 267 | 268 | ### `lib/log.js` 269 | 270 | This is a simple wrapper that exposes an already-built instance of [Winston][Winston], configured to 271 | log to the console, file, or both. It's easy to add other logging targets if need be. 272 | 273 | ### `sources/*` 274 | 275 | There are currently two sources, one pull and one push, but it is easy to add more. 276 | 277 | All sources must expose a `method` field that is either `push` or `poll` so that Pheme knows how to 278 | handle them. They also must expose a `createSource(conf, pheme)` method that they use to return an 279 | object. It gets the instance-specific configuration and an instance of Pheme. 280 | 281 | The object returned for poll sources must have a `poll()` method. It can do whatever it wants (see 282 | the RSS source for an example), and it is expected to use `pheme.store` in order to store the 283 | events it finds. 284 | 285 | The object returned for push sources must have a `handle(req, res, next)` method. This behaves 286 | basically like an Express middleware. The source can decline to process it by calling `next()`, and 287 | `req` and `res` are the usual Express request and response objects. The expectation is that the 288 | source knows how to respond to whatever hook calls it. It also uses `pheme.store` to add new events 289 | to the database. See the GitHub source for an example. 290 | 291 | ### `lib/filters/events.js` 292 | 293 | The structure hints that there could be multiple filter types, other than events, even though now 294 | that's all there is. The idea is indeed that at some point there could be other filters on the 295 | dashboard content, for instance for things that aren't events like currently open WBS polls for a 296 | given person. 297 | 298 | Right now that module just exports a big map of filters on the events. Each has a key name (which 299 | identifies it across the system), a human `name` and `description`. It must have an `origin` which 300 | maps to what a given source produces as the origin of its events (RSS uses the RSS feed name given 301 | in the configuration so that different RSS feeds have different origins, GitHub uses "github" for 302 | all its content because it makes sense as a source). 303 | 304 | Filters with a `github` origin are expected to have an array of repositories that are sources to be 305 | included in the filter. 306 | 307 | At this point there is no way to union multiple origins (say, those two GitHub repos and that RSS 308 | feed together) even though it will almost certainly make sense. That's a limitation that relatively 309 | easy to lift just by making the keys accept arrays of filters, and having the view-generation 310 | code generator in `setupDDocs()` handle that. 311 | 312 | 313 | ## Midgard Code Layout 314 | 315 | ### `index.html` 316 | 317 | A very basic bare bones HTML page that loads the style and script. 318 | 319 | ### `css/midgard.css` 320 | 321 | A pretty basic CSS file. It just loads up [normalize][normalize] and [ungrid][ungrid], and then 322 | styles the various controls in a pretty general manner. 323 | 324 | There is no magic and no framework. The complete built CSS is ~7K. 325 | 326 | ### `components/*.jsx` 327 | 328 | The JSX files under `components/` are simple, reusable components. At some point they should probably be extracted into a shared library that can be reused across W3C applications. 329 | 330 | Most of them are extremely simple and largely there to keep the JSX readable, without having to rely 331 | excessively on `div`s and classes. 332 | 333 | #### `application.jsx` 334 | 335 | A simple layout wrapper, with a title, that just renders its children. Used to render routed 336 | components into. 337 | 338 | #### `col.jsx` and `row.jsx` 339 | 340 | Very simple row and column items that use ungrid. Nothing fancy. 341 | 342 | #### `spinner.jsx` 343 | 344 | This is a simple loading/progress spinner with built-in SVG, no dependencies, and CSS animation so 345 | that when Chrome drops SMIL support this will work. It takes a few options. 346 | 347 | #### `flash-list.jsx` 348 | 349 | This just renders the list of success/error messages that are stored in the message store. 350 | 351 | It has a magical mode, but only the initiated can turn it on. 352 | 353 | ### `js/midgard.jsx` 354 | 355 | This is the entry point for the JS application. Most of what it does is to import things and get 356 | them set up. 357 | 358 | It does not do much apart from rendering either a spinner (while loading), a login form (if not 359 | logged in), or the application itself. There is routing support in place, but it is not currently 360 | wired in. Doing so would be relatively easy. 361 | 362 | ### `stores/*.js` and `actions/*.js` 363 | 364 | One architectural approach that works well with React is known as Flux. At its heart it is a simple 365 | idea to handle events and data in an application, in such a manner that avoids tangled-up messes. 366 | 367 | The application (typically driven by the user) can trigger an **action**, usually with attached 368 | data. An example from the code are error messages that can be emitted pretty much anywhere in the 369 | application (ditto success messages). 370 | 371 | Actions are all sent towards the **dispatcher** (which we reuse from the basic Flux implementation). 372 | The dispatcher makes these available to whoever wants to listen. This is similar to pub/sub, except that an event's full trip is taken into consideration, and it only ever travels in one direction. 373 | 374 | Stores listen to actions, and keep any data that the application might need handy (either locally or 375 | by accessing it when needed). For the error/success messages, the store just keeps them around until 376 | they are dismissed, which means that navigation across components will still render the messages in 377 | the store. 378 | 379 | Finally, components can listen to changes in stores, and react to them so as to update their 380 | rendering. 381 | 382 | This application uses actions and stores relatively extensively but data management could probably 383 | be refactored some to make it a little bit clearer. One promising approach being developed is 384 | Redux; its ideas would seem to match this type of application really well, but I estimated that it 385 | was still too early days to apply that. 386 | 387 | #### `actions/messages.js` and `actions/user.js` 388 | 389 | These are actions. These modules can just be imported by any component that wishes to carry out such 390 | actions, without having to know anything about whether or how the result gets stored, or how it 391 | might influence the rest of the application (it's completely fire-and-forget). 392 | 393 | The `messages.js` action module supports `error()` and `success()` messages, and can `dismiss()` a 394 | given message. 395 | 396 | The `user.js` action module supports `login()` and `logout()` actions corresponding to what the user 397 | does, it can `loadUser()` to get the user's information (after login has completed), and can 398 | manipulate the filters that the user has configured using `addFilter()` and `removeFilter()`. 399 | 400 | #### `stores/login.js` and `stores/message.js` 401 | 402 | The `login` store keeps information about whether the user is logged in, what their information is, 403 | and handles the logging out when requested. The `message` store keeps a list of error and success 404 | messages that haven't been dismissed. 405 | 406 | #### `actions/mailbox.js`, `actions/last-seen.js` and `actions/configuration.js` 407 | 408 | These actions will select a filter mailbox (which enables various parts of the app to stay in sync 409 | with that), trigger the loading of the configuration (currently just the list of filters the server 410 | has available), and `last-seen.js` can both indicate the date of the last message seen in a box and 411 | initiate polling for updates to mailbox filters. 412 | 413 | #### `store/mailbox.js`, `store/last-seen.js`, `store/configuration.js`, and `store/filter.js` 414 | 415 | The configuration store just lists the filters available on the server. If the server's list 416 | changes, the application currently needs to be reloaded. This could be changed (but it's not a very 417 | frequent situation). 418 | 419 | The last-seen store handles polling the server regularly by sending it a map of the most recently 420 | seen message in a filter mailbox (which it tracks) and receiving a count of new events since each 421 | of those dates. The date structure is an array that is basically (where `d` is a date): 422 | 423 | ```js 424 | [ 425 | d.getUTCFullYear() 426 | , d.getUTCMonth() + 1 427 | , d.getUTCDate() 428 | , d.getUTCHours() 429 | , d.getUTCMinutes() 430 | , d.getUTCSeconds() 431 | , d.getUTCMilliseconds() 432 | ] 433 | ``` 434 | 435 | The reason for this unusual structure (apart from the fact that JSON doesn't do dates) is that it is 436 | the same structure used as key for the event views in CouchDB. The advantage is that it allows for 437 | other queries, for instance `[2015, 3, 15]` will match everything on March 15, 2015 irrespective of 438 | the rest. 439 | 440 | By default last-seen polls every minute. This could be made configurable. 441 | 442 | The mailbox store just stores the current mailbox. It is a very good example of why there is 443 | currently too much boilerplate in stores that could be extracted relatively easily. 444 | 445 | The filter store keeps track of the user's preference in terms of which filters she wants to have 446 | active as mailboxes. The data is stored on the server so as to persist across devices, but it's 447 | handled not through saving the user object directly (which could be problematic given that such 448 | objects tend to have ACL information and the such) but by talking to a special endpoint that just 449 | enables saving the filters. They are saved immediately with every edit, it's fast enough for that. 450 | 451 | ### The `js/*.jsx` components 452 | 453 | These are non-reusable components that are specific to this applications. 454 | 455 | #### `login.jsx` 456 | 457 | A simple component that displays a login form and triggers an action to log in. 458 | 459 | #### `logout-button.jsx` 460 | 461 | A button that can be used (and reused) anywhere (in our case, it's part of the navigation). When 462 | clicked it dispatches a `logout` action. 463 | 464 | #### `toolbar.jsx` 465 | 466 | A simple component that lists the actions available from the toolbar, and handles toggling the 467 | visibility of the setting. 468 | 469 | #### `filter-toggle.jsx` 470 | 471 | A small component that is instantiated with an available filter description and knows how to toggle 472 | it by dispatching add/remove actions. 473 | 474 | #### `filter-list.jsx` 475 | 476 | Shows the list of filter mailboxes that the user has configured, reacting to changes in that 477 | configuration. It also manages picking which one is actually selected, and stores that information 478 | locally so that reloading returns to the same place. 479 | 480 | #### `filter-selector.jsx` 481 | 482 | Essentially a button and/or tab that can be clicked to select a filter mailbox, sitting in the list 483 | of filters. It also knows how to render the unread count. 484 | 485 | #### `event-list.jsx` 486 | 487 | The list of events for the selected mailbox. It simply tracks changes to the currently selected 488 | mailbox and fetches the events that match it. It will use different rendering for different types of 489 | events. RSS rendering is built-in (though it could be farmed out); GitHub rendering is complex 490 | enough to justify its own component. 491 | 492 | #### `show-github.jsx` 493 | 494 | A component that knows how to render a GitHub-related event. This gets relatively convoluted because 495 | there are many different types of those. 496 | 497 | One important aspect of this component is `remoteRenderURL()`. Upon instantiation, for certain types 498 | of events, it will actually contact GitHub's API in order to obtain an HTML rendering of the content 499 | of the event. This is typically true of any event that can include Markdown — we don't want to 500 | render that ourselves. 501 | 502 | The actual rendering is basically a big bunch of if branches that depend on event type and possibly 503 | other information bits to pick the right rendering for a given event. 504 | 505 | It is known at this time that not all events have rendering. If you see a JSON dump, that's where 506 | it's coming from. Adding the rendering for a new event type is easy. 507 | 508 | ## Suggested Improvements 509 | 510 | The Flux usage was grown rather than architected. It could use a bit of fine-tuning now that the 511 | application has taken shape. Also, it's worth looking at Redux. 512 | 513 | The components and much of the style can probably be extracted so that that can be reused in other 514 | W3C applications (see what's similar with Ash-Nazg, noting that the component may have been 515 | tweaked between the two). 516 | 517 | [CouchDB]: http://couchdb.apache.org/ 518 | [Express]: http://expressjs.com/ 519 | [Pheme]: https://github.com/w3c/pheme 520 | [Midgard]: https://github.com/w3c/midgard 521 | [React]: https://facebook.github.io/react/docs/getting-started.html 522 | [Flux]: http://facebook.github.io/flux/ 523 | [Browserify]: http://browserify.org/ 524 | [JSX]: https://facebook.github.io/react/docs/displaying-data.html 525 | [cleancss]: https://github.com/jakubpawlowicz/clean-css 526 | [nodemon]: https://github.com/remy/nodemon 527 | [ngrok]: https://ngrok.com/ 528 | [pm2]: https://github.com/Unitech/pm2 529 | [Watchify]: https://github.com/substack/watchify 530 | [cradle]: https://github.com/flatiron/cradle 531 | [Winston]: http://github.com/flatiron/winston 532 | [normalize]: http://necolas.github.com/normalize.css/ 533 | [ungrid]: http://chrisnager.github.io/ungrid/ 534 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Robin Berjon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /NOTES.txt: -------------------------------------------------------------------------------- 1 | 2 | REACTIFY: 3 | ✓ move all the existing JS into an "OLD" directory, to be ported one by one 4 | ✓ rewrite the css to have an entry point, use ungrid and normalize, reuse the same stuff as AN 5 | ✓ rewrite index.html to just load the real dependencies 6 | ✓ have the root render as a react component 7 | ✓ get the login logic from AN and port it to the model we have here 8 | ✓ render the login 9 | ✓ start porting over the rest piece by piece 10 | 11 | MAILBOX: 12 | - keep track of last seen 13 | - maybe a single POST with keys all being the filter names, and values the last-seen 14 | date stamp. Does a count on the DB, returns the count for each. 15 | - use that to poll the DB every ~5 minutes and update the UI. The currently shown view 16 | gets some sort of button? Use notification API? 17 | - show badges 18 | ✓ show list of mailboxes that depends on configured filters 19 | ✓ store/load filter preferences from user 20 | ✓ drop the widgets ideas 21 | ✓ merge configuration of sources and event filters list, they really should be edited in just 22 | the one place 23 | 24 | DB: 25 | ✓ kill all the old DBs, and just have a store that's for Couch 26 | ✓ have the DB be configured when run, not on load 27 | 28 | PHEME: 29 | ✓ add support for minutes using https://cvs.w3.org/recent-commits?user=swick 30 | ✓ add the W3C RSS 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Midgard 2 | 3 | A dashboard for the dwellers of our world. 4 | 5 | This is a dashboard that knows how to display the event information stored in 6 | [Pheme](https://github.com/w3c/pheme). 7 | 8 | There is development/deployment documentation covering both Midgard and Pheme in the [`DEVELOPMENT.md` document](https://github.com/w3c/midgard/blob/master/DEVELOPMENT.md) here. 9 | -------------------------------------------------------------------------------- /components/application.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import DocumentTitle from "react-document-title"; 4 | 5 | export default class Application extends React.Component { 6 | render () { 7 | return 8 |
9 |

{this.props.title}

10 |
{this.props.children}
11 | 12 |
13 |
14 | ; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /components/col.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // this is basically ungrid in a box 5 | export default class Col extends React.Component { 6 | render () { 7 | return
{this.props.children}
; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /components/flash-list.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | let utils = require("../js/utils") 5 | , pp = utils.pathPrefix() 6 | ; 7 | 8 | // a very simple flash error 9 | export default class FlashList extends React.Component { 10 | // receive a store to listen to 11 | // when it changes, get messages from there 12 | constructor (props) { 13 | super(props); 14 | this.state = { messages: [] }; 15 | } 16 | componentDidMount () { 17 | this.props.store.addChangeListener(this._onChange.bind(this)); 18 | } 19 | componentWillUnmount () { 20 | this.props.store.removeChangeListener(this._onChange.bind(this)); 21 | } 22 | _onChange () { 23 | this.setState({ messages: this.props.store.messages() }); 24 | } 25 | dismiss (id) { 26 | this.props.actions.dismiss(id); 27 | } 28 | 29 | render () { 30 | let st = this.state 31 | , messages = st.messages || [] 32 | ; 33 | return
34 | { 35 | messages.map( 36 | (msg) => { 37 | return
38 | 39 |

40 | {msg.message + " "} 41 | { 42 | msg.repeat > 0 ? x{msg.repeat + 1} : "" 43 | } 44 |

45 | { 46 | msg.mode === "dom" ? 47 |

48 | Error 49 |

: 50 | "" 51 | } 52 |
53 | ; 54 | } 55 | ) 56 | } 57 |
58 | ; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /components/row.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // this is basically ungrid in a box 5 | export default class Row extends React.Component { 6 | render () { 7 | return
{this.props.children}
; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /components/spinner.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // a very simple spinner 5 | export default class Spinner extends React.Component { 6 | render () { 7 | let size = 52 8 | , fill = this.props.fill || "#f1647c" 9 | ; 10 | if (this.props.size === "small") size /= 2; 11 | return
12 | 13 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /css/midgard.css: -------------------------------------------------------------------------------- 1 | 2 | @import "../node_modules/normalize.css/normalize.css"; 3 | @import "../node_modules/ungrid/ungrid.css"; 4 | .col { 5 | vertical-align: top; 6 | } 7 | 8 | /* fonts */ 9 | 10 | /* Extra-thin, used for headings */ 11 | @font-face { 12 | font-family: "Titillium"; 13 | src: url("../fonts/titilliumweb-extralight-webfont.eot"); 14 | src: url("../fonts/titilliumweb-extralight-webfont.eot?#iefix") format("embedded-opentype"), 15 | url("../fonts/titilliumweb-extralight-webfont.woff2") format("woff2"), 16 | url("../fonts/titilliumweb-extralight-webfont.woff") format("woff"), 17 | url("../fonts/titilliumweb-extralight-webfont.ttf") format("truetype"); 18 | font-weight: 200; 19 | font-style: normal; 20 | } 21 | 22 | /* Light & light italic, used as regular */ 23 | @font-face { 24 | font-family: "Titillium"; 25 | src: url("../fonts/titilliumweb-light-webfont.eot"); 26 | src: url("../fonts/titilliumweb-light-webfont.eot?#iefix") format("embedded-opentype"), 27 | url("../fonts/titilliumweb-light-webfont.woff2") format("woff2"), 28 | url("../fonts/titilliumweb-light-webfont.woff") format("woff"), 29 | url("../fonts/titilliumweb-light-webfont.ttf") format("truetype"); 30 | font-weight: normal; 31 | font-style: normal; 32 | } 33 | @font-face { 34 | font-family: "Titillium"; 35 | src: url("../fonts/titilliumweb-lightitalic-webfont.eot"); 36 | src: url("../fonts/titilliumweb-lightitalic-webfont.eot?#iefix") format("embedded-opentype"), 37 | url("../fonts/titilliumweb-lightitalic-webfont.woff2") format("woff2"), 38 | url("../fonts/titilliumweb-lightitalic-webfont.woff") format("woff"), 39 | url("../fonts/titilliumweb-lightitalic-webfont.ttf") format("truetype"); 40 | font-weight: normal; 41 | font-style: italic; 42 | } 43 | 44 | /* Semibold & semibold italic, used as bold */ 45 | @font-face { 46 | font-family: "Titillium"; 47 | src: url("../fonts/titilliumweb-semibold-webfont.eot"); 48 | src: url("../fonts/titilliumweb-semibold-webfont.eot?#iefix") format("embedded-opentype"), 49 | url("../fonts/titilliumweb-semibold-webfont.woff2") format("woff2"), 50 | url("../fonts/titilliumweb-semibold-webfont.woff") format("woff"), 51 | url("../fonts/titilliumweb-semibold-webfont.ttf") format("truetype"); 52 | font-weight: bold; 53 | font-style: normal; 54 | } 55 | @font-face { 56 | font-family: "Titillium"; 57 | src: url("../fonts/titilliumweb-semibolditalic-webfont.eot"); 58 | src: url("../fonts/titilliumweb-semibolditalic-webfont.eot?#iefix") format("embedded-opentype"), 59 | url("../fonts/titilliumweb-semibolditalic-webfont.woff2") format("woff2"), 60 | url("../fonts/titilliumweb-semibolditalic-webfont.woff") format("woff"), 61 | url("../fonts/titilliumweb-semibolditalic-webfont.ttf") format("truetype"); 62 | font-weight: bold; 63 | font-style: italic; 64 | } 65 | 66 | 67 | html, body { 68 | width: 100%; 69 | height: 100%; 70 | margin: 0; 71 | padding: 0; 72 | font-family: Titillium; 73 | } 74 | header { 75 | border-bottom: 1px solid silver; 76 | padding: 0 0 0 10px; 77 | } 78 | h1, div.app-body { 79 | margin: 0 30px; 80 | } 81 | h1 { 82 | color: #f1647c; 83 | font-size: 50px; 84 | font-weight: 300; 85 | padding: 0 0 0 10px; 86 | } 87 | h2 { 88 | font-weight: 300; 89 | font-size: 30px; 90 | margin-bottom: 10px; 91 | } 92 | a { 93 | color: #f1647c; 94 | text-decoration: underline #84df5d; 95 | } 96 | a:hover { 97 | color: #84df5d; 98 | text-decoration: underline #f1647c; 99 | } 100 | 101 | /*.col.nav { 102 | width: 200px; 103 | padding: 0 10px; 104 | } 105 | */ 106 | 107 | /* forms */ 108 | .formline { 109 | width: auto; 110 | padding-bottom: 10px; 111 | } 112 | .formline label { 113 | display: block; 114 | font-weight: bold; 115 | } 116 | .formline.actions { 117 | text-align: right; 118 | } 119 | 120 | 121 | /* ui */ 122 | .spinner { 123 | padding: 20px; 124 | text-align: center; 125 | } 126 | th { 127 | text-align: left; 128 | } 129 | th, td { 130 | padding: 5px; 131 | vertical-align: top; 132 | } 133 | tr { 134 | border-bottom: 1px solid silver; 135 | } 136 | tbody tr:nth-of-type(even) { 137 | background: #eee; 138 | } 139 | td > ul { 140 | margin: 0; 141 | padding-left: 20px; 142 | } 143 | button, a.button { 144 | background: #f1647c; 145 | border: none; 146 | border-radius: 5px; 147 | color: #fff; 148 | text-decoration: none; 149 | padding: 0 5px; 150 | } 151 | button:disabled { 152 | background: silver; 153 | color: #333; 154 | } 155 | .flash-list { 156 | margin-left: 230px; 157 | } 158 | .flash-list button { 159 | position: absolute; 160 | top: 10px; 161 | right: 10px; 162 | color: #fff; 163 | } 164 | .flash-list p { 165 | margin: 0; 166 | } 167 | .flash-success, .flash-error { 168 | border-radius: 5px; 169 | padding: 20px; 170 | margin-top: 10px; 171 | position: relative; 172 | } 173 | .flash-success { 174 | border: 1px solid #84df5d; 175 | } 176 | .flash-error { 177 | border: 1px solid #df5d5d; 178 | } 179 | .flash-success button { 180 | background: #84df5d; 181 | } 182 | .flash-error button { 183 | background: #df5d5d; 184 | } 185 | .flash-list p .repeat { 186 | border-radius:5px; 187 | color: white; 188 | background-color: #F1647C; 189 | padding-left: 5px; 190 | padding-right: 5px; 191 | } 192 | .good { 193 | color: green; 194 | } 195 | .bad { 196 | color: red; 197 | } 198 | td.good, td.bad { 199 | font-weight: bold; 200 | } 201 | 202 | /* login */ 203 | .login-box { 204 | max-width: 350px; 205 | border: 1px solid silver; 206 | padding: 10px 20px; 207 | margin: 50px auto; 208 | } 209 | 210 | /* toolbar */ 211 | .toolbar { 212 | text-align: right; 213 | } 214 | .toolbar button { 215 | color: #f1647c; 216 | padding: 5px 10px; 217 | text-decoration: none; 218 | cursor: pointer; 219 | background: transparent; 220 | border: none; 221 | border-radius: 0; 222 | font: inherit; 223 | border-right: 1px solid #f1647c; 224 | } 225 | .toolbar button:last-of-type { 226 | border-right: none; 227 | } 228 | .toolbar button:hover { 229 | text-decoration: underline; 230 | } 231 | .toolbar button:active { 232 | font-weight: bold; 233 | } 234 | 235 | .prefs { 236 | text-align: left; 237 | border: 1px solid silver; 238 | margin-bottom: 30px; 239 | } 240 | .prefs h2 { 241 | font-size: 24px; 242 | margin: 0 10px; 243 | } 244 | .prefs .filter { 245 | display: inline-block; 246 | width: 200px; 247 | height: 100px; 248 | border: 1px solid #f1647c; 249 | margin: 10px; 250 | vertical-align: top; 251 | } 252 | .prefs .filter h3 { 253 | margin: 5px; 254 | } 255 | .prefs .filter h3 input { 256 | margin-right: 10px; 257 | } 258 | .prefs .filter p { 259 | margin: 0 0 0 25px; 260 | } 261 | 262 | /* columns */ 263 | .mbx-list { 264 | width: 250px; 265 | border-top: 1px solid silver; 266 | } 267 | 268 | 269 | /* mailbox style */ 270 | .mbx-list ul { 271 | list-style-type: none; 272 | margin: 0; 273 | padding: 0; 274 | } 275 | .mbx-list button { 276 | display: block; 277 | width: 100%; 278 | text-align: left; 279 | border-radius: 0; 280 | padding: 10px; 281 | border: 1px solid silver; 282 | border-top: none; 283 | border-bottom-color: #f1647c; 284 | background: #fff; 285 | color: #000; 286 | } 287 | .mbx-list li:last-of-type button { 288 | border-bottom-color: silver; 289 | } 290 | .mbx-list button:hover { 291 | background: #f1647c; 292 | color: #fff; 293 | } 294 | .mbx-list li.selected button { 295 | font-weight: bold; 296 | color: #f1647c; 297 | background: #eee; 298 | border-left: 5px solid #f1647c; 299 | padding-left: 5px; 300 | } 301 | .mbx-list button .pill { 302 | font-size: 10px; 303 | font-weight: bold; 304 | color: #fff; 305 | background: #f1647c; 306 | border-radius: 50%; 307 | padding: 2px 5px; 308 | } 309 | .mbx-list button:hover .pill { 310 | color: #f1647c; 311 | background: #fff; 312 | } 313 | .mbx-list p { 314 | padding: 10px; 315 | border-bottom: 1px solid silver; 316 | } 317 | 318 | .messages { 319 | padding: 0 30px; 320 | } 321 | .messages .meta { 322 | text-align: right; 323 | background: rgba(240,240,240,0.5); 324 | padding: 5px 10px 2px 10px; 325 | margin-bottom: 10px; 326 | } 327 | .messages .message { 328 | border-bottom: 1px solid silver; 329 | margin-bottom: 50px; 330 | } 331 | 332 | .email-hidden-toggle { 333 | display: none; 334 | } 335 | .gh-user { 336 | font-weight: bold; 337 | } 338 | .message .content { 339 | border-left: 10px solid silver; 340 | padding-left: 20px; 341 | } 342 | .message .content ul { 343 | padding-left: 10px; 344 | } 345 | .message .content blockquote { 346 | font-style: italic; 347 | } 348 | 349 | .message .label { 350 | padding: 2px 4px; 351 | border-radius: 2px; 352 | box-shadow: 0 -1px 0 rgba(0,0,0, 0.12) inset; 353 | box-sizing: border-box; 354 | } 355 | 356 | .gh-user img { 357 | vertical-align: middle; 358 | border-radius: 3px; 359 | } -------------------------------------------------------------------------------- /fonts/titilliumweb-extralight-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-extralight-webfont.eot -------------------------------------------------------------------------------- /fonts/titilliumweb-extralight-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-extralight-webfont.ttf -------------------------------------------------------------------------------- /fonts/titilliumweb-extralight-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-extralight-webfont.woff -------------------------------------------------------------------------------- /fonts/titilliumweb-extralight-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-extralight-webfont.woff2 -------------------------------------------------------------------------------- /fonts/titilliumweb-light-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-light-webfont.eot -------------------------------------------------------------------------------- /fonts/titilliumweb-light-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-light-webfont.ttf -------------------------------------------------------------------------------- /fonts/titilliumweb-light-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-light-webfont.woff -------------------------------------------------------------------------------- /fonts/titilliumweb-light-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-light-webfont.woff2 -------------------------------------------------------------------------------- /fonts/titilliumweb-lightitalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-lightitalic-webfont.eot -------------------------------------------------------------------------------- /fonts/titilliumweb-lightitalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-lightitalic-webfont.ttf -------------------------------------------------------------------------------- /fonts/titilliumweb-lightitalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-lightitalic-webfont.woff -------------------------------------------------------------------------------- /fonts/titilliumweb-lightitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-lightitalic-webfont.woff2 -------------------------------------------------------------------------------- /fonts/titilliumweb-semibold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibold-webfont.eot -------------------------------------------------------------------------------- /fonts/titilliumweb-semibold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibold-webfont.ttf -------------------------------------------------------------------------------- /fonts/titilliumweb-semibold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibold-webfont.woff -------------------------------------------------------------------------------- /fonts/titilliumweb-semibold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibold-webfont.woff2 -------------------------------------------------------------------------------- /fonts/titilliumweb-semibolditalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibolditalic-webfont.eot -------------------------------------------------------------------------------- /fonts/titilliumweb-semibolditalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibolditalic-webfont.ttf -------------------------------------------------------------------------------- /fonts/titilliumweb-semibolditalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibolditalic-webfont.woff -------------------------------------------------------------------------------- /fonts/titilliumweb-semibolditalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/fonts/titilliumweb-semibolditalic-webfont.woff2 -------------------------------------------------------------------------------- /img/criss-cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /img/error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/img/error.jpg -------------------------------------------------------------------------------- /img/favicon-updates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/img/favicon-updates.png -------------------------------------------------------------------------------- /img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/midgard/6ceefba7a0d4b9a83e53f2054089b1e799be6b02/img/favicon.png -------------------------------------------------------------------------------- /img/honeycomb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /img/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /img/w3c.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | W3C Dashboard 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /js/actions/configuration.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | 4 | module.exports = { 5 | loadConfiguration: function () { 6 | DashboardDispatch.dispatch({ type: "load-configuration" }); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /js/actions/last-seen.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | 4 | module.exports = { 5 | startWatching: function () { 6 | DashboardDispatch.dispatch({ type: "watch-seen-since" }); 7 | } 8 | , sawFilter: function (id, date) { 9 | DashboardDispatch.dispatch({ type: "saw-filter", id: id, date: date }); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /js/actions/mailbox.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | 4 | module.exports = { 5 | selectMailbox: function (id) { 6 | DashboardDispatch.dispatch({ type: "select-mailbox", id: id }); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /js/actions/messages.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | 4 | module.exports = { 5 | error: function (msg, opts) { 6 | console.error(msg); 7 | DashboardDispatch.dispatch({ type: "error", message: msg, mode: opts && opts.mode ? opts.mode : "" }); 8 | } 9 | , success: function (msg) { 10 | console.log(msg); 11 | DashboardDispatch.dispatch({ type: "success", message: msg }); 12 | } 13 | , dismiss: function (id) { 14 | DashboardDispatch.dispatch({ type: "dismiss", id: id }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /js/actions/user.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | 4 | module.exports = { 5 | login: function (username, password) { 6 | DashboardDispatch.dispatch({ type: "login", username: username, password: password }); 7 | } 8 | , loadUser: function () { 9 | DashboardDispatch.dispatch({ type: "load-user" }); 10 | } 11 | , logout: function () { 12 | DashboardDispatch.dispatch({ type: "logout" }); 13 | } 14 | , addFilter: function (id) { 15 | DashboardDispatch.dispatch({ type: "add-filter", id: id }); 16 | } 17 | , removeFilter: function (id) { 18 | DashboardDispatch.dispatch({ type: "remove-filter", id: id }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /js/dispatcher.js: -------------------------------------------------------------------------------- 1 | 2 | var Dispatcher = require("flux").Dispatcher; 3 | module.exports = new Dispatcher(); 4 | -------------------------------------------------------------------------------- /js/event-list.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import Spinner from "../components/spinner.jsx"; 5 | import ShowGitHub from "./show-github.jsx"; 6 | 7 | import MailboxStore from "./stores/mailbox"; 8 | import LastSeenActions from "./actions/last-seen"; 9 | 10 | // /!\ magically create a global fetch 11 | require("isomorphic-fetch"); 12 | let utils = require("./utils") 13 | , apiEvents = utils.endpoint("api/events/") 14 | ; 15 | 16 | function cleanup (html, origin) { 17 | let res; 18 | if (origin === "W3CMemes") res = html.replace(//ig, ""); 19 | else res = html; 20 | return { __html: res }; 21 | } 22 | 23 | 24 | export default class EventList extends React.Component { 25 | constructor (props) { 26 | super(props); 27 | this.state = { 28 | mailbox: null 29 | , loading: false 30 | , events: [] 31 | }; 32 | } 33 | componentDidMount () { 34 | MailboxStore.addChangeListener(this.loadEvents.bind(this)); 35 | this.loadEvents(); 36 | } 37 | componentWillUnmount () { 38 | MailboxStore.removeChangeListener(this.loadEvents.bind(this)); 39 | } 40 | loadEvents () { 41 | var mbx = MailboxStore.mailbox() 42 | , comp = this 43 | ; 44 | if (!mbx) { 45 | return comp.setState({ 46 | mailbox: null 47 | , events: [] 48 | }); 49 | } 50 | comp.setState({ loading: true }); 51 | fetch(apiEvents + mbx, { credentials: "include", mode: "cors" }) 52 | .then(utils.jsonHandler) 53 | .then((data) => { 54 | let mostRecent = data.payload[0]; 55 | if (mostRecent) { 56 | let d = new Date(mostRecent.time); 57 | // up it by one so that the document we're seeing isn't selected. 58 | d = new Date(d.getTime() + 1); 59 | LastSeenActions.sawFilter(mbx, d); 60 | } 61 | comp.setState({ 62 | mailbox: mbx 63 | , events: data.payload 64 | , loading: false 65 | }); 66 | }) 67 | .catch(utils.catchHandler); 68 | } 69 | 70 | render () { 71 | let st = this.state; 72 | if (st.loading) return
; 73 | if (!st.events.length) return

No events.

; 74 | return
75 | { 76 | st.events.map((ev) => { 77 | { 78 | let type = ev.event 79 | , p = ev.payload 80 | ; 81 | if (type === "rss") { 82 | return
83 |
84 | 85 | {" • "} 86 | # 87 |
88 | { 89 | (ev.origin === "W3CMemes") ? 90 | "" : 91 |

{p.title}

92 | } 93 |
94 |
; 95 | } 96 | else if (ev.origin === "github") return ; 97 | else return
{JSON.stringify(ev, null, 4)}
; 98 | } 99 | }) 100 | } 101 |
; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /js/filter-list.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import FilterStore from "./stores/filter"; 5 | import ConfigurationStore from "./stores/configuration"; 6 | import MailboxActions from "./actions/mailbox"; 7 | 8 | import FilterSelector from "./filter-selector.jsx"; 9 | 10 | export default class FilterList extends React.Component { 11 | constructor (props) { 12 | super(props); 13 | this.state = { 14 | userFilters: FilterStore.getFilters() 15 | , allFilters: ConfigurationStore.getFilters() 16 | }; 17 | } 18 | componentDidMount () { 19 | FilterStore.addChangeListener(this._onChange.bind(this)); 20 | ConfigurationStore.addChangeListener(this._onChange.bind(this)); 21 | let sid = this.getSelected(); 22 | // XXX this is bad because we drive it but we don't respond to it 23 | // this project is a good candidate for Redux, if there's a refactor 24 | if (sid) MailboxActions.selectMailbox(sid); // code smell 25 | } 26 | componentWillUnmount () { 27 | FilterStore.removeChangeListener(this._onChange.bind(this)); 28 | ConfigurationStore.removeChangeListener(this._onChange.bind(this)); 29 | } 30 | _onChange () { 31 | this.setState({ userFilters: FilterStore.getFilters(), allFilters: ConfigurationStore.getFilters() }); 32 | } 33 | _onSelect (id) { 34 | if (id === null) return this.removeSelected(); 35 | let sid = this.getSelected(); 36 | if (sid) this.refs["fs-" + sid].unselect(); 37 | this.setSelected(id); 38 | this.refs["fs-" + id].select(); 39 | } 40 | setSelected (id) { 41 | localStorage.setItem("selectedID", id); 42 | MailboxActions.selectMailbox(id); 43 | } 44 | removeSelected () { 45 | localStorage.removeItem("selectedID"); 46 | } 47 | getSelected () { 48 | return localStorage.getItem("selectedID"); 49 | } 50 | 51 | render () { 52 | let st = this.state 53 | , comp = this 54 | ; 55 | if (Object.keys(st.userFilters).length === 0) { 56 | return

57 | You have no configured event lists to follow; add some using the “Configure” 58 | button above. 59 |

60 | ; 61 | } 62 | return 72 | ; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /js/filter-selector.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import LastSeenStore from "./stores/last-seen"; 5 | 6 | export default class FilterSelector extends React.Component { 7 | constructor (props) { 8 | super(props); 9 | this.state = { 10 | selected: props.selected 11 | , unreadCount: 0 12 | }; 13 | } 14 | select () { this.setState({ selected: true }); } 15 | unselect () { this.setState({ selected: false }); } 16 | _onClick () { 17 | // if (this.state.selected) return; // you can click it if there's new stuff, it reload 18 | this.props.onClick(this.props.id); 19 | } 20 | componentDidMount () { 21 | // XXX get rid of this if you can figure out why we get multiple calls to _unreadCount() 22 | // even after the components has unmounted, when toggling back and forth a filter in the 23 | // configuration panel. 24 | this.mounted = true; 25 | LastSeenStore.addChangeListener(this._unreadCount.bind(this)); 26 | } 27 | componentWillUnmount () { 28 | // XXX see above 29 | this.mounted = false; 30 | LastSeenStore.removeChangeListener(this._unreadCount.bind(this)); 31 | // notify our death 32 | // this is all a bad code smell 33 | if (this.state.selected) this.props.onClick(null); 34 | } 35 | _unreadCount () { 36 | // XXX see above 37 | if (!this.mounted) return; 38 | this.setState({ unreadCount: LastSeenStore.getFilterCount(this.props.id) }); 39 | } 40 | 41 | render () { 42 | let st = this.state 43 | , unread 44 | ; 45 | if (st.unreadCount) unread = {st.unreadCount}; 46 | return
  • 47 | 52 |
  • 53 | ; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /js/filter-toggle.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import UserActions from "./actions/user"; 5 | 6 | export default class FilterToggle extends React.Component { 7 | constructor (props) { 8 | super(props); 9 | this.state = { 10 | selected: props.selected 11 | }; 12 | } 13 | _toggle () { 14 | if (this.state.selected) UserActions.removeFilter(this.props.id); 15 | else UserActions.addFilter(this.props.id); 16 | this.setState({ selected: !this.state.selected }); 17 | } 18 | 19 | render () { 20 | let st = this.state 21 | , pr = this.props 22 | ; 23 | return
    24 |

    25 | 26 | {pr.name} 27 |

    28 |

    29 | {pr.description} 30 |

    31 |
    32 | ; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /js/login.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import Spinner from "../components/spinner.jsx"; 5 | 6 | import UserActions from "./actions/user"; 7 | import LoginStore from "./stores/login"; 8 | import MessageActions from "./actions/messages"; 9 | 10 | let utils = require("./utils"); 11 | 12 | export default class Login extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { loading: false }; 16 | } 17 | _onLogin (ev) { 18 | ev.preventDefault(); 19 | this.setState({ loading: true }); 20 | UserActions.login(utils.val(this.refs.username), utils.val(this.refs.password)); 21 | } 22 | componentDidMount () { 23 | LoginStore.addChangeListener(this._onChange.bind(this)); 24 | } 25 | componentWillUnmount () { 26 | LoginStore.removeChangeListener(this._onChange.bind(this)); 27 | } 28 | _onChange () { 29 | if (LoginStore.lastLoginFailed()) { 30 | MessageActions.error("Login failed.", { mode: "dom" }); 31 | this.setState({ loading: false }); 32 | } 33 | } 34 | 35 | render () { 36 | let st = this.state 37 | , spinner = st.loading ? : null 38 | ; 39 | return
    40 |

    41 | Log into the dashboard using your regular W3C credentials. 42 |

    43 |
    44 |
    45 | 46 | 47 |
    48 |
    49 | 50 | 51 |
    52 |
    53 | 54 | { spinner } 55 |
    56 |
    57 |
    58 | ; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /js/logout-button.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import UserActions from "./actions/user"; 4 | 5 | export default class LogoutButton extends React.Component { 6 | handleClick () { 7 | UserActions.logout(); 8 | } 9 | render () { 10 | return ; 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /js/midgard.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import { Router, Route } from "react-router"; 5 | import BrowserHistory from "react-router/lib/BrowserHistory"; 6 | 7 | import Favicon from "react-favicon"; 8 | 9 | import Application from "../components/application.jsx"; 10 | import Row from "../components/row.jsx"; 11 | import Col from "../components/col.jsx"; 12 | import Spinner from "../components/spinner.jsx"; 13 | import FlashList from "../components/flash-list.jsx"; 14 | 15 | import Login from "./login.jsx"; 16 | import Toolbar from "./toolbar.jsx"; 17 | import FilterList from "./filter-list.jsx"; 18 | import EventList from "./event-list.jsx"; 19 | 20 | import UserActions from "./actions/user"; 21 | import LoginStore from "./stores/login"; 22 | 23 | import MessageActions from "./actions/messages"; 24 | import MessageStore from "./stores/message"; 25 | 26 | import ConfigurationActions from "./actions/configuration"; 27 | import ConfigurationStore from "./stores/configuration"; 28 | 29 | import LastSeenActions from "./actions/last-seen"; 30 | import LastSeenStore from "./stores/last-seen"; 31 | 32 | 33 | let utils = require("./utils") 34 | , pp = utils.pathPrefix() 35 | ; 36 | 37 | function getState () { 38 | var st = { 39 | loggedIn: LoginStore.isLoggedIn() 40 | , allFilters: ConfigurationStore.getFilters() 41 | , unreadCount: (Object.keys(ConfigurationStore.getFilters() || {})).map(LastSeenStore.getFilterCount).reduce((a,b) => a+b, 0) 42 | }; 43 | if (st.loggedIn === null || st.allFilters === null) st.status = "loading"; 44 | else if (st.loggedIn) st.status = "ok"; 45 | else st.status = "login-required"; 46 | return st; 47 | } 48 | 49 | class W3CDashboard extends React.Component { 50 | constructor (props) { 51 | super(props); 52 | this.state = getState(); 53 | } 54 | componentDidMount () { 55 | LoginStore.addChangeListener(this._onChange.bind(this)); 56 | ConfigurationStore.addChangeListener(this._onChange.bind(this)); 57 | LastSeenStore.addChangeListener(this._onChange.bind(this)); 58 | UserActions.loadUser(); 59 | ConfigurationActions.loadConfiguration(); 60 | LastSeenActions.startWatching(); 61 | } 62 | componentWillUnmount () { 63 | LoginStore.removeChangeListener(this._onChange.bind(this)); 64 | ConfigurationStore.removeChangeListener(this._onChange.bind(this)); 65 | LastSeenStore.removeChangeListener(this._onChange.bind(this)); 66 | } 67 | _onChange () { 68 | this.setState(getState()); 69 | } 70 | 71 | render () { 72 | let st = this.state 73 | , toolbar 74 | , body 75 | , favicon = 76 | , title = "W3C Dashboard" + (st.unreadCount ? ` (${st.unreadCount})` : ''); 77 | ; 78 | if (st.unreadCount > 0) { 79 | favicon = 80 | } 81 | if (st.status === "loading") { 82 | body = ; 83 | } 84 | else if (st.status === "login-required") { 85 | body = ; 86 | } 87 | else { 88 | toolbar = ; 89 | body = 90 | 91 | 92 | 93 | ; 94 | } 95 | return 96 | {favicon} 97 | {toolbar} 98 | 99 | {body} 100 | 101 | ; 102 | } 103 | } 104 | 105 | React.render( 106 | 107 | 108 | 109 | , document.body 110 | ); 111 | -------------------------------------------------------------------------------- /js/show-github.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import Spinner from "../components/spinner.jsx"; 5 | 6 | import GHUserStore from "./stores/gh-user.js"; 7 | 8 | // /!\ magically create a global fetch 9 | require("isomorphic-fetch"); 10 | let utils = require("./utils"); 11 | 12 | function background (pic) { 13 | return "transparent url('" + utils.pathPrefix() + "node_modules/octicons/svg/" + pic + ".svg') no-repeat scroll 0px 7px / 15px 15px"; 14 | } 15 | 16 | export default class ShowGitHub extends React.Component { 17 | constructor (props) { 18 | super(props); 19 | this.state = { 20 | html: null 21 | , loading: false 22 | }; 23 | } 24 | componentWillMount () { 25 | let comp = this 26 | , renderURL = this.remoteRenderURL() 27 | ; 28 | if (renderURL) { 29 | comp.setState({ loading: true }); 30 | fetch( renderURL 31 | , { 32 | mode: "cors" 33 | , headers: { "Accept": "application/vnd.github.v3.html+json" } 34 | } 35 | ) 36 | .then(utils.jsonHandler) 37 | .then((data) => { 38 | comp.setState({ 39 | html: data.body_html 40 | , loading: false 41 | }); 42 | }) 43 | .catch(utils.catchHandler); 44 | 45 | } 46 | } 47 | remoteRenderURL () { 48 | let type = this.props.event 49 | , p = this.props.payload 50 | ; 51 | if (type === "push") return null; 52 | if (type === "issues" && (p.action === "opened" || p.action === "reopened")) return p.issue.url; 53 | if (type === "issue_comment" || type === "pull_request_review_comment" || type === "commit_comment") return p.comment.url; // this includes pull request comments 54 | else return null; 55 | } 56 | 57 | render () { 58 | let st = this.state 59 | , props = this.props 60 | , p = props.payload 61 | , type = props.event 62 | , link 63 | , content 64 | , intro 65 | , style = {} // padding-left: 20px 66 | ; 67 | 68 | // some things we don't bother rendering 69 | if ( 70 | (type === "delete" && p.ref_type === "branch") || 71 | (type === "create" && p.ref_type === "branch") || 72 | (type === "pull_request" && p.action === "synchronize") 73 | ) return
    ; 74 | 75 | // pick the link 76 | if (type === "issues") link = p.issue.html_url; 77 | else if (type === "issue_comment" || type === "pull_request_review_comment" || type === "commit_comment") link = p.comment.html_url; 78 | else if (type === "pull_request") link = p.pull_request.html_url; 79 | else if (type === "push") link = p.compare; 80 | else if (type === "fork") link = p.forkee.html_url; 81 | else if (type === "gollum") link = p.pages[0].html_url; 82 | 83 | // some types have actual content payloads 84 | if (st.loading) content = ; 85 | else if (st.html) content =
    ; 86 | 87 | // here we need to actually switch on events 88 | if (type === "issues") { 89 | let target = ""; 90 | switch (p.action) { 91 | case "assigned": 92 | target = {" to " } 93 | break; 94 | case "unassigned": 95 | target = {" from " } 96 | break; 97 | case "labeled": 98 | case "unlabeled": 99 | target = p.label ? {" as " } "{p.label.name}" : ""; 100 | break; 101 | } 102 | intro =

    103 | {p.action} issue 104 | {" "} 105 | {p.repository}#{p.issue.number} 106 | {" "} 107 | “{p.issue.title}” 108 | {target}. 109 | {" "} 110 | { 111 | p.label ? 112 | {p.label.name} : 113 | "" 114 | } 115 |

    116 | ; 117 | // XXX we don't handle: assigned, unassigned, labeled, unlabeled 118 | // we handle opened, closed, reopened 119 | if (p.action === "closed") style.background = background("issue-closed"); 120 | else if (p.action === "opened") style.background = background("issue-opened"); 121 | else if (p.action === "reopened") style.background = background("issue-reopened"); 122 | } 123 | else if (type === "issue_comment") { 124 | // XXX there may be other actions than "created" 125 | intro =

    126 | commented on issue 127 | {" "} 128 | {p.repository}#{p.issue}. 129 |

    130 | ; 131 | style.background = background("comment"); 132 | } 133 | else if (type === "pull_request_review_comment") { 134 | intro =

    135 | commented on pull request 136 | {" "} 137 | {p.repository}#{p.pull_request.number} 138 | {" "} 139 | “{p.pull_request.title}”. 140 | { 141 | p.label ? 142 | {p.label.name} : 143 | "" 144 | } 145 |

    146 | ; 147 | style.background = background("comment"); 148 | } 149 | else if (type === "commit_comment") { 150 | intro =

    151 | commented on commit 152 | {" "} 153 | {p.repository} {p.comment.commit_id.substr(0, 7)} on file {p.comment.path} 154 |

    155 | ; 156 | style.background = background("comment"); 157 | } 158 | else if (type === "pull_request") { 159 | intro =

    160 | {p.action} pull request 161 | {" "} 162 | {p.repository}#{p.pull_request.number} 163 | {" "} 164 | “{p.pull_request.title}”. 165 | { 166 | p.label ? 167 | {p.label.name} : 168 | "" 169 | } 170 |

    171 | ; 172 | if (p.action === "closed") style.background = background("git-merge"); 173 | else style.background = background("git-pull-request"); 174 | } 175 | else if (type === "push") { 176 | if (p.deleted) { 177 | intro =

    178 | deleted branch 179 | {" "} 180 | {p.repository}#{p.ref.replace("refs/heads/", "")}. 181 |

    182 | ; 183 | 184 | } else { 185 | intro =

    186 | pushed to 187 | {" "} 188 | {p.repository}#{p.ref.replace("refs/heads/", "")}. 189 |

    190 | ; 191 | content =
      192 | { 193 | p.commits.map((c) => { 194 | let short_sha = c.id.substr(0, 7); 195 | return
    • {short_sha} {c.message}
    • ; 196 | }) 197 | } 198 |
    199 | ; 200 | } 201 | style.background = background("repo-push"); 202 | } 203 | else if (type === "create") { 204 | intro =

    205 | created a {p.ref_type} named {p.ref} on repository 206 | {" "} 207 | {p.repository}. 208 |

    209 | ; 210 | style.background = background("repo-created"); 211 | } 212 | else if (type === "fork") { 213 | intro =

    214 | forked repository 215 | {" "} 216 | {p.repository} to 217 | {" "} 218 | {p.forkee.full_name}. 219 |

    220 | ; 221 | style.background = background("repo-forked"); 222 | } 223 | else if (type === "gollum") { 224 | intro =

    225 | changed wiki pages in repository 226 | {" "} 227 | {p.repository}. 228 |

    229 | ; 230 | content =
      231 | { 232 | p.pages.map((w) => { 233 | let short_sha = w.sha.substr(0, 7); 234 | return
    • {w.title} ({w.action})
    • ; 235 | }) 236 | } 237 |
    238 | ; 239 | 240 | style.background = background("repo-wiki"); 241 | } 242 | else { 243 | intro =

    Unknown GH event type

    ; 244 | content =
    ; 245 | } 246 | 247 | return
    248 |
    249 | 250 | {" • "} 251 | { 252 | link ? # : "#" 253 | } 254 | 255 |
    256 | {intro} 257 |
    258 | {content} 259 |
    260 |
    ; 261 | } 262 | } 263 | 264 | class GithubUser extends React.Component { 265 | constructor (props) { 266 | super(props); 267 | this.state = {}; 268 | } 269 | 270 | loadUser () { 271 | this.setState(GHUserStore.getUser(this.props.name)); 272 | } 273 | 274 | componentDidMount () { 275 | GHUserStore.addChangeListener(this.loadUser.bind(this)); 276 | this.loadUser(); 277 | } 278 | componentWillUnmount () { 279 | GHUserStore.removeChangeListener(this.loadUser.bind(this)); 280 | } 281 | 282 | render () { 283 | return @{this.props.name}; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /js/stores/configuration.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | // this loads up whatever required configuration is sitting on the server 7 | 8 | // /!\ magically create a global fetch 9 | require("isomorphic-fetch"); 10 | 11 | let utils = require("../utils") 12 | , _filters = null 13 | , apiFilters = utils.endpoint("api/events") 14 | , ConfigurationStore = module.exports = assign({}, EventEmitter.prototype, { 15 | emitChange: function () { this.emit("change"); } 16 | , addChangeListener: function (cb) { this.on("change", cb); } 17 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 18 | 19 | , getFilters: function () { 20 | return _filters; 21 | } 22 | }) 23 | ; 24 | 25 | ConfigurationStore.dispatchToken = DashboardDispatch.register((action) => { 26 | switch (action.type) { 27 | case "load-configuration": 28 | fetch(apiFilters, { credentials: "include", mode: "cors" }) 29 | .then(utils.jsonHandler) 30 | .then((data) => { 31 | _filters = data; 32 | ConfigurationStore.emitChange(); 33 | }) 34 | .catch(utils.catchHandler); 35 | break; 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /js/stores/filter.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | import LoginStore from "./login.js"; 6 | 7 | // /!\ magically create a global fetch 8 | require("isomorphic-fetch"); 9 | 10 | 11 | // DRY 12 | let utils = require("../utils") 13 | , _filters = {} 14 | , _user = null 15 | , apiFilters = utils.endpoint("api/user/filters") 16 | , FilterStore = module.exports = assign({}, EventEmitter.prototype, { 17 | emitChange: function () { this.emit("change"); } 18 | , addChangeListener: function (cb) { this.on("change", cb); } 19 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 20 | 21 | , getFilters: function () { 22 | return _filters; 23 | } 24 | }) 25 | ; 26 | 27 | LoginStore.addChangeListener(function () { 28 | if (LoginStore.isLoggedIn()) { 29 | _user = LoginStore.getUser(); 30 | _filters = _user.filters || {}; 31 | FilterStore.emitChange(); 32 | } 33 | }); 34 | 35 | function saveFilters () { 36 | _user.filters = _filters; 37 | fetch( apiFilters 38 | , { 39 | credentials: "include" 40 | , mode: "cors" 41 | , method: "put" 42 | , headers: { "Content-Type": "application/json" } 43 | , body: JSON.stringify(_filters) 44 | }) 45 | .then(() => { 46 | FilterStore.emitChange(); 47 | }) 48 | .catch(utils.catchHandler); 49 | } 50 | 51 | FilterStore.dispatchToken = DashboardDispatch.register((action) => { 52 | switch (action.type) { 53 | case "add-filter": 54 | _filters[action.id] = true; 55 | saveFilters(); 56 | break; 57 | case "remove-filter": 58 | delete _filters[action.id]; 59 | saveFilters(); 60 | break; 61 | } 62 | }); 63 | -------------------------------------------------------------------------------- /js/stores/gh-user.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | let utils = require("../utils"); 7 | 8 | let _ghusers = {}, _loading = {} 9 | , GHUserStore = module.exports = assign({}, EventEmitter.prototype, { 10 | emitChange: function () { this.emit("change"); } 11 | , addChangeListener: function (cb) { this.on("change", cb); } 12 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 13 | , getUser: function (name) { 14 | if (_ghusers[name]) { 15 | return _ghusers[name]; 16 | } 17 | if (!_loading[name]) { 18 | _loading[name] = true; 19 | fetch( "https://api.github.com/users/" + name 20 | , { 21 | mode: "cors" 22 | , headers: { "Accept": "application/vnd.github.v3.html+json" } 23 | }) 24 | .then(utils.jsonHandler) 25 | .then((data) => { 26 | _ghusers[name] = { 27 | fullname: data.name 28 | , avatar: data.avatar_url + "&s=48" 29 | }; 30 | GHUserStore.emitChange(); 31 | }) 32 | .catch(utils.catchHandler); 33 | } 34 | return {}; 35 | } 36 | }); 37 | 38 | -------------------------------------------------------------------------------- /js/stores/last-seen.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | import FilterStore from "./filter.js"; 6 | 7 | // /!\ magically create a global fetch 8 | require("isomorphic-fetch"); 9 | 10 | // DRY 11 | let utils = require("../utils") 12 | , _filterCounts = {} 13 | , _lastSeen = {} 14 | , apiSince = utils.endpoint("api/events-since") 15 | , LastSeenStore = module.exports = assign({}, EventEmitter.prototype, { 16 | emitChange: function () { this.emit("change"); } 17 | , addChangeListener: function (cb) { this.on("change", cb); } 18 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 19 | 20 | , getFilterCount: function (filter) { 21 | return _filterCounts[filter] || 0; 22 | } 23 | , load: function () { 24 | let json = localStorage.getItem("last-seen-filters"); 25 | _lastSeen = json ? JSON.parse(json) : {}; 26 | } 27 | }) 28 | ; 29 | 30 | function save () { 31 | localStorage.setItem("last-seen-filters", JSON.stringify(_lastSeen, null, 4)); 32 | } 33 | 34 | function checkNewStuff () { 35 | fetch( apiSince 36 | , { 37 | credentials: "include" 38 | , mode: "cors" 39 | , method: "post" 40 | , headers: { "Content-Type": "application/json" } 41 | , body: JSON.stringify(_lastSeen) 42 | }) 43 | .then(utils.jsonHandler) 44 | .then((data) => { 45 | _filterCounts = data; 46 | save(); 47 | LastSeenStore.emitChange(); 48 | }) 49 | .catch(utils.catchHandler) 50 | ; 51 | } 52 | 53 | FilterStore.addChangeListener(function () { 54 | let userFilters = FilterStore.getFilters() 55 | , newSeen = {} 56 | , newFilters = {} 57 | , unseen = false 58 | ; 59 | for (let k in userFilters) { 60 | if (typeof _lastSeen[k] === "undefined") unseen = true; 61 | newSeen[k] = _lastSeen[k] || null; 62 | newFilters[k] = _filterCounts[k] || 0; 63 | } 64 | _lastSeen = newSeen; 65 | _filterCounts = newFilters; 66 | save(); 67 | if (unseen) checkNewStuff(); 68 | else LastSeenStore.emitChange(); 69 | }); 70 | 71 | LastSeenStore.dispatchToken = DashboardDispatch.register((action) => { 72 | switch (action.type) { 73 | case "watch-seen-since": 74 | LastSeenStore.load(); 75 | checkNewStuff(); 76 | // check every minute 77 | setInterval(checkNewStuff, 60 * 1000); 78 | break; 79 | case "saw-filter": 80 | let d = action.date; 81 | _lastSeen[action.id] = [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), 82 | d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), 83 | d.getUTCMilliseconds()]; 84 | _filterCounts[action.id] = 0; 85 | save(); 86 | LastSeenStore.emitChange(); 87 | break; 88 | } 89 | }); 90 | -------------------------------------------------------------------------------- /js/stores/login.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | // /!\ magically create a global fetch 7 | require("isomorphic-fetch"); 8 | 9 | let utils = require("../utils") 10 | , _user = null 11 | , _loggedIn = null 12 | , _lastLoginFailed = false 13 | , apiUser = utils.endpoint("api/user") 14 | , LoginStore = module.exports = assign({}, EventEmitter.prototype, { 15 | emitChange: function () { this.emit("change"); } 16 | , addChangeListener: function (cb) { this.on("change", cb); } 17 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 18 | 19 | , getUser: function () { 20 | return _user; 21 | } 22 | , isLoggedIn: function () { 23 | return _loggedIn; 24 | } 25 | , lastLoginFailed: function () { 26 | return _lastLoginFailed; 27 | } 28 | }) 29 | ; 30 | 31 | LoginStore.dispatchToken = DashboardDispatch.register((action) => { 32 | switch (action.type) { 33 | case "login": 34 | fetch( apiUser 35 | , { 36 | credentials: "include" 37 | , mode: "cors" 38 | , method: "post" 39 | , headers: { "Content-Type": "application/json" } 40 | , body: JSON.stringify({ 41 | username: action.username 42 | , password: action.password 43 | }) 44 | }) 45 | .then(utils.jsonHandler) 46 | .then((data) => { 47 | if (data.username) { 48 | _loggedIn = true; 49 | _user = data; 50 | _lastLoginFailed = false; 51 | } 52 | else { 53 | _loggedIn = false; 54 | _user = null; 55 | _lastLoginFailed = true; 56 | } 57 | LoginStore.emitChange(); 58 | }) 59 | .catch(utils.catchHandler); 60 | break; 61 | case "load-user": 62 | fetch(apiUser, { credentials: "include", mode: "cors" }) 63 | .then(utils.jsonHandler) 64 | .then((data) => { 65 | if (data.username) { 66 | _loggedIn = true; 67 | _user = data; 68 | } 69 | else { 70 | _loggedIn = false; 71 | _user = null; 72 | } 73 | LoginStore.emitChange(); 74 | }) 75 | .catch(utils.catchHandler); 76 | break; 77 | case "logout": 78 | fetch( apiUser 79 | , { 80 | credentials: "include" 81 | , mode: "cors" 82 | , method: "delete" 83 | }) 84 | .then(utils.jsonHandler) 85 | .then(() => { 86 | _loggedIn = false; 87 | _user = null; 88 | _lastLoginFailed = false; 89 | LoginStore.emitChange(); 90 | }) 91 | .catch(utils.catchHandler); 92 | break; 93 | } 94 | }); 95 | -------------------------------------------------------------------------------- /js/stores/mailbox.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | // XXX DRY 7 | 8 | let _mbx = null 9 | , MailboxStore = module.exports = assign({}, EventEmitter.prototype, { 10 | emitChange: function () { this.emit("change"); } 11 | , addChangeListener: function (cb) { this.on("change", cb); } 12 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 13 | 14 | , mailbox: function () { 15 | return _mbx; 16 | } 17 | }) 18 | ; 19 | 20 | MailboxStore.dispatchToken = DashboardDispatch.register((action) => { 21 | switch (action.type) { 22 | case "select-mailbox": 23 | _mbx = action.id; 24 | MailboxStore.emitChange(); 25 | break; 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /js/stores/message.js: -------------------------------------------------------------------------------- 1 | 2 | import DashboardDispatch from "../dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | let _messages = [] 7 | , _counter = 0 8 | , MessageStore = module.exports = assign({}, EventEmitter.prototype, { 9 | emitChange: function () { this.emit("change"); } 10 | , addChangeListener: function (cb) { this.on("change", cb); } 11 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 12 | 13 | , messages: function () { 14 | return _messages; 15 | } 16 | }) 17 | ; 18 | 19 | MessageStore.dispatchToken = DashboardDispatch.register((action) => { 20 | switch (action.type) { 21 | case "error": 22 | case "success": 23 | let msg = typeof action.message === "string" ? action.message : action.message.message; 24 | if (_messages.length > 0 && _messages[_messages.length - 1].message === msg) { 25 | _messages[_messages.length - 1].repeat++; 26 | } else { 27 | _messages.push({ 28 | id: ++_counter 29 | , message: msg 30 | , type: action.type 31 | , mode: action.mode 32 | , repeat: 0 33 | }); 34 | } 35 | MessageStore.emitChange(); 36 | break; 37 | case "dismiss": 38 | _messages = _messages.filter(function (m) { return m.id != action.id; }); 39 | MessageStore.emitChange(); 40 | break; 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /js/toolbar.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | import Spinner from "../components/spinner.jsx"; 5 | 6 | import FilterToggle from "./filter-toggle.jsx"; 7 | import Logout from "./logout-button.jsx"; 8 | 9 | import FilterStore from "./stores/filter"; 10 | import ConfigurationStore from "./stores/configuration"; 11 | 12 | export default class Toolbar extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { 16 | showing: false 17 | , userFilters: FilterStore.getFilters() 18 | , allFilters: ConfigurationStore.getFilters() 19 | }; 20 | } 21 | componentDidMount () { 22 | FilterStore.addChangeListener(this._onChange.bind(this)); 23 | ConfigurationStore.addChangeListener(this._onChange.bind(this)); 24 | } 25 | componentWillUnmount () { 26 | FilterStore.removeChangeListener(this._onChange.bind(this)); 27 | ConfigurationStore.removeChangeListener(this._onChange.bind(this)); 28 | } 29 | _onChange () { 30 | this.setState({ userFilters: FilterStore.getFilters(), allFilters: ConfigurationStore.getFilters() }); 31 | } 32 | _toggleFilters () { 33 | this.setState({ showing: !this.state.showing }); 34 | } 35 | 36 | render () { 37 | let st = this.state 38 | , prefs 39 | ; 40 | if (st.showing) { 41 | if (st.loading) { 42 | prefs =
    ; 43 | } 44 | else { 45 | prefs =
    46 |

    Pick the filters you wish to subscribe to

    47 | { 48 | Object.keys(st.allFilters) 49 | .map((id) => { 50 | return ; 51 | }) 52 | } 53 |
    54 | ; 55 | } 56 | } 57 | return
    58 | 59 | 60 | {prefs} 61 |
    62 | ; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import MessageActions from "./actions/messages"; 4 | 5 | 6 | let config = require("../config.json"); 7 | 8 | module.exports = { 9 | jsonHandler: (res) => { return res.json(); } 10 | , catchHandler: (e) => { 11 | MessageActions.error(e); 12 | } 13 | , pathPrefix: () => { 14 | return config.pathPrefix; 15 | } 16 | , endpoint: (str) => { 17 | str = str.replace(/^\//, ""); 18 | return config.api + str; 19 | } 20 | , val: (ref) => { 21 | let el = React.findDOMNode(ref) 22 | , value 23 | ; 24 | if (!el) return null; 25 | if (el.multiple) { 26 | value = []; 27 | for (var i = 0, n = el.selectedOptions.length; i < n; i++) { 28 | value.push(el.selectedOptions.item(i).value.trim()); 29 | } 30 | } 31 | else value = el.value.trim(); 32 | return value; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "midgard", 3 | "version": "0.0.2", 4 | "private": true, 5 | "license": "MIT", 6 | "devDependencies": { 7 | "babel": "^5.8.21", 8 | "babelify": "^6.1.3", 9 | "browserify": "^11.0.1", 10 | "clean-css": "^3.3.8", 11 | "exorcist": "^0.4.0", 12 | "flux": "^2.0.3", 13 | "isomorphic-fetch": "^2.1.1", 14 | "nodemon": "1.4.0", 15 | "normalize.css": "^3.0.3", 16 | "object-assign": "^3.0.0", 17 | "react": "^0.13.3", 18 | "react-favicon": "0.0.3", 19 | "react-router": "1.0.0-beta2", 20 | "uglify-js": "^3.4.9", 21 | "ungrid": "^1.0.1", 22 | "watchify": "^3.3.1" 23 | }, 24 | "dependencies": { 25 | "octicons": "^2.2.0", 26 | "react-document-title": "^2.0.3" 27 | }, 28 | "browserify": { 29 | "transform": [ 30 | "babelify" 31 | ] 32 | }, 33 | "scripts": { 34 | "build-js-debug": "browserify js/midgard.jsx --debug | exorcist js/midgard.js.map > js/midgard.js", 35 | "build-js": "browserify js/midgard.jsx | uglifyjs -c warnings=false -m > js/midgard.min.js", 36 | "watch-js": "watchify js/midgard.jsx --verbose --ignore-watch=\"**/node_modules/**\" --ignore-watch=\"**/js/midgard.min.js\" -o 'uglifyjs -c warnings=false -m > js/midgard.min.js'", 37 | "build-css": "cleancss -o ./css/midgard.min.css ./css/midgard.css", 38 | "watch-css": "nodemon --watch css/midgard.css --exec 'npm run build-css'", 39 | "build": "npm run build-css && npm run build-js", 40 | "watch": "npm run watch-css & npm run watch-js" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /w3c.json: -------------------------------------------------------------------------------- 1 | { 2 | "repo-type": "tool", 3 | "contacts": ["deniak", "dontcallmedom"] 4 | } 5 | --------------------------------------------------------------------------------