├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── config-private.sample.json ├── config.json ├── frontend ├── .eslintrc.json ├── App.jsx ├── App.styl ├── actions.js ├── components │ ├── GitHubSignIn.jsx │ ├── GitHubSignIn.styl │ ├── Issue.jsx │ ├── Issue.styl │ ├── IssueList.jsx │ └── IssueList.styl ├── issues.js ├── main.jsx ├── main.styl └── reducers.js ├── gulpfile.js ├── package.json ├── server ├── package.json └── server.js ├── service-worker ├── .eslintrc.json ├── github.js └── service-worker.js └── www └── index.html /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | server/node_modules/* 3 | www/* 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 6 9 | }, 10 | "rules": { 11 | // Possible errors 12 | "comma-dangle": [2, "never"], 13 | "no-cond-assign": [2, "except-parens"], 14 | "no-console": 2, 15 | "no-constant-condition": 2, 16 | "no-control-regex": 2, 17 | "no-debugger": 2, 18 | "no-dupe-args": 2, 19 | "no-dupe-keys": 2, 20 | "no-duplicate-case": 2, 21 | "no-empty": 2, 22 | "no-empty-character-class": 2, 23 | "no-ex-assign": 2, 24 | "no-extra-boolean-cast": 2, 25 | "no-extra-parens": 0, // https://github.com/eslint/eslint/issues/3065 26 | "no-extra-semi": 2, 27 | "no-func-assign": 2, 28 | "no-inner-declarations": 0, 29 | "no-invalid-regexp": 2, 30 | "no-irregular-whitespace": 2, 31 | "no-negated-in-lhs": 2, 32 | "no-obj-calls": 2, 33 | "no-regex-spaces": 2, 34 | "no-sparse-arrays": 2, 35 | "no-unexpected-multiline": 2, 36 | "no-unreachable": 2, 37 | "use-isnan": 2, 38 | "valid-jsdoc": 0, 39 | "valid-typeof": 2, 40 | 41 | // Best practices 42 | "accessor-pairs": 2, 43 | "array-callback-return": 2, 44 | "block-scoped-var": 0, 45 | "complexity": 0, 46 | "consistent-return": 2, 47 | "curly": [2, "all"], 48 | "default-case": 0, 49 | "dot-location": [2, "property"], 50 | "dot-notation": 2, 51 | "eqeqeq": 2, 52 | "guard-for-in": 0, 53 | "no-alert": 2, 54 | "no-caller": 2, 55 | "no-case-declarations": 2, 56 | "no-div-regex": 0, 57 | "no-else-return": 2, 58 | "no-empty-pattern": 2, 59 | "no-eq-null": 2, 60 | "no-eval": 2, 61 | "no-extend-native": 2, 62 | "no-extra-bind": 2, 63 | "no-extra-label": 2, 64 | "no-fallthrough": 2, 65 | "no-floating-decimal": 2, 66 | "no-implicit-coercion": 2, 67 | "no-implicit-globals": 2, 68 | "no-implied-eval": 0, 69 | "no-invalid-this": 2, 70 | "no-iterator": 2, 71 | "no-labels": [2, { "allowLoop": true }], 72 | "no-lone-blocks": 2, 73 | "no-loop-func": 0, 74 | "no-magic-numbers": 2, 75 | "no-multi-spaces": 2, 76 | "no-multi-str": 2, 77 | "no-native-reassign": 2, 78 | "no-new": 2, 79 | "no-new-func": 2, 80 | "no-new-wrappers": 2, 81 | "no-octal": 2, 82 | "no-octal-escape": 2, 83 | "no-param-reassign": 0, 84 | "no-process-env": 2, 85 | "no-proto": 2, 86 | "no-redeclare": 2, 87 | "no-return-assign": [2, "always"], 88 | "no-script-url": 2, 89 | "no-self-assign": 2, 90 | "no-self-compare": 2, 91 | "no-sequences": 2, 92 | "no-throw-literal": 2, 93 | "no-unmodified-loop-condition": 2, 94 | "no-unused-expressions": 2, 95 | "no-unused-labels": 2, 96 | "no-useless-call": 2, 97 | "no-useless-concat": 2, 98 | "no-void": 2, 99 | "no-warning-comments": 0, 100 | "no-with": 2, 101 | "radix": [2, "as-needed"], 102 | "vars-on-top": 0, 103 | "wrap-iife": [2, "outside"], 104 | "yoda": [2, "never"], 105 | 106 | // Strict Mode 107 | "strict": [2, "global"], 108 | 109 | // Variables 110 | "init-declarations": 0, 111 | "no-catch-shadow": 2, 112 | "no-delete-var": 2, 113 | "no-label-var": 2, 114 | "no-shadow": 2, 115 | "no-shadow-restricted-names": 2, 116 | "no-undef": 2, 117 | "no-undef-init": 2, 118 | "no-undefined": 0, 119 | "no-unused-vars": 2, 120 | "no-use-before-define": [2, "nofunc"], 121 | 122 | // Node.js and CommonJS 123 | "callback-return": 0, 124 | "global-require": 2, 125 | "handle-callback-err": 2, 126 | "no-mixed-requires": [2, true], 127 | "no-new-require": 2, 128 | "no-path-concat": 2, 129 | "no-process-exit": 2, 130 | "no-restricted-imports": 0, 131 | "no-restricted-modules": 0, 132 | "no-sync": 0, 133 | 134 | // Stylistic Issues 135 | "array-bracket-spacing": [2, "never"], 136 | "block-spacing": [2, "always"], 137 | "brace-style": [2, "1tbs", { "allowSingleLine": false }], 138 | "camelcase": [2, { "properties": "always" }], 139 | "comma-spacing": [2, { "before": false, "after": true }], 140 | "comma-style": [2, "last"], 141 | "computed-property-spacing": [2, "never"], 142 | "consistent-this": 0, 143 | "eol-last": 2, 144 | "func-names": 0, 145 | "func-style": [2, "declaration"], 146 | "id-blacklist": 0, 147 | "id-length": 0, 148 | "id-match": 0, 149 | "indent": [2, 2, { "SwitchCase": 1 }], 150 | "jsx-quotes": 0, 151 | "key-spacing": [2, { "beforeColon": false, "afterColon": true, "mode": "strict" }], 152 | "keyword-spacing": [2, { "before": true, "after": true }], 153 | "linebreak-style": [2, "unix"], 154 | "lines-around-comment": 0, 155 | "max-depth": 0, 156 | "max-len": [2, 120], 157 | "max-nested-callbacks": 0, 158 | "max-params": 0, 159 | "max-statements": 0, 160 | "new-cap": 2, 161 | "new-parens": 2, 162 | "newline-after-var": 0, 163 | "newline-per-chained-call": 0, 164 | "no-array-constructor": 2, 165 | "no-bitwise": 0, 166 | "no-continue": 0, 167 | "no-inline-comments": 0, 168 | "no-lonely-if": 2, 169 | "no-mixed-spaces-and-tabs": 2, 170 | "no-multiple-empty-lines": 2, 171 | "no-negated-condition": 0, 172 | "no-nested-ternary": 2, 173 | "no-new-object": 2, 174 | "no-plusplus": 0, 175 | "no-restricted-syntax": 0, 176 | "no-spaced-func": 2, 177 | "no-ternary": 0, 178 | "no-trailing-spaces": 2, 179 | "no-underscore-dangle": 2, 180 | "no-unneeded-ternary": 2, 181 | "no-whitespace-before-property": 2, 182 | "object-curly-spacing": [2, "always"], 183 | "one-var": [2, "never"], 184 | "one-var-declaration-per-line": [2, "initializations"], 185 | "operator-assignment": [2, "always"], 186 | "operator-linebreak": [2, "after"], 187 | "padded-blocks": [2, "never"], 188 | "quote-props": [2, "as-needed"], 189 | "quotes": [2, "double", { "allowTemplateLiterals": true }], 190 | "require-jsdoc": 0, 191 | "semi": [2, "always"], 192 | "semi-spacing": 2, 193 | "sort-imports": 0, 194 | "sort-vars": 0, 195 | "space-before-blocks": [2, "always"], 196 | "space-before-function-paren": [2, { "anonymous": "always", "named": "never" }], 197 | "space-in-parens": [2, "never"], 198 | "space-infix-ops": 2, 199 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 200 | "spaced-comment": [2, "always", { "markers": ["///"] }], 201 | "wrap-regex": 0, 202 | 203 | // ECMAScript 6 204 | "arrow-body-style": [2, "as-needed"], 205 | "arrow-parens": [2, "as-needed"], 206 | "arrow-spacing": 2, 207 | "constructor-super": 2, 208 | "generator-star-spacing": [2, "after"], 209 | "no-class-assign": 2, 210 | "no-confusing-arrow": 0, 211 | "no-const-assign": 2, 212 | "no-dupe-class-members": 2, 213 | "no-new-symbol": 2, 214 | "no-this-before-super": 2, 215 | "no-useless-constructor": 2, 216 | "no-var": 2, 217 | "object-shorthand": 2, 218 | "prefer-arrow-callback": 2, 219 | "prefer-const": 2, 220 | "prefer-reflect": 0, 221 | "prefer-rest-params": 0, 222 | "prefer-spread": 2, 223 | "prefer-template": 0, 224 | "require-yield": 2, 225 | "template-curly-spacing": [2, "never"], 226 | "yield-star-spacing": [2, "after"] 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /npm-debug.log 3 | 4 | /config-private.json 5 | 6 | /www/bundle.js 7 | /www/service-worker-bundle.js 8 | /www/styles.css 9 | 10 | /server/node_modules/ 11 | /server/npm-debug.log 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | script: 5 | npm run lint && npm test 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2016 Domenic Denicola 2 | 3 | This work is free. You can redistribute it and/or modify it under the 4 | terms of the Do What The Fuck You Want To Public License, Version 2, 5 | as published by Sam Hocevar. See below for more details. 6 | 7 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 8 | Version 2, December 2004 9 | 10 | Copyright (C) 2004 Sam Hocevar 11 | 12 | Everyone is permitted to copy and distribute verbatim or modified 13 | copies of this license document, and changing it is allowed as long 14 | as the name is changed. 15 | 16 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 17 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 18 | 19 | 0. You just DO WHAT THE FUCK YOU WANT TO. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domenic/html-dashboard/b134ba9c18318b51c10e05a8a2768072332a040b/README.md -------------------------------------------------------------------------------- /config-private.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitHub": { 3 | "clientSecret": "" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": "1337", 3 | "repo": "whatwg/html", 4 | "gitHub": { 5 | "scopes": "", 6 | "clientId": "f6d2e811b0f087ef0daf", 7 | "redirectRoute": "/github-oauth", 8 | "redirectURL": "http://localhost:1337/github-oauth", 9 | "cookie": "gitHubAccessToken", 10 | "callback": "gitHubOAuthComplete" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc.json", 3 | "env": { 4 | "browser": true 5 | }, 6 | "parserOptions": { 7 | "ecmaFeatures": { 8 | "jsx": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/App.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const React = require("react"); 3 | const { connect } = require("react-redux"); 4 | const actions = require("./actions.js"); 5 | const GitHubSignIn = require("./components/GitHubSignIn.jsx"); 6 | const IssueList = require("./components/IssueList.jsx"); 7 | const config = require("../config.json"); 8 | 9 | class App extends React.Component { 10 | componentDidMount() { 11 | if (this.props.gitHub.accessToken) { 12 | this.props.dispatch(actions.loadIssues()); 13 | } 14 | } 15 | 16 | render() { 17 | const { dispatch, gitHub, issues } = this.props; 18 | const signedIn = Boolean(gitHub.accessToken); 19 | 20 | return
21 |

{config.repo} issue tracker

22 | dispatch(actions.signInToGitHub())} 23 | disabled={gitHub.signingIn} 24 | signedIn={signedIn} /> 25 | { 26 | signedIn ? 27 |
28 | 31 | 34 | 37 | 40 |
41 | : 42 |

Sign in (click the button in the upper right) to see the dashboard.

43 | } 44 |
; 45 | } 46 | } 47 | 48 | function mapStateToProps(state) { 49 | return { 50 | gitHub: state.gitHub, 51 | issues: state.issues 52 | }; 53 | } 54 | 55 | module.exports = connect(mapStateToProps)(App); 56 | -------------------------------------------------------------------------------- /frontend/App.styl: -------------------------------------------------------------------------------- 1 | #app 2 | > h1 3 | font-size: 30px 4 | margin-bottom: 10px 5 | -------------------------------------------------------------------------------- /frontend/actions.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const cookies = require("cookies-js"); 3 | const config = require("../config.json"); 4 | 5 | const gitHubOauthURL = 6 | `https://github.com/login/oauth/authorize` + 7 | `?client_id=${encodeURIComponent(config.gitHub.clientId)}` + 8 | `&scope=${encodeURIComponent(config.gitHub.scopes)}` + 9 | `&redirect_uri=${encodeURIComponent(config.gitHub.redirectURL)}`; 10 | // TODO use state? 11 | 12 | function beginGitHubSignIn() { 13 | return { 14 | type: "begin GitHub sign in" 15 | }; 16 | } 17 | 18 | function receiveGitHubAccessToken(accessToken) { 19 | return { 20 | type: "receive GitHub access token", 21 | accessToken 22 | }; 23 | } 24 | 25 | exports.signInToGitHub = () => 26 | dispatch => { 27 | dispatch(beginGitHubSignIn()); 28 | 29 | window[config.gitHub.callback] = () => { 30 | delete window[config.gitHub.callback]; 31 | 32 | dispatch(receiveGitHubAccessToken(cookies.get(config.gitHub.cookie))); 33 | dispatch(exports.loadIssues()); 34 | }; 35 | 36 | window.open(gitHubOauthURL, "OAuth", 37 | "width=1038,height=650,status=no,resizable=yes,toolbar=no,menubar=no,scrollbars=yes"); 38 | }; 39 | 40 | function issuesLoaded(issuesData, userData) { 41 | return { 42 | type: "issues loaded", 43 | data: { 44 | issues: issuesData, 45 | username: userData.login 46 | } 47 | }; 48 | } 49 | 50 | exports.loadIssues = () => 51 | (dispatch, getState) => { 52 | const accessToken = getState().gitHub.accessToken; 53 | 54 | return Promise.all([ 55 | fetch("/issues.json?token=" + accessToken).then(res => res.json()), 56 | fetch("/user.json?token=" + accessToken).then(res => res.json()) 57 | ]) 58 | .then(([issuesData, userData]) => dispatch(issuesLoaded(issuesData, userData))); 59 | // TODO error handling 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/components/GitHubSignIn.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const React = require("react"); 3 | 4 | module.exports = props => ( 5 |
6 | { 7 | props.signedIn ? 8 |

Signed in to GitHub

9 | : 10 | 11 | } 12 |
13 | ); 14 | -------------------------------------------------------------------------------- /frontend/components/GitHubSignIn.styl: -------------------------------------------------------------------------------- 1 | .github-sign-in 2 | position: absolute 3 | top: 0 4 | right: 0 5 | display: inline-block 6 | background: #444 7 | color: white 8 | padding: 4px 9 | font-size: 0.8rem 10 | -------------------------------------------------------------------------------- /frontend/components/Issue.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const React = require("react"); 3 | const Colr = require("colr"); 4 | const relativeDate = require("relative-date"); 5 | 6 | module.exports = ({ issue, getLabelURL, getAssigneeURL }) => ( 7 |
  • 8 | {issue.title} 9 | 10 |
      11 | { 12 | issue.labels.map(label => ( 13 |
    • 14 | {label.name} 17 |
    • 18 | )) 19 | } 20 |
    21 | 22 |

    #{issue.number} opened by {issue.user.login} 25 |

    26 | 27 |
      28 | { 29 | issue.assignees.map(assignee => ( 30 |
    • 31 | 32 | {`@${assignee.login}`} 33 | 34 |
    • 35 | )) 36 | } 37 |
    38 |
  • 39 | ); 40 | 41 | function foregroundColor(backgroundColor) { 42 | const color = Colr.fromHex("#" + backgroundColor); 43 | const hsl = color.toHslObject(); 44 | 45 | return hsl.l > 70 ? "#333" : "#fff"; 46 | } 47 | -------------------------------------------------------------------------------- /frontend/components/Issue.styl: -------------------------------------------------------------------------------- 1 | .issue 2 | padding: 12px 3 | border-top: solid 1px #eee 4 | position: relative 5 | 6 | &:hover 7 | background: #F5F5F5 8 | 9 | .title 10 | line-height: 1.25 11 | font-size: 16px 12 | font-weight: bold 13 | 14 | .labels 15 | li 16 | display: inline-block 17 | padding: 3px 4px 18 | font-size: 11px 19 | font-weight: bold 20 | line-height: 1 21 | border-radius: 2px 22 | box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12) 23 | margin-right: 4px 24 | 25 | .extra-info 26 | color: #767676 27 | font-size: 12px 28 | margin-top: 3px 29 | 30 | a 31 | color: #767676 32 | 33 | &:hover 34 | color: #4078C0 35 | 36 | .assignees 37 | position: absolute 38 | right: 12px 39 | top: 12px 40 | display: inline 41 | 42 | > li 43 | display: inline 44 | -------------------------------------------------------------------------------- /frontend/components/IssueList.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const React = require("react"); 3 | const Issue = require("./Issue.jsx"); 4 | const filterIssues = require("../issues.js").filter; 5 | const config = require("../../config.json"); 6 | 7 | module.exports = ({ issues, filter, description }) => { 8 | const filteredIssues = filterIssues(issues, filter).sort((a, b) => { 9 | if (a.pull_request && !b.pull_request) { 10 | return -1; 11 | } 12 | if (!a.pull_request && b.pull_request) { 13 | return 1; 14 | } 15 | return b.created_at.localeCompare(a.created_at); 16 | }); 17 | 18 | function getLabelURL(labelName) { 19 | return `https://github.com/${config.repo}/issues?q=${encodeURIComponent(filter + " label:\"" + labelName + "\"")}`; 20 | } 21 | 22 | return ( 23 | 33 | ); 34 | }; 35 | 36 | function getAssigneeURL(assigneeUsername) { 37 | return `https://github.com/issues?q=${encodeURIComponent("assignee:" + assigneeUsername + " is:open")}`; 38 | } 39 | -------------------------------------------------------------------------------- /frontend/components/IssueList.styl: -------------------------------------------------------------------------------- 1 | .issue-list 2 | border: 1px solid #3c790a 3 | width: 800px 4 | padding: 5px 0 0 5px 5 | display: inline-block 6 | 7 | > h1 8 | font-size: 20px 9 | font-weight: bold 10 | margin-bottom: 10px 11 | 12 | .filter 13 | color: #777 14 | font-size: 12px 15 | margin-bottom: 5px 16 | 17 | &:hover 18 | color: #4078C0 19 | 20 | .issue-container 21 | list-style-type: none 22 | margin: 0 23 | padding: 0 24 | 25 | height: 800px 26 | overflow-y: scroll 27 | -------------------------------------------------------------------------------- /frontend/issues.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Implements a subset of filters from https://help.github.com/articles/searching-issues/ 4 | // This may not be a good idea... 5 | 6 | const filters = { 7 | is: issueMatchesIs, 8 | label: issueMatchesLabel, 9 | no: issueMatchesNo, 10 | assignee: issueMatchesAssignee, 11 | comments: issueMatchesComments, 12 | author: issueMatchesAuthor 13 | }; 14 | 15 | exports.filter = (issues, filterString) => { 16 | const filtered = []; 17 | const parsedFilter = parseFilter(filterString); 18 | 19 | issueLoop: for (const issue of issues) { 20 | for (const key of Object.keys(parsedFilter)) { 21 | if (!filters[key](issue, parsedFilter[key])) { 22 | continue issueLoop; 23 | } 24 | } 25 | 26 | filtered.push(issue); 27 | } 28 | 29 | return filtered; 30 | }; 31 | 32 | function issueMatchesIs(issue, isFilter) { 33 | return issueMatchesIsType(issue, isFilter) && issueMatchesIsState(issue, isFilter); 34 | } 35 | 36 | function issueMatchesIsType(issue, isFilter) { 37 | const isPR = Boolean(issue.pull_request); 38 | 39 | const negative = isFilter.negative.filter(value => value === "pr" || value === "issue"); 40 | const positive = isFilter.positive.filter(value => value === "pr" || value === "issue"); 41 | 42 | if (isPR && negative.includes("pr")) { 43 | return false; 44 | } 45 | if (!isPR && negative.includes("issue")) { 46 | return false; 47 | } 48 | 49 | if (isPR && positive.includes("pr")) { 50 | return true; 51 | } 52 | if (!isPR && positive.includes("issue")) { 53 | return true; 54 | } 55 | 56 | return negative.length === 0 && positive.length === 0; 57 | } 58 | 59 | function issueMatchesIsState(issue, isFilter) { 60 | const state = issue.state; 61 | 62 | if (isFilter.negative.includes(state)) { 63 | return false; 64 | } 65 | 66 | if (isFilter.positive.includes(state)) { 67 | return true; 68 | } 69 | 70 | return isFilter.positive.length === 0; 71 | } 72 | 73 | function issueMatchesLabel(issue, labelFilter) { 74 | const labels = issue.labels.map(obj => obj.name); 75 | return multipleMatcherHelper(labels, labelFilter); 76 | } 77 | 78 | function issueMatchesNo(issue, noFilter) { 79 | if (noFilter.negative.length > 0) { 80 | throw new Error("Unexpected negative no filter"); 81 | } 82 | 83 | for (const no of noFilter.positive) { 84 | if (issue[no] !== null) { 85 | return false; 86 | } 87 | } 88 | 89 | return true; 90 | } 91 | 92 | function issueMatchesAssignee(issue, assigneeFilter) { 93 | const assignees = issue.assignees.map(obj => obj.login); 94 | return multipleMatcherHelper(assignees, assigneeFilter); 95 | } 96 | 97 | function issueMatchesComments(issue, commentsFilter) { 98 | const numComments = issue.comments; 99 | 100 | // Only support numbers for now, not >50 or 10..100 syntax 101 | for (const pos of commentsFilter.positive) { 102 | if (numComments !== Number(pos)) { 103 | return false; 104 | } 105 | } 106 | 107 | for (const neg of commentsFilter.negative) { 108 | if (numComments === Number(neg)) { 109 | return false; 110 | } 111 | } 112 | 113 | return true; 114 | } 115 | 116 | function issueMatchesAuthor(issue, authorFilter) { 117 | const author = issue.user.login; 118 | // NOTE: GitHub does *not* appear to work this way for author... 119 | return multipleMatcherHelper([author], authorFilter); 120 | } 121 | 122 | function multipleMatcherHelper(candidates, filter) { 123 | if (filter.negative.length > 0 && candidates.length > 0 && arraysIntersect(filter.negative, candidates)) { 124 | return false; 125 | } 126 | if ((filter.positive.length === 0 || candidates.length > 0) && arraysIntersect(filter.positive, candidates)) { 127 | return true; 128 | } 129 | 130 | return false; 131 | } 132 | 133 | function parseFilter(filterString) { 134 | const pieceRegexp = /(-?)([a-z]+):([^ "]+|"[^"]+")/g; 135 | 136 | const parsed = Object.create(null); 137 | 138 | let result; 139 | while ((result = pieceRegexp.exec(filterString))) { 140 | const [isNegation, type, value] = [result[1] === "-", result[2], removeOuterQuotes(result[3])]; 141 | 142 | if (!(type in parsed)) { 143 | parsed[type] = { 144 | positive: [], 145 | negative: [] 146 | }; 147 | } 148 | 149 | parsed[type][isNegation ? "negative" : "positive"].push(value); 150 | } 151 | 152 | return parsed; 153 | } 154 | 155 | function arraysIntersect(array1, array2) { 156 | for (const el1 of array1) { 157 | for (const el2 of array2) { 158 | if (el1 === el2) { 159 | return true; 160 | } 161 | } 162 | } 163 | 164 | return array1.length === 0 || array2.length === 0; 165 | } 166 | 167 | function removeOuterQuotes(string) { 168 | if (string.startsWith("\"") && string.endsWith("\"")) { 169 | return string.substring(1, string.length - 1); 170 | } 171 | 172 | return string; 173 | } 174 | -------------------------------------------------------------------------------- /frontend/main.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const React = require("react"); 3 | const reactRedux = require("react-redux"); 4 | const { Provider } = require("react-redux"); 5 | const reactDOM = require("react-dom"); 6 | const redux = require("redux"); 7 | const reduxThunk = require("redux-thunk").default; 8 | 9 | const reducers = require("./reducers.js"); 10 | const App = require("./App.jsx"); 11 | 12 | navigator.serviceWorker.register("service-worker-bundle.js"); 13 | 14 | const store = redux.createStore(reducers, undefined, redux.applyMiddleware(reduxThunk)); 15 | 16 | reactDOM.render( 17 | 18 | 19 | , 20 | document.querySelector("main") 21 | ); 22 | -------------------------------------------------------------------------------- /frontend/main.styl: -------------------------------------------------------------------------------- 1 | :root 2 | font-family: Helvetica, Arial 3 | box-sizing: border-box 4 | color: #333 5 | 6 | * 7 | box-sizing: inherit 8 | 9 | p, h1, h2, h3, h4, h5, h6 10 | margin: 0 11 | 12 | body, h1, h2, h3, h4, h5, h6 13 | font-size: 1rem 14 | 15 | h1, h2, h3, h4, h5, h6 16 | font-weight: normal 17 | 18 | ul 19 | list-style-type: none 20 | margin: 0 21 | padding: 0 22 | 23 | a 24 | text-decoration: none 25 | color: #333 26 | 27 | &:hover 28 | color: #4078C0 29 | 30 | body 31 | margin: 1em 32 | -------------------------------------------------------------------------------- /frontend/reducers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const config = require("../config.json"); 3 | const cookies = require("cookies-js"); 4 | const combineReducers = require("redux").combineReducers; 5 | 6 | module.exports = combineReducers({ 7 | gitHub, 8 | issues 9 | }); 10 | 11 | function gitHub(state, action) { 12 | if (state === undefined) { 13 | return { 14 | signingIn: false, 15 | username: null, 16 | accessToken: cookies.get(config.gitHub.cookie) 17 | }; 18 | } 19 | 20 | switch (action.type) { 21 | case "begin GitHub sign in": { 22 | return Object.assign({}, state, { 23 | signingIn: true, 24 | username: null, 25 | accessToken: null 26 | }); 27 | } 28 | 29 | case "receive GitHub access token": { 30 | return Object.assign({}, state, { 31 | signingIn: false, 32 | username: null, 33 | accessToken: action.accessToken 34 | }); 35 | } 36 | 37 | case "issues loaded": { 38 | return Object.assign({}, state, { 39 | username: action.data.username 40 | }); 41 | } 42 | 43 | default: { 44 | return state; 45 | } 46 | } 47 | } 48 | 49 | function issues(state, action) { 50 | if (state === undefined) { 51 | // Consider loading from cache? 52 | return []; 53 | } 54 | 55 | switch (action.type) { 56 | case "issues loaded": { 57 | return action.data.issues; 58 | } 59 | 60 | default: { 61 | return state; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const gulp = require("gulp"); 3 | const gulpStylus = require("gulp-stylus"); 4 | const gulpConcat = require("gulp-concat"); 5 | const vinylSourceStream = require("vinyl-source-stream"); 6 | const browserify = require("browserify"); 7 | const rimraf = require("rimraf"); 8 | 9 | const OUTPUT = "www/"; 10 | 11 | const FRONTEND = "frontend"; 12 | const FRONTEND_WATCH = FRONTEND + "/**/*.{js,jsx}"; 13 | const FRONTEND_ENTRY = FRONTEND + "/main.jsx"; 14 | const FRONTEND_OUTPUT = "bundle.js"; 15 | 16 | const SW = "service-worker"; 17 | const SW_WATCH = SW + "/**/*.js"; 18 | const SW_ENTRY = SW + "/service-worker.js"; 19 | const SW_OUTPUT = "service-worker-bundle.js"; 20 | 21 | const STYLUS = FRONTEND + "/**/*.styl"; 22 | const STYLUS_WATCH = STYLUS; 23 | const STYLUS_OUTPUT = "styles.css"; 24 | 25 | gulp.task("stylus", () => 26 | gulp.src(STYLUS) 27 | .pipe(gulpStylus()) 28 | .pipe(gulpConcat(STYLUS_OUTPUT)) 29 | .pipe(gulp.dest(OUTPUT)) 30 | ); 31 | 32 | gulp.task("frontend", () => 33 | browserify({ 34 | entries: FRONTEND_ENTRY, 35 | debug: true 36 | }) 37 | .transform("babelify", { presets: ["react"] }) 38 | .bundle() 39 | .pipe(vinylSourceStream(FRONTEND_OUTPUT)) 40 | .pipe(gulp.dest(OUTPUT)) 41 | ); 42 | 43 | gulp.task("service worker", () => 44 | browserify({ 45 | entries: SW_ENTRY, 46 | debug: true 47 | }) 48 | .bundle() 49 | .pipe(vinylSourceStream(SW_OUTPUT)) 50 | .pipe(gulp.dest(OUTPUT)) 51 | ); 52 | 53 | gulp.task("build", ["frontend", "service worker", "stylus"]); 54 | 55 | gulp.task("watch", ["build"], () => { 56 | gulp.watch(FRONTEND_WATCH, ["frontend"]); 57 | gulp.watch(SW_WATCH, ["service worker"]); 58 | gulp.watch(STYLUS_WATCH, ["stylus"]); 59 | }); 60 | 61 | gulp.task("clean", () => { 62 | rimraf.sync(OUTPUT + FRONTEND_OUTPUT); 63 | rimraf.sync(OUTPUT + SW_OUTPUT); 64 | }); 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-dashboard", 3 | "private": true, 4 | "description": "A dashboard for issue and pull request management in whatwg/html", 5 | "keywords": [], 6 | "version": "1.0.0-pre", 7 | "author": "Domenic Denicola (https://domenic.me/)", 8 | "license": "WTFPL", 9 | "repository": "domenic/html-dashboard", 10 | "scripts": { 11 | "test": "mocha", 12 | "lint": "eslint .", 13 | "build": "gulp build", 14 | "watch": "gulp watch", 15 | "gulp": "gulp", 16 | "start": "node server/server.js" 17 | }, 18 | "dependencies": { 19 | "colr": "^1.2.2", 20 | "cookies-js": "^1.2.2", 21 | "koa-no-cache": "^1.1.0", 22 | "parse-link-header": "^0.4.1", 23 | "react": "^15.1.0", 24 | "react-dom": "^15.1.0", 25 | "react-redux": "^4.4.5", 26 | "redux": "^3.5.2", 27 | "redux-thunk": "^2.1.0", 28 | "relative-date": "^1.1.3", 29 | "sw-toolbox": "^3.2.1" 30 | }, 31 | "devDependencies": { 32 | "babel-preset-react": "^6.5.0", 33 | "babelify": "^7.3.0", 34 | "browserify": "^13.0.1", 35 | "eslint": "^2.13.1", 36 | "gulp": "^3.9.1", 37 | "gulp-concat": "^2.6.0", 38 | "gulp-stylus": "^2.4.0", 39 | "mocha": "^2.5.3", 40 | "rimraf": "^2.5.2", 41 | "vinyl-source-stream": "^1.1.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comic-reader-server", 3 | "private": true, 4 | "description": "An experimental comic book reader's server", 5 | "version": "1.0.0-pre", 6 | "author": "Domenic Denicola (https://domenic.me/)", 7 | "license": "WTFPL", 8 | "scripts": { 9 | "server": "node server.js" 10 | }, 11 | "dependencies": { 12 | "http-errors": "^1.3.1", 13 | "koa": "^1.1.2", 14 | "koa-router": "^5.3.0", 15 | "koa-static": "^1.5.2", 16 | "requisition": "^1.5.1" 17 | }, 18 | "devDependencies": {} 19 | } 20 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const path = require("path"); 3 | const koa = require("koa"); 4 | const koaRouter = require("koa-router"); 5 | const koaStatic = require("koa-static"); 6 | const koaNoCache = require("koa-no-cache"); 7 | const requisition = require("requisition"); 8 | const httpError = require("http-errors"); 9 | const config = require("../config.json"); 10 | const privateConfig = require("../config-private.json"); 11 | 12 | const ROOT = path.resolve(__dirname, "../www"); 13 | 14 | const ONE_YEAR = 31536000000; 15 | 16 | const app = koa(); 17 | const router = koaRouter(); 18 | 19 | router.get(config.gitHub.redirectRoute, function* () { 20 | /* eslint-disable no-invalid-this */ // seems to be a bug? 21 | 22 | handleGitHubOAuthError(this.request.query); 23 | 24 | /* eslint-disable camelcase */ 25 | const response = yield requisition.post("https://github.com/login/oauth/access_token") 26 | .type("application/x-www-form-urlencoded") 27 | .set("Accept", "application/json") 28 | .send({ 29 | grant_type: "authorization_code", 30 | client_id: config.gitHub.clientId, 31 | redirect_uri: config.gitHub.redirectURL, 32 | client_secret: privateConfig.gitHub.clientSecret, 33 | code: this.request.query.code 34 | }); 35 | /* eslint-enable camelcase */ 36 | 37 | const responseJSON = yield response.json(); 38 | handleGitHubOAuthError(responseJSON); 39 | 40 | this.cookies.set(config.gitHub.cookie, responseJSON.access_token, { 41 | expires: new Date(Date.now() + ONE_YEAR), 42 | httpOnly: false 43 | }); 44 | 45 | this.body = ` 46 | `; 47 | }); 48 | 49 | function handleGitHubOAuthError(response) { 50 | if (response.error) { 51 | throw httpError(response.error_description, { code: response.error }); 52 | } 53 | } 54 | 55 | app.use(koaNoCache({ paths: ["/service-worker-bundle.js"] })) 56 | .use(koaStatic(ROOT)) 57 | .use(router.routes()) 58 | .use(router.allowedMethods()) 59 | .listen(config.port); 60 | -------------------------------------------------------------------------------- /service-worker/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc.json", 3 | "env": { 4 | "browser": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /service-worker/github.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function gitHubRequest(pathOrURL, token) { 4 | if (!pathOrURL.startsWith("https:")) { 5 | pathOrURL = "https://api.github.com/" + pathOrURL; 6 | } 7 | 8 | return fetch(pathOrURL, { 9 | headers: { 10 | Authorization: "token " + token, 11 | // https://developer.github.com/changes/2016-5-27-multiple-assignees/ 12 | Accept: "application/vnd.github.cerberus-preview" 13 | } 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /service-worker/service-worker.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const swToolbox = require("sw-toolbox"); 3 | const parseLinkHeader = require("parse-link-header"); 4 | const gitHub = require("./github.js"); 5 | const config = require("../config.json"); 6 | 7 | const CACHE_NAME = "v2"; 8 | 9 | swToolbox.router.get("/issues.json", request => { 10 | const accessToken = (new URL(request.url)).searchParams.get("token"); 11 | 12 | return caches.match(request) 13 | .then(res => res || fetchAndCache(fetchIssues, request, accessToken)); 14 | }); 15 | 16 | swToolbox.router.get("/user.json", request => { 17 | const accessToken = (new URL(request.url)).searchParams.get("token"); 18 | 19 | return caches.match(request) 20 | .then(res => res || fetchAndCache(fetchUser, request, accessToken)); 21 | }); 22 | 23 | function fetchAndCache(fetcher, request, accessToken) { 24 | return fetcher(accessToken).then(response => 25 | caches.open(CACHE_NAME).then(cache => { 26 | cache.put(request, response.clone()); 27 | return response; 28 | }) 29 | ); 30 | } 31 | 32 | function fetchIssues(accessToken) { 33 | return gitHub(`repos/${config.repo}/issues?state=all`, accessToken).then(firstResponse => { 34 | const links = parseLinkHeader(firstResponse.headers.get("link")); 35 | const nextPage = Number(links.next.page); 36 | const lastPage = Number(links.last.page); 37 | 38 | const subsequentPageURLs = []; 39 | for (let i = nextPage; i <= lastPage; ++i) { 40 | const url = new URL(links.next.url); 41 | url.searchParams.set("page", i); 42 | subsequentPageURLs.push(url.href); 43 | } 44 | 45 | const subsequentJSONPromises = subsequentPageURLs.map(url => gitHub(url, accessToken).then(res => res.json())); 46 | 47 | const jsonPromises = [firstResponse.json(), ...subsequentJSONPromises]; 48 | return Promise.all(jsonPromises); 49 | }) 50 | .then(jsons => jsonResponse(flattenArray(jsons))); 51 | } 52 | 53 | function fetchUser(accessToken) { 54 | return gitHub("user", accessToken); 55 | } 56 | 57 | function jsonResponse(obj) { 58 | return new Response(JSON.stringify(obj, undefined, 2), { headers: { "Content-Type": "application/json" } }); 59 | } 60 | 61 | function flattenArray(array) { 62 | const result = []; 63 | for (const subarray of array) { 64 | result.push(...subarray); 65 | } 66 | return result; 67 | } 68 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | whatwg/html dashboard 4 | 5 | 6 | 7 |
    8 |
    9 | 10 | --------------------------------------------------------------------------------