├── .gitignore ├── README.md ├── docs ├── edit.png ├── filter-milestone.gif ├── query.png ├── submit-pull.gif ├── ticket.png ├── timeline.png └── upload.gif ├── index.js ├── package.json ├── proxy.js ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── App.test.js ├── actions.js ├── components │ ├── Attachment.js │ ├── AttachmentUpload.css │ ├── AttachmentUpload.js │ ├── Avatar.css │ ├── Avatar.js │ ├── Board.css │ ├── Board.js │ ├── Button.css │ ├── Button.js │ ├── CodeBlock.js │ ├── Comment.css │ ├── Comment.js │ ├── CommentContent.css │ ├── CommentContent.js │ ├── CommentEditor.css │ ├── CommentEditor.js │ ├── CommentHeader.css │ ├── CommentHeader.js │ ├── CommentMeta.css │ ├── CommentMeta.js │ ├── DropList.css │ ├── DropList.js │ ├── DropSelect.css │ ├── DropSelect.js │ ├── Dropdown.css │ ├── Dropdown.js │ ├── Error.js │ ├── Footer.css │ ├── Footer.js │ ├── FormattedText.css │ ├── FormattedText.js │ ├── Header.css │ ├── Header.js │ ├── ListTable.css │ ├── ListTable.js │ ├── ListTableItem.css │ ├── ListTableItem.js │ ├── Loading.css │ ├── Loading.js │ ├── Login.css │ ├── Login.js │ ├── PullRequests.css │ ├── PullRequests.js │ ├── Query.css │ ├── Query.js │ ├── QueryHeader.css │ ├── QueryHeader.js │ ├── SlackMention.js │ ├── Spinner.css │ ├── Spinner.js │ ├── Tag.css │ ├── Tag.js │ ├── Ticket.css │ ├── Ticket.js │ ├── TicketChanges.css │ ├── TicketChanges.js │ ├── TicketList.js │ ├── TicketListItem.css │ ├── TicketListItem.js │ ├── TicketState.css │ ├── TicketState.js │ ├── TicketStatus.css │ ├── TicketStatus.js │ ├── TicketUpdate.css │ ├── TicketUpdate.js │ ├── Time.js │ ├── Timeline.css │ ├── Timeline.js │ ├── TimelineEvent.css │ ├── TimelineEvent.js │ └── UserLink.js ├── containers │ ├── Attachment.js │ ├── AttachmentUpload.js │ ├── Board.js │ ├── Query.js │ ├── Summary.css │ ├── Summary.js │ ├── Ticket.js │ ├── TicketTimeline.js │ └── selectors │ │ ├── LabelSelect.js │ │ └── MilestoneSelect.js ├── index.css ├── index.js ├── lib │ ├── text-formatter.js │ ├── text-parser.js │ ├── trac.js │ └── workflow.js ├── logo.svg ├── reducers │ ├── components.js │ ├── index.js │ ├── prs.js │ ├── query.js │ ├── tickets.js │ └── user.js ├── registerServiceWorker.js └── slack.svg └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /proxy/vendor 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Not Trac 2 | 3 | This project isn't Trac. 4 | 5 |

→ View Not Trac

6 | 7 | ## Disclaimer 8 | 9 | This connects to WordPress.org's Trac instance via XML-RPC, and hence requires your username and password. You shouldn't trust my code, and you should carefully audit all code that handles your username and password before entering it into this application. 10 | 11 | This especially applies if you're a committer. Your WordPress.org password controls your commit access to 28% of the web, be extremely cautious with it. 12 | 13 | **THERE IS ZERO SUPPORT PROVIDED FOR THIS INTERFACE**. This is something I made for my own use. If it's useful to you, great; if not, also great. 14 | 15 | ## Features 16 | 17 | Not Trac is a React app built from the ground up for me. I hate waiting around for slow things, and I want lots of information at a glance. You might find it useful too. 18 | 19 | ### Query, then filter and sort 20 | 21 | View the list of tickets and their status, and browse with fast pagination. 22 | 23 | 24 | 25 | Filter by component, label, and milestone, and re-sort in a snap. 26 | 27 | 28 | 29 | 30 | ### See important changes at a glance 31 | 32 | The ticket header and summary give you the rundown on tickets at first glance. 33 | 34 | 35 | 36 | The ticket timeline view highlights important events. 37 | 38 | 39 | 40 | 41 | ### Comment with instant previews 42 | 43 | Leave a comment just like you would on Trac, and preview it instantly (no round-trip to the server). 44 | 45 | 46 | 47 | 48 | ### Upload attachments, or use pull requests 49 | 50 | Upload attachments just like you would on Trac. 51 | 52 | 53 | 54 | First-class pull request support is available, freeing up your time to work on the important things. 55 | 56 | 57 | 58 | 59 | ## Running Locally 60 | 61 | ```sh 62 | # Clone 63 | git clone https://github.com/rmccue/not-trac.git 64 | cd not-trac 65 | 66 | # Install all dependencies: 67 | npm install 68 | 69 | # Run the following two simultaneously (maybe in separate terminal sessions): 70 | npm run start 71 | ``` 72 | 73 | ## License 74 | 75 | Uh... ISC I guess? 76 | -------------------------------------------------------------------------------- /docs/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/edit.png -------------------------------------------------------------------------------- /docs/filter-milestone.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/filter-milestone.gif -------------------------------------------------------------------------------- /docs/query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/query.png -------------------------------------------------------------------------------- /docs/submit-pull.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/submit-pull.gif -------------------------------------------------------------------------------- /docs/ticket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/ticket.png -------------------------------------------------------------------------------- /docs/timeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/timeline.png -------------------------------------------------------------------------------- /docs/upload.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/upload.gif -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const express = require( 'express' ); 2 | const proxy = require( './proxy' ); 3 | const basicAuth = require( 'express-basic-auth' ); 4 | const bodyParser = require('body-parser'); 5 | const app = express(); 6 | 7 | const PORT = parseInt( process.env.PORT || '3090', 10 ); 8 | 9 | const corsMiddleware = ( req, res, next ) => { 10 | const origin = req.get( 'Origin' ); 11 | if ( origin ) { 12 | res.set( 'Access-Control-Allow-Origin', origin.replace( /\r|\n/, '' ) ); 13 | } 14 | 15 | res.set( 'Access-Control-Allow-Headers', 'Authorization, Content-Type' ); 16 | res.set( 'Access-Control-Allow-Methods', 'POST, OPTIONS' ); 17 | res.set( 'Access-Control-Allow-Credentials', 'true' ); 18 | res.set( 'Vary', 'Origin' ); 19 | 20 | next(); 21 | }; 22 | 23 | app.post( 24 | '/proxy', 25 | basicAuth( { 26 | authorizer: () => true, 27 | unauthorizedResponse: () => ( { message: 'Username and password are required' } ), 28 | } ), 29 | bodyParser.json(), 30 | corsMiddleware, 31 | proxy 32 | ); 33 | 34 | app.options( 35 | '/proxy', 36 | corsMiddleware, 37 | ( req, res ) => { 38 | res.status( 200 ); 39 | res.send( "Let's go.\n" ); 40 | } 41 | ); 42 | 43 | app.use( express.static( __dirname + '/build' ) ); 44 | app.get( '/*', (req, res) => { 45 | res.sendFile( __dirname + '/build/index.html' ); 46 | } ); 47 | 48 | app.use( ( err, req, res, next ) => { 49 | if ( err instanceof proxy.HttpError ) { 50 | const data = Object.assign( {}, err.data, { error: true } ); 51 | 52 | res.status( err.code ); 53 | res.set( 'Content-Type', 'application/json' ); 54 | res.send( JSON.stringify( data ) ); 55 | return next(); 56 | } 57 | 58 | next( err ); 59 | } ); 60 | 61 | app.listen( PORT, () => console.log( `Trac proxy listening on port ${ PORT }` ) ); 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "less-terrible-trac", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "now": "^8.5.4", 7 | "react-scripts": "1.0.10" 8 | }, 9 | "dependencies": { 10 | "base64-js": "^1.2.1", 11 | "body-parser": "^1.18.2", 12 | "bytes": "^2.5.0", 13 | "express": "^4.16.2", 14 | "express-basic-auth": "^1.1.3", 15 | "moment": "^2.18.1", 16 | "npm-run-all": "^4.1.2", 17 | "prismjs": "^1.6.0", 18 | "prop-types": "^15.5.10", 19 | "query-string": "^5.0.0", 20 | "react": "^15.6.1", 21 | "react-document-title": "^2.0.3", 22 | "react-dom": "^15.6.1", 23 | "react-redux": "^5.0.5", 24 | "react-router-dom": "^4.1.2", 25 | "react-router-modal": "^1.1.13", 26 | "redux": "^3.7.2", 27 | "redux-thunk": "^2.2.0", 28 | "simple-text-parser": "^1.0.0", 29 | "string-hash": "^1.1.3", 30 | "xmlrpc": "^1.3.2" 31 | }, 32 | "scripts": { 33 | "start": "run-p start-proxy start-js", 34 | "start-js": "react-scripts start", 35 | "start-proxy": "node index.js", 36 | "now-start": "PORT=80 node index.js", 37 | "build": "react-scripts build", 38 | "test": "react-scripts test --env=jsdom", 39 | "eject": "react-scripts eject", 40 | "deploy": "now" 41 | }, 42 | "proxy": "http://localhost:3090", 43 | "now": { 44 | "alias": "not-trac.rmccue.io" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /proxy.js: -------------------------------------------------------------------------------- 1 | const xmlrpc = require( 'xmlrpc' ); 2 | 3 | const HttpError = function ( data, code = 500 ) { 4 | this.data = data; 5 | this.code = code; 6 | }; 7 | 8 | const replacer = ( key, value ) => { 9 | if ( Buffer.isBuffer( value ) ) { 10 | return value.toString(); 11 | } else if ( typeof value === 'object' && value.type === 'Buffer' ) { 12 | const buffer = Buffer.from( value.data ); 13 | return buffer.toString(); 14 | } 15 | 16 | return value; 17 | }; 18 | 19 | module.exports = ( req, res ) => { 20 | const data = req.body; 21 | 22 | const missing = [ 'method', 'parameters' ].filter( key => ! ( key in req.body ) ); 23 | if ( missing.length ) { 24 | throw new HttpError( 25 | { 26 | message: 'Missing required parameter.', 27 | param: missing.join( ',' ), 28 | }, 29 | 400 30 | ); 31 | } 32 | 33 | const client = xmlrpc.createSecureClient( { 34 | url: 'https://core.trac.wordpress.org/login/xmlrpc', 35 | basic_auth: { 36 | user: req.auth.user, 37 | pass: req.auth.password, 38 | } 39 | } ); 40 | 41 | const types = data.types || {}; 42 | let params; 43 | try { 44 | params = data.parameters.map( ( parameter, idx ) => { 45 | const type = types[ idx ]; 46 | if ( ! type ) { 47 | return parameter; 48 | } 49 | 50 | switch ( type ) { 51 | case 'base64': 52 | return Buffer.from( parameter ); 53 | 54 | default: 55 | throw new HttpError( { 56 | message: `Invalid type ${ type } for parameter ${ parameter }`, 57 | param: parameter, 58 | } ); 59 | } 60 | } ); 61 | } catch ( e ) { 62 | throw new HttpError( { 63 | message: e.message, 64 | }, 400 ); 65 | } 66 | client.methodCall( data.method, data.parameters, ( err, value ) => { 67 | if ( err ) { 68 | throw new HttpError( { 69 | message: `Received an error from Trac`, 70 | data: err, 71 | } ); 72 | } 73 | res.set( 'Content-Type', 'application/json' ); 74 | res.send( JSON.stringify( value, replacer ) ); 75 | } ); 76 | }; 77 | 78 | module.exports.HttpError = HttpError; 79 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | } 3 | .App .App-modal-backdrop { 4 | background-color: rgba(50, 55, 60, 0.4); 5 | } 6 | .App .App-modal { 7 | border: none; 8 | box-shadow: 0 0 1px #0073aa; 9 | } 10 | 11 | .App .Header .Dropdown-content { 12 | color: #32373c; 13 | } 14 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import qs from 'query-string'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Link, Redirect, BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 5 | import { ModalContainer } from 'react-router-modal'; 6 | 7 | import { reset_user_credentials, set_user_credentials } from './actions'; 8 | import Avatar from './components/Avatar'; 9 | import DropList from './components/DropList'; 10 | import Footer from './components/Footer'; 11 | import Header from './components/Header'; 12 | import Login from './components/Login'; 13 | 14 | import Attachment from './containers/Attachment'; 15 | import Board from './containers/Board'; 16 | import Query from './containers/Query'; 17 | import Summary from './containers/Summary'; 18 | import Ticket from './containers/Ticket'; 19 | 20 | import './App.css'; 21 | import 'react-router-modal/css/react-router-modal.css'; 22 | 23 | class App extends React.Component { 24 | constructor( props ) { 25 | super( props ); 26 | 27 | this.state = { 28 | user: null, 29 | }; 30 | } 31 | 32 | onLogin( user, remember ) { 33 | this.props.dispatch( set_user_credentials( user ) ); 34 | 35 | if ( remember ) { 36 | localStorage.setItem( 'trac-auth', JSON.stringify( user ) ); 37 | } 38 | } 39 | 40 | onLogOut() { 41 | localStorage.removeItem( 'trac-auth' ); 42 | 43 | this.props.dispatch( reset_user_credentials() ); 44 | } 45 | 46 | render() { 47 | const { dispatch, user } = this.props; 48 | 49 | if ( ! user.username ) { 50 | return 51 |
52 |
55 |
56 | this.onLogin( user, remember ) } 58 | /> 59 |
60 |
62 |
; 63 | } 64 | 65 | return 66 |
67 |
71 | { user ? 72 |
    73 |
  • 74 | Components 75 |
  • 76 |
  • 77 | 80 | 81 | @{ user.username } 82 |
  • 83 | } 84 | > 85 |
  • 86 | View profile 89 |
  • 90 |
  • 91 | 95 |
  • 96 | 97 | 98 |
99 | : null } 100 |
101 |
102 | 103 | 108 | ( 112 | 115 | )} 116 | /> 117 | ( 121 | 124 | )} 125 | /> 126 | { 130 | console.log( qs.parse( location.search ) ); 131 | return Testing! 132 | }} 133 | /> 134 | ( 138 | 146 | )} 147 | /> 148 | } 152 | /> 153 | ( 157 | 161 | )} 162 | /> 163 | ( 164 |
165 |

404

166 |

No route matches this URL.

167 |
168 | )} /> 169 |
170 |
171 |
177 |
; 178 | } 179 | } 180 | 181 | export default connect( 182 | ({ user }) => ({ user }) 183 | )( App ); 184 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | export const PUSH_ATTACHMENT = 'PUSH_ATTACHMENT'; 2 | export const PUSH_TICKET_CHANGE = 'PUSH_TICKET_CHANGE'; 3 | export const RECEIVE_PRS = 'RECEIVE_PRS'; 4 | export const REQUEST_PRS = 'REQUEST_PRS'; 5 | export const RESET_USER_CREDENTIALS = 'RESET_USER_CREDENTIALS'; 6 | export const SET_COMPONENTS = 'SET_COMPONENTS'; 7 | export const SET_QUERY_PARAMS = 'SET_QUERY_PARAMS'; 8 | export const SET_QUERY_RESULTS = 'SET_QUERY_RESULTS'; 9 | export const SET_TICKET_ATTACHMENTS = 'SET_TICKET_ATTACHMENTS'; 10 | export const SET_TICKET_CHANGES = 'SET_TICKET_CHANGES'; 11 | export const SET_TICKET_DATA = 'SET_TICKET_DATA'; 12 | export const SET_USER_CREDENTIALS = 'SET_USER_CREDENTIALS'; 13 | 14 | export function push_attachment( id, attachment ) { 15 | return { type: PUSH_ATTACHMENT, id, attachment }; 16 | } 17 | 18 | export function push_ticket_change( id, change ) { 19 | return { type: PUSH_TICKET_CHANGE, id, change }; 20 | } 21 | 22 | export function reset_user_credentials() { 23 | return { type: RESET_USER_CREDENTIALS }; 24 | } 25 | 26 | export function set_components( components ) { 27 | return { type: SET_COMPONENTS, components }; 28 | } 29 | 30 | export function set_query_params( params ) { 31 | return { type: SET_QUERY_PARAMS, params }; 32 | } 33 | 34 | export function set_query_results( results ) { 35 | return { type: SET_QUERY_RESULTS, results }; 36 | } 37 | 38 | export function set_ticket_attachments( id, attachments ) { 39 | return { type: SET_TICKET_ATTACHMENTS, id, attachments }; 40 | } 41 | 42 | export function set_ticket_changes( id, changes ) { 43 | return { type: SET_TICKET_CHANGES, id, changes }; 44 | } 45 | 46 | export function set_ticket_data( id, data ) { 47 | return { type: SET_TICKET_DATA, id, data }; 48 | } 49 | 50 | export function set_user_credentials( user ) { 51 | return { type: SET_USER_CREDENTIALS, user }; 52 | } 53 | 54 | export function update_prs() { 55 | return (dispatch, getStore) => { 56 | dispatch( { type: REQUEST_PRS } ); 57 | 58 | fetch( 'https://api.github.com/repos/WordPress/wordpress-develop/pulls?state=all' ) 59 | .then( resp => resp.json() ) 60 | .then( data => { 61 | dispatch({ 62 | type: RECEIVE_PRS, 63 | prs: data, 64 | }); 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Attachment.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import CodeBlock from '../components/CodeBlock'; 5 | import Loading from './Loading'; 6 | 7 | const guessLanguage = filename => { 8 | const parts = filename.split( '.' ); 9 | const ext = parts.pop(); 10 | 11 | switch ( ext ) { 12 | case 'diff': 13 | case 'patch': 14 | return 'diff'; 15 | 16 | case 'php': 17 | return 'php'; 18 | 19 | default: 20 | return 'diff'; 21 | } 22 | }; 23 | 24 | export default class Attachment extends React.Component { 25 | render() { 26 | const { data, id, isLoading, ticket } = this.props; 27 | 28 | const lang = guessLanguage( id ); 29 | 30 | return
31 |
32 |

33 | 34 | #{ ticket } 35 | 36 | { ': ' } 37 | { id } 38 |

39 |
40 | 41 | { isLoading ? 42 | 43 | : 44 | { data } 45 | } 46 |
; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/AttachmentUpload.css: -------------------------------------------------------------------------------- 1 | .AttachmentUpload.dropping { 2 | border: 2px dashed #b4b9be; 3 | border-radius: 3px; 4 | height: 4rem; 5 | position: relative; 6 | } 7 | .AttachmentUpload.dropping:before { 8 | position: absolute; 9 | left: 0; 10 | right: 0; 11 | bottom: 0; 12 | top: 0; 13 | 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | 18 | content: "Drop files here"; 19 | font-size: 1rem; 20 | color: #b4b9be; 21 | } 22 | .AttachmentUpload.dropping > .buttons { 23 | display: none; 24 | } 25 | .AttachmentUpload > .buttons { 26 | display: flex; 27 | align-items: center; 28 | margin-top: -2px; 29 | } 30 | .AttachmentUpload .Button { 31 | margin-right: 0.5em; 32 | } 33 | .AttachmentUpload-uploader > input[type=file] { 34 | display: none; 35 | } 36 | 37 | .AttachmentUpload-description { 38 | padding: 7px; 39 | width: calc( 100% - 7px * 2 ); 40 | font: inherit; 41 | border: 1px solid #bfe7f3; 42 | } 43 | 44 | .AttachmentUpload-progress > span { 45 | margin-left: 1rem; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/AttachmentUpload.js: -------------------------------------------------------------------------------- 1 | import base64 from 'base64-js'; 2 | import bytes from 'bytes'; 3 | import React from 'react'; 4 | import { Modal } from 'react-router-modal'; 5 | 6 | import Button from './Button'; 7 | import PullRequests from './PullRequests'; 8 | import Spinner from './Spinner'; 9 | 10 | import './AttachmentUpload.css'; 11 | 12 | const INITIAL_STATE = { 13 | description: '', 14 | dropping: false, 15 | file: null, 16 | licenseAgree: false, 17 | progress: 0, 18 | showingPullSelector: false, 19 | uploading: false, 20 | uploadMessage: '', 21 | }; 22 | 23 | export default class AttachmentUpload extends React.PureComponent { 24 | constructor( props ) { 25 | super( props ); 26 | 27 | this.state = { ...INITIAL_STATE }; 28 | } 29 | 30 | onUpload() { 31 | const { file } = this.state; 32 | 33 | const reader = new FileReader(); 34 | 35 | reader.onprogress = e => { 36 | // e is an ProgressEvent. 37 | if ( ! e.lengthComputable ) { 38 | return; 39 | } 40 | 41 | const progress = Math.round( ( e.loaded / e.total ) * 100 ); 42 | this.setState({ progress }); 43 | }; 44 | 45 | reader.onabort = function(e) { 46 | this.setState({ 47 | uploading: false, 48 | }); 49 | alert('File read cancelled'); 50 | }; 51 | 52 | reader.onload = e => { 53 | this.setState({ 54 | progress: 100, 55 | }); 56 | 57 | const { description } = this.state; 58 | const bufferView = new Uint8Array( reader.result ); 59 | const data = base64.fromByteArray( bufferView ); 60 | 61 | this.props.onUpload({ 62 | data, 63 | description, 64 | filename: file.name, 65 | }); 66 | 67 | // Reset state. 68 | this.setState({ ...INITIAL_STATE }); 69 | }; 70 | 71 | // Read in the file as a binary string. 72 | reader.readAsArrayBuffer( file ); 73 | 74 | this.setState({ 75 | progress: 0, 76 | uploading: true, 77 | uploadMessage: 'Reading file…', 78 | }); 79 | } 80 | 81 | onSelectPull( pull, file ) { 82 | const description = `${ pull.title } (From ${ pull.html_url })`; 83 | 84 | this.setState({ 85 | description, 86 | file, 87 | }); 88 | } 89 | 90 | onDragOver( e ) { 91 | e.preventDefault(); 92 | 93 | // Explicitly show this is a copy. 94 | e.dataTransfer.dropEffect = 'copy'; 95 | 96 | this.setState({ dropping: true }); 97 | } 98 | 99 | onDragLeave( e ) { 100 | e.preventDefault(); 101 | 102 | this.setState({ dropping: false }); 103 | } 104 | 105 | onDrop( e ) { 106 | e.preventDefault(); 107 | 108 | // If there's no files, ignore it. 109 | if ( ! e.dataTransfer.files.length ) { 110 | this.setState({ dropping: false }); 111 | return; 112 | } 113 | 114 | this.setState({ 115 | file: e.dataTransfer.files[0], 116 | dropping: false 117 | }); 118 | } 119 | 120 | render() { 121 | const { prs, ticket } = this.props; 122 | const { file, licenseAgree } = this.state; 123 | 124 | if ( ! file ) { 125 | return
this.onDragOver( e ) } 128 | onDragLeave={ e => this.onDragLeave( e ) } 129 | onDrop={ e => this.onDrop( e ) } 130 | > 131 |

132 | 139 | { prs ? 140 | 145 | : null } 146 | (Or drop files here.) 147 |

148 | 149 | { prs && this.state.showingPullSelector ? 150 | 151 | this.setState({ showingPullSelector: false }) } 156 | onReload={ this.props.onLoadPulls } 157 | onSelect={ ( pull, file ) => this.onSelectPull( pull, file ) } 158 | /> 159 | 160 | : null } 161 |
; 162 | } 163 | 164 | return
165 |

Uploading { file.name } ({ bytes( file.size ) }):

166 | { this.state.uploading ? 167 |

168 | 172 | 173 | 174 | 175 | 176 | { this.state.uploadMessage } 177 | 178 |

179 | : 180 |
181 |

182 | this.setState({ description: e.target.value }) } 188 | /> 189 |

190 |

191 | 201 |

202 |

203 | 208 | 211 |

212 |
213 | } 214 |
; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/components/Avatar.css: -------------------------------------------------------------------------------- 1 | .Avatar { 2 | border-radius: 3px; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Avatar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Avatar.css'; 4 | 5 | export default ({ user, size = 48 }) => ( 6 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/components/Board.css: -------------------------------------------------------------------------------- 1 | .Board { 2 | width: 100vw; 3 | margin-left: calc(50% - 50vw); 4 | margin-right: calc(50% - 50vw); 5 | } 6 | 7 | .Board-columns { 8 | display: flex; 9 | align-items: flex-start; 10 | overflow-x: scroll; 11 | height: 85vh; 12 | 13 | font-size: 0.9em; 14 | padding: 1em 1vw; 15 | 16 | background: #cbcdce; 17 | } 18 | 19 | .Board-columns:after { 20 | content: ""; 21 | display: block; 22 | height: 100%; 23 | width: 50px; 24 | } 25 | 26 | .Board-column { 27 | width: 28vw; 28 | margin-right: 0.75vw; 29 | flex-shrink: 0; 30 | flex-grow: 0; 31 | position: relative; 32 | overflow-y: scroll; 33 | max-height: 100%; 34 | padding: 0; 35 | background: rgba( 255, 255, 255, 0.4 ); 36 | border-radius: 3px; 37 | } 38 | 39 | /* Convince Firefox to show right margin */ 40 | .Board-columns::after { 41 | display: block; 42 | content: " "; 43 | flex-shrink: 0; 44 | flex-grow: 0; 45 | height: 1px; 46 | width: 1px; 47 | } 48 | 49 | .Board-column > .Header { 50 | font-size: 0.75em; 51 | position: sticky; 52 | top: 0; 53 | } 54 | 55 | .Board .ListTableItem { 56 | background: #fff; 57 | border: none; 58 | margin: 0.4em 0.5em; 59 | border-radius: 3px; 60 | box-shadow: 1px 2px 2px rgba( 0, 0, 0, 0.1 ); 61 | } 62 | .Board .ListTableItem:hover { 63 | box-shadow: 1px 2px 2px rgba( 0, 0, 0, 0.2 ) 64 | } 65 | 66 | .Board .TicketListItem-detail-title-block { 67 | flex-direction: column; 68 | } 69 | .Board .TicketListItem-detail-title-block .col-main-title a { 70 | display: block; 71 | } 72 | .Board .TicketListItem-detail-tags { 73 | display: none; 74 | margin-left: 0; 75 | } 76 | .Board.with-labels .TicketListItem-detail-tags { 77 | display: block; 78 | } 79 | .Board .TicketListItem-detail-opened { 80 | display: none; 81 | } 82 | .Board .TicketListItem-detail-milestone { 83 | display: block; 84 | margin-left: -20px; 85 | margin-top: 0.5em; 86 | } 87 | .Board .TicketListItem-detail-patch { 88 | display: none; 89 | } 90 | -------------------------------------------------------------------------------- /src/components/Board.js: -------------------------------------------------------------------------------- 1 | import qs from 'query-string'; 2 | import React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import Button from './Button'; 6 | import Header from './Header'; 7 | import Loading from './Loading'; 8 | import QueryHeader from './QueryHeader'; 9 | import Tag from './Tag'; 10 | import TicketList from './TicketList'; 11 | 12 | import './Board.css'; 13 | 14 | const COLUMNS = { 15 | 'icebox': { 16 | title: 'Icebox', 17 | }, 18 | 'backlog': { 19 | title: 'Backlog', 20 | }, 21 | 'needs-patch': { 22 | title: 'Needs Patch', 23 | keywords: [], 24 | }, 25 | 'assigned': { 26 | title: 'Assigned (No Patch)', 27 | }, 28 | 'has-patch': { 29 | title: 'Has Patch', 30 | keywords: [ 'has-patch' ], 31 | }, 32 | 'commit': { 33 | title: 'Commit', 34 | keywords: [ 'has-patch', 'commit' ], 35 | }, 36 | }; 37 | 38 | const getColumn = ticket => { 39 | const keywords = ticket.attributes.keywords.split( ' ' ).map( k => k.trim() ); 40 | 41 | if ( ticket.attributes.milestone === 'Future Release' ) { 42 | return 'icebox'; 43 | } 44 | 45 | if ( keywords.indexOf( 'has-patch' ) >= 0 ) { 46 | if ( keywords.indexOf( 'commit' ) >= 0 ) { 47 | return 'commit'; 48 | } 49 | 50 | return 'has-patch'; 51 | } 52 | 53 | if ( ticket.attributes.owner ) { 54 | return 'assigned'; 55 | } 56 | 57 | if ( ticket.attributes.status === 'accepted' || keywords.indexOf( 'needs-patch' ) >= 0 ) { 58 | return 'needs-patch'; 59 | } 60 | 61 | return 'backlog'; 62 | }; 63 | 64 | const columnise = tickets => { 65 | const data = {}; 66 | Object.keys( COLUMNS ).forEach( key => { 67 | data[ key ] = { 68 | ...COLUMNS[ key ], 69 | tickets: [], 70 | } 71 | }); 72 | tickets.forEach( ticket => { 73 | const column = getColumn( ticket ); 74 | data[ column ].tickets.push( ticket ); 75 | }); 76 | return data; 77 | }; 78 | 79 | export default class Board extends React.PureComponent { 80 | constructor( props ) { 81 | super( props ); 82 | 83 | this.state = { 84 | showingLabels: true, 85 | }; 86 | 87 | this.milestoneComponent = ({ className, name }) => { 88 | const nextParams = { 89 | ...this.props.params, 90 | milestone: name, 91 | }; 92 | const search = '?' + qs.stringify( nextParams ); 93 | return 94 | 95 | { name } 96 | ; 97 | }; 98 | this.labelComponent = ({ name }) => { 99 | const nextParams = { 100 | ...this.props.params, 101 | keywords: '~' + name, 102 | }; 103 | const search = '?' + qs.stringify( nextParams ); 104 | return 105 | 106 | ; 107 | }; 108 | } 109 | 110 | render() { 111 | const { loading, params, tickets, onUpdateQuery } = this.props; 112 | const { showingLabels } = this.state; 113 | 114 | const data = columnise( tickets ); 115 | 116 | const queryLink = { 117 | pathname: '/query', 118 | search: '?' + qs.stringify( params ), 119 | }; 120 | 121 | return
122 | 126 | 127 | 128 | Switch to list view 129 | 130 | 133 | 134 | { loading ? ( 135 | 136 | ) : ( 137 |
138 | { Object.keys( data ).map( name => { 139 | const column = data[ name ]; 140 | return
141 |
142 | { column.tickets.length > 0 ? 143 | 148 | : 149 |

No items.

150 | } 151 |
; 152 | } ) } 153 |
154 | ) } 155 |
; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/components/Button.css: -------------------------------------------------------------------------------- 1 | .Button { 2 | display: inline-block; 3 | font-family: inherit; 4 | font-size: 13px; 5 | line-height: 26px; 6 | height: 28px; 7 | margin: 0; 8 | padding: 0 10px 1px; 9 | cursor: pointer; 10 | border-width: 1px; 11 | border-style: solid; 12 | border-radius: 3px; 13 | white-space: nowrap; 14 | 15 | color: #555; 16 | border-color: #cccccc; 17 | background: #f7f7f7; 18 | -webkit-box-shadow: 0 1px 0 #cccccc; 19 | box-shadow: 0 1px 0 #cccccc; 20 | vertical-align: top; 21 | } 22 | 23 | .Button:hover, .Button:focus { 24 | background: #fafafa; 25 | border-color: #999; 26 | color: #23282d; 27 | } 28 | 29 | .Button:focus { 30 | border-color: #5b9dd9; 31 | box-shadow: 0 0 3px rgba( 0, 115, 170, .8 ); 32 | } 33 | 34 | .Button:active { 35 | background: #eee; 36 | border-color: #999; 37 | box-shadow: inset 0 2px 5px -3px rgba( 0, 0, 0, 0.5 ); 38 | transform: translateY(1px); 39 | } 40 | 41 | .Button:disabled { 42 | color: #a0a5aa; 43 | border-color: #ddd; 44 | background: #f7f7f7; 45 | box-shadow: none; 46 | text-shadow: 0 1px 0 #fff; 47 | cursor: default; 48 | transform: none; 49 | } 50 | 51 | 52 | .Button.primary { 53 | background: #0085ba; 54 | border-color: #0073aa #006799 #006799; 55 | box-shadow: 0 1px 0 #006799; 56 | color: #fff; 57 | text-decoration: none; 58 | text-shadow: 0 -1px 1px #006799, 59 | 1px 0 1px #006799, 60 | 0 1px 1px #006799, 61 | -1px 0 1px #006799; 62 | } 63 | 64 | .Button.primary:hover { 65 | background: #008ec2; 66 | border-color: #006799; 67 | color: #fff; 68 | } 69 | 70 | .Button.primary:focus { 71 | box-shadow: 0 1px 0 #0073aa, 72 | 0 0 2px 1px #33b3db; 73 | } 74 | 75 | .Button.primary:active { 76 | background: #0073aa; 77 | border-color: #006799; 78 | box-shadow: inset 0 2px 0 #006799; 79 | vertical-align: top; 80 | } 81 | 82 | .Button.primary:disabled { 83 | color: #66c6e4; 84 | background: #008ec2; 85 | border-color: #007cb2; 86 | box-shadow: none; 87 | text-shadow: 0 -1px 0 rgba( 0, 0, 0, 0.1 ); 88 | cursor: default; 89 | } 90 | -------------------------------------------------------------------------------- /src/components/Button.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import './Button.css'; 5 | 6 | const Button = ({ children, disabled, fake, primary, submit, onClick }) => { 7 | const className = primary ? "Button primary" : "Button"; 8 | 9 | if ( fake ) { 10 | return ; 15 | } 16 | 17 | return ; 133 | } ) } 134 | 135 | : null } 136 | 137 | 138 | { mode === 'preview' ? ( 139 | 143 | ) : ( 144 |