├── .babelrc ├── .gitignore ├── .travis.yml ├── IconTemplate.png ├── IconTemplate@2x.png ├── auto_updater.json ├── graphics ├── auth-bg.png ├── comments.svg ├── done.gif └── spinn-loader.svg ├── index.html ├── index.js ├── lib ├── Auth.js ├── Jira.js └── update.js ├── media ├── Icon.icns ├── icon.png ├── logo.png └── minira.png ├── menu.js ├── package.json ├── readme.md ├── src └── client │ ├── app.injector.js │ ├── app.js │ ├── app.routes.js │ ├── components │ ├── auth.component.js │ ├── issue.component.js │ ├── issues.component.js │ ├── settings.component.js │ └── suggest.component.js │ ├── directives │ └── authRouterOutlet.directive.js │ ├── scss │ ├── base │ │ └── _base.scss │ ├── components │ │ ├── _btn.scss │ │ ├── _labels.scss │ │ └── _loading.scss │ ├── elements │ │ └── _logo.scss │ ├── layout │ │ └── _frame.scss │ ├── modules │ │ ├── _auth.scss │ │ ├── _issue-comments.scss │ │ ├── _issue.scss │ │ ├── _issues.scss │ │ ├── _settings.scss │ │ └── _suggest.scss │ └── utils │ │ └── _colors.scss │ ├── services │ ├── jira.service.js │ └── nav.service.js │ └── templates │ ├── app.template.html │ ├── auth.template.html │ ├── issue.template.html │ ├── issues.template.html │ └── settings.template.html ├── webpack-production.config.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "angular2-annotations", 5 | "transform-decorators-legacy", 6 | "transform-class-properties", 7 | "transform-flow-strip-types" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bundle.js 3 | bundle.css 4 | build 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '4.3' 4 | env: 5 | - CXX=g++-4.8 6 | addons: 7 | apt: 8 | sources: 9 | - ubuntu-toolchain-r-test 10 | packages: 11 | - g++-4.8 12 | - libgnome-keyring-dev 13 | -------------------------------------------------------------------------------- /IconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/IconTemplate.png -------------------------------------------------------------------------------- /IconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/IconTemplate@2x.png -------------------------------------------------------------------------------- /auto_updater.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://github.com/jenslind/minira/releases/download/v1.0.1/Minira-osx-1.0.1.zip" 3 | } 4 | -------------------------------------------------------------------------------- /graphics/auth-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/graphics/auth-bg.png -------------------------------------------------------------------------------- /graphics/comments.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /graphics/done.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/graphics/done.gif -------------------------------------------------------------------------------- /graphics/spinn-loader.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const menubar = require('menubar') 4 | const Jira = require('./lib/Jira.js') 5 | const Auth = require('./lib/Auth.js') 6 | const autoUpdate = require('./lib/update.js') 7 | const ipc = require('electron').ipcMain 8 | const Menu = require('electron').Menu 9 | const storage = require('electron-json-storage') 10 | const menuTpl = require('./menu.js') 11 | 12 | const mb = menubar({ 13 | 'preload-window': true, 14 | 'transparent': true, 15 | 'resizable': false 16 | }) 17 | let jira = null 18 | 19 | mb.on('ready', () => { 20 | // Auto-update 21 | autoUpdate(mb.app.getVersion()) 22 | 23 | // Set application menu 24 | const appMenu = Menu.buildFromTemplate(menuTpl) 25 | Menu.setApplicationMenu(appMenu) 26 | 27 | // Get many issues 28 | ipc.on('getIssues', (event, jql) => { 29 | jira.getIssues(jql) 30 | .then((issues) => mb.window.webContents.send('issues', issues)) 31 | }) 32 | 33 | // Get a specific jira issue 34 | ipc.on('getIssue', (event, id) => { 35 | jira.getIssue(id) 36 | .then((issue) => mb.window.webContents.send('issue', issue)) 37 | }) 38 | 39 | ipc.on('getComments', (event, id) => { 40 | jira.getComments(id) 41 | .then((comments) => mb.window.webContents.send('comments', comments)) 42 | }) 43 | 44 | ipc.on('addComment', (event, comment) => { 45 | jira.addComment(comment) 46 | .then((newComment) => mb.window.webContents.send('commentAdded', newComment)) 47 | }) 48 | 49 | // Get assignable users to a issue 50 | ipc.on('getAssignable', (event, issueId) => { 51 | jira.getAssignable(issueId) 52 | .then((assignable) => mb.window.webContents.send('assignable', assignable)) 53 | }) 54 | 55 | // Assign user to a issue 56 | ipc.on('assignUser', (event, data) => { 57 | jira.assignUser(data.issue, data.user) 58 | }) 59 | 60 | ipc.on('getTransitions', (event, issueId) => { 61 | jira.getTransitions(issueId) 62 | .then((transitions) => { 63 | event.returnValue = transitions 64 | }) 65 | }) 66 | 67 | ipc.on('doTransition', (event, data) => { 68 | jira.transition(data.issueId, data.transitionId) 69 | }) 70 | 71 | ipc.on('isAuthed', (event) => { 72 | Auth.getAuth((success) => { 73 | if (!jira && success) jira = new Jira(success) 74 | event.returnValue = success 75 | }) 76 | }) 77 | 78 | ipc.on('auth', (event, info) => { 79 | Auth.auth(info) 80 | .then((res) => { 81 | event.returnValue = true 82 | }) 83 | .catch(() => { 84 | event.returnValue = false 85 | }) 86 | }) 87 | 88 | ipc.on('unAuth', (event) => { 89 | Auth.unAuth((done) => { 90 | event.returnValue = done 91 | }) 92 | }) 93 | 94 | ipc.on('updateSettings', (event, data) => { 95 | storage.set('settings', data) 96 | }) 97 | 98 | ipc.on('getSettings', (event) => { 99 | storage.get('settings', (err, settings) => { 100 | event.returnValue = (!err) ? settings : null 101 | }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /lib/Auth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const keytar = require('keytar') 4 | const storage = require('electron-json-storage') 5 | const got = require('got') 6 | 7 | class Auth { 8 | 9 | static test (info) { 10 | return got(info.baseUrl + '/rest/api/2/myself', { 11 | auth: info.user + ':' + info.pass, 12 | json: true 13 | }) 14 | } 15 | 16 | static auth (info) { 17 | return Auth.test(info) 18 | .then((res) => { 19 | storage.set('authSettings', { 20 | baseUrl: info.baseUrl, 21 | user: info.user, 22 | userAvatar: res.body.avatarUrls['48x48'] 23 | }, () => { 24 | keytar.addPassword('Minira', info.user, info.pass) 25 | Promise.resolve(true) 26 | }) 27 | }) 28 | } 29 | 30 | static getAuth (cb) { 31 | storage.get('authSettings', (err, settings) => { 32 | if (!Object.keys(settings).length || err) return cb(false) 33 | const pass = keytar.getPassword('Minira', settings.user) 34 | if (!pass) return cb(false) 35 | 36 | return cb({ 37 | baseUrl: settings.baseUrl, 38 | user: settings.user, 39 | pass: pass, 40 | avatar: settings.userAvatar 41 | }) 42 | }) 43 | } 44 | 45 | static unAuth (cb) { 46 | storage.get('authSettings', (err, settings) => { 47 | if (err) return cb(false) 48 | storage.remove('authSettings', (err) => { 49 | if (err) return cb(false) 50 | keytar.deletePassword('Minira', settings.user) 51 | cb(true) 52 | }) 53 | }) 54 | } 55 | 56 | } 57 | 58 | module.exports = Auth 59 | -------------------------------------------------------------------------------- /lib/Jira.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const got = require('got') 4 | 5 | class Jira { 6 | 7 | constructor (auth) { 8 | if (auth) { 9 | this.BASE_URL = auth.baseUrl 10 | this.USER = auth.user 11 | this.PASS = auth.pass 12 | } 13 | } 14 | 15 | _getAuth () { 16 | return this.USER + ':' + this.PASS 17 | } 18 | 19 | _getBaseUrl () { 20 | return this.BASE_URL + '/rest/api/2' 21 | } 22 | 23 | getIssues (jql) { 24 | let self = this 25 | 26 | if (!jql) jql = 'assignee=' + this.USER + ' AND status!=done' 27 | 28 | return got(this._getBaseUrl() + '/search?jql=' + jql, 29 | { 30 | auth: self._getAuth(), 31 | json: true 32 | }) 33 | .then((res) => { 34 | return res.body.issues 35 | }) 36 | } 37 | 38 | getIssue (id) { 39 | let self = this 40 | return got(this._getBaseUrl() + '/issue/' + id + '?expand=renderedFields,transitions', { 41 | auth: self._getAuth(), 42 | json: true 43 | }) 44 | .then((res) => { 45 | return res.body 46 | }) 47 | } 48 | 49 | assignUser (issue, user) { 50 | let self = this 51 | return got.put(issue, 52 | { 53 | auth: self._getAuth(), 54 | body: '{ "fields": { "assignee": { "name": "' + user + '" } } }', 55 | headers: { 56 | 'Content-Type': 'application/json' 57 | } 58 | }) 59 | } 60 | 61 | getAssignable (issueKey) { 62 | let self = this 63 | return got(this._getBaseUrl() + '/user/assignable/search?issueKey=' + issueKey, 64 | { 65 | auth: self._getAuth(), 66 | json: true 67 | }) 68 | .then((res) => { 69 | return res.body 70 | }) 71 | } 72 | 73 | getTransitions (issueId) { 74 | let self = this 75 | return got(this._getBaseUrl() + '/issue/' + issueId + '/transitions', 76 | { 77 | auth: self._getAuth(), 78 | json: true 79 | }) 80 | .then((res) => { 81 | return res.body 82 | }) 83 | } 84 | 85 | transition (issueId, transitionId) { 86 | let self = this 87 | return got.post(this._getBaseUrl() + '/issue/' + issueId + '/transitions', { 88 | auth: self._getAuth(), 89 | body: '{ "transition": { "id":' + transitionId + '} }', 90 | headers: { 91 | 'Content-Type': 'application/json' 92 | } 93 | }) 94 | } 95 | 96 | getComments (id) { 97 | let self = this 98 | return got(this._getBaseUrl() + '/issue/' + id + '/comment?expand=renderedBody', { 99 | auth: self._getAuth(), 100 | json: true 101 | }) 102 | .then((res) => { 103 | return res.body 104 | }) 105 | } 106 | 107 | addComment (comment) { 108 | let self = this 109 | return got.post(this._getBaseUrl() + '/issue/' + comment.issueKey + '/comment?expand=renderedBody', { 110 | auth: self._getAuth(), 111 | body: '{ "body": "' + comment.body + '" }', 112 | headers: { 113 | 'Content-Type': 'application/json' 114 | } 115 | }) 116 | .then((res) => { 117 | return res.body 118 | }) 119 | } 120 | 121 | } 122 | 123 | module.exports = Jira 124 | -------------------------------------------------------------------------------- /lib/update.js: -------------------------------------------------------------------------------- 1 | const GhReleases = require('electron-gh-releases') 2 | 3 | const update = (currentVersion) => { 4 | let options = { 5 | repo: 'jenslind/minira', 6 | currentVersion: currentVersion 7 | } 8 | 9 | const updater = new GhReleases(options) 10 | 11 | // Check for updates 12 | // `status` returns true if there is a new update available 13 | updater.check((err, status) => { 14 | if (!err && status) { 15 | // Download the update 16 | updater.download() 17 | } 18 | }) 19 | 20 | // When an update has been downloaded 21 | updater.on('update-downloaded', (info) => { 22 | // Restart the app and install the update 23 | updater.install() 24 | }) 25 | } 26 | 27 | module.exports = update 28 | -------------------------------------------------------------------------------- /media/Icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/media/Icon.icns -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/media/icon.png -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/media/logo.png -------------------------------------------------------------------------------- /media/minira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenslind/minira/544392ec3c324962d0f7486e7bf43639bf4079c7/media/minira.png -------------------------------------------------------------------------------- /menu.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const app = require('electron').app 3 | 4 | const menuTpl = [ 5 | { 6 | label: 'Minira', 7 | submenu: [ 8 | { 9 | label: 'Quit', 10 | accelerator: 'Command+Q', 11 | click: function () { 12 | app.quit() 13 | } 14 | } 15 | ] 16 | }, 17 | { 18 | label: 'Edit', 19 | submenu: [ 20 | { 21 | label: 'Undo', 22 | accelerator: 'CmdOrCtrl+Z', 23 | role: 'undo' 24 | }, 25 | { 26 | label: 'Redo', 27 | accelerator: 'Shift+CmdOrCtrl+Z', 28 | role: 'redo' 29 | }, 30 | { 31 | type: 'separator' 32 | }, 33 | { 34 | label: 'Cut', 35 | accelerator: 'CmdOrCtrl+X', 36 | role: 'cut' 37 | }, 38 | { 39 | label: 'Copy', 40 | accelerator: 'CmdOrCtrl+C', 41 | role: 'copy' 42 | }, 43 | { 44 | label: 'Paste', 45 | accelerator: 'CmdOrCtrl+V', 46 | role: 'paste' 47 | }, 48 | { 49 | label: 'Select All', 50 | accelerator: 'CmdOrCtrl+A', 51 | role: 'selectall' 52 | } 53 | ] 54 | } 55 | ] 56 | 57 | module.exports = menuTpl 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minira", 3 | "version": "1.0.1", 4 | "description": "JIRA in your menubar.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "standard", 8 | "postinstall": "electron-rebuild", 9 | "build": "npm test && webpack --config webpack-production.config.js && electron-packager ./ Minira --platform=darwin --arch=x64 --version=0.37.2 --app-version=$npm_package_version --out=./build --overwrite --ignore='^/media$' --ignore='^/build$' --ignore='^/src$' --ignore='^/node_modules/angular2$' --prune --icon=media/Icon.icns --sign='Jens Lind' --app-bundle-id=com.jenslind.minira", 10 | "release": "npm run build && electron-release --app=build/Minira-darwin-x64/Minira.app --output=build/Minira-darwin-x64/Minira-osx-$npm_package_version.zip --token=$MINIRA_TOKEN" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/jenslind/minira.git" 15 | }, 16 | "author": "Jens Lind", 17 | "license": "MIT", 18 | "dependencies": { 19 | "angular2": "2.0.0-beta.9", 20 | "electron-gh-releases": "^2.0.2", 21 | "electron-json-storage": "^2.0.0", 22 | "got": "^6.2.0", 23 | "keytar": "^3.0.0", 24 | "menubar": "^4.0.2", 25 | "normalize.css": "^3.0.3", 26 | "zone.js": "0.5.15", 27 | "rxjs": "5.0.0-beta.2", 28 | "reflect-metadata": "0.1.2" 29 | }, 30 | "devDependencies": { 31 | "babel-core": "^6.7.0", 32 | "babel-eslint": "^5.0.0", 33 | "babel-loader": "^6.2.4", 34 | "babel-plugin-angular2-annotations": "^5.0.0", 35 | "babel-plugin-transform-class-properties": "^6.6.0", 36 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 37 | "babel-plugin-transform-flow-strip-types": "^6.7.0", 38 | "babel-preset-es2015": "^6.6.0", 39 | "css-loader": "^0.23.1", 40 | "electron-packager": "^5.2.1", 41 | "electron-prebuilt": "0.37.2", 42 | "electron-rebuild": "^1.1.3", 43 | "electron-release": "^2.2.0", 44 | "extract-text-webpack-plugin": "^1.0.1", 45 | "html-loader": "^0.4.3", 46 | "node-sass": "^3.4.2", 47 | "sass-loader": "^3.1.2", 48 | "standard": "^6.0.8", 49 | "style-loader": "^0.13.0", 50 | "webpack": "^1.12.14" 51 | }, 52 | "standard": { 53 | "parser": "babel-eslint" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | > Menubar app for [JIRA](https://www.atlassian.com/software/jira) 6 | 7 | [![Build Status](https://travis-ci.org/jenslind/minira.svg?branch=master)](https://travis-ci.org/jenslind/minira) 8 | 9 | Minira is built on top of [Electron](http://electron.atom.io/), [Menubar](https://github.com/maxogden/menubar) and [Angular 2](https://angular.io/). 10 | It is not a replacement for JIRA:s web interface, the goal is to list a number of important issues for the moment and have easy access to edit them. 11 | 12 | ![minira.app](https://raw.githubusercontent.com/jenslind/minira/master/media/minira.png) 13 | 14 | ## The name? 15 | 16 | Jira is coming from the Japanese word for `Godzilla`, `Gojira`. `Minira` is the Japanese word for [Minilla](https://en.wikipedia.org/wiki/Minilla), Godzillas adopted son. :fireworks: 17 | 18 | ## Development 19 | 20 | [![js-standard-style](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard) 21 | 22 | Clone this repo and: 23 | 24 | ``` 25 | npm install 26 | ``` 27 | 28 | Build: 29 | ``` 30 | webpack 31 | ``` 32 | 33 | Build the .app: 34 | ``` 35 | npm run build 36 | ``` 37 | 38 | ## License 39 | 40 | MIT © [Jens Lind](http://jenslind.com) 41 | -------------------------------------------------------------------------------- /src/client/app.injector.js: -------------------------------------------------------------------------------- 1 | import { Injector } from 'angular2/core' 2 | 3 | let appInjectorRef: Injector 4 | export const appInjector = (injector) => { 5 | if (injector) appInjectorRef = injector 6 | return appInjectorRef 7 | } 8 | -------------------------------------------------------------------------------- /src/client/app.js: -------------------------------------------------------------------------------- 1 | import 'angular2/bundles/angular2-polyfills' 2 | import { appInjector } from './app.injector' 3 | import { Component, View, provide, ComponentRef } from 'angular2/core' 4 | import { RouteConfig, ROUTER_PROVIDERS, LocationStrategy, HashLocationStrategy, RouterLink } from 'angular2/router' 5 | import { AuthRouterOutlet } from './directives/authRouterOutlet.directive' 6 | import { bootstrap } from 'angular2/platform/browser' 7 | import { appRoutes } from './app.routes' 8 | import JiraService from './services/jira.service' 9 | import NavService from './services/nav.service' 10 | import './scss/base/_base' 11 | import './scss/layout/_frame' 12 | import './scss/elements/_logo' 13 | import './scss/components/_btn' 14 | import './scss/components/_labels' 15 | import './scss/components/_loading' 16 | 17 | @Component({ 18 | selector: 'jira-app' 19 | }) 20 | @View({ 21 | directives: [AuthRouterOutlet, RouterLink], 22 | template: require('./templates/app.template.html') 23 | }) 24 | @RouteConfig(appRoutes) 25 | class JiraApp { 26 | constructor (nav: NavService) { 27 | this.nav = nav 28 | this.buttons = this.nav.get() 29 | } 30 | } 31 | 32 | document.addEventListener('DOMContentLoaded', () => { 33 | bootstrap(JiraApp, [ 34 | ROUTER_PROVIDERS, 35 | provide(LocationStrategy, { useClass: HashLocationStrategy }), 36 | JiraService, 37 | NavService 38 | ]) 39 | .then((appRef: ComponentRef) => { 40 | appInjector(appRef.injector) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/client/app.routes.js: -------------------------------------------------------------------------------- 1 | import IssuesComponent from './components/issues.component' 2 | import IssueComponent from './components/issue.component' 3 | import AuthComponent from './components/auth.component' 4 | import SettingsComponent from './components/settings.component' 5 | 6 | export const appRoutes = [ 7 | { path: '/', component: IssuesComponent, as: 'IssuesComponent' }, 8 | { path: '/issue/:issueId', component: IssueComponent, as: 'IssueComponent' }, 9 | { path: '/auth', component: AuthComponent, as: 'AuthComponent' }, 10 | { path: '/settings', component: SettingsComponent, as: 'SettingsComponent' } 11 | ] 12 | -------------------------------------------------------------------------------- /src/client/components/auth.component.js: -------------------------------------------------------------------------------- 1 | import { Component, View } from 'angular2/core' 2 | import { FormBuilder, Validators } from 'angular2/common' 3 | import { Router } from 'angular2/router' 4 | import JiraService from '../services/jira.service' 5 | import NavService from '../services/nav.service' 6 | import { ipcRenderer } from 'electron' 7 | import '../scss/modules/_auth' 8 | 9 | @Component({ 10 | selector: 'auth' 11 | }) 12 | @View({ 13 | template: require('../templates/auth.template') 14 | }) 15 | export default class AuthComponent { 16 | constructor (jira: JiraService, fb: FormBuilder, router: Router, nav: NavService) { 17 | this.fb = fb 18 | this.router = router 19 | this.authForm = this.fb.group({ 20 | baseUrl: ['', Validators.required], 21 | user: ['', Validators.required], 22 | pass: ['', Validators.required] 23 | }) 24 | 25 | this.hideLoading = true 26 | 27 | nav.hide('settings').hide('issues').hide('shadow') 28 | } 29 | 30 | auth () { 31 | this.hideLoading = false 32 | 33 | let self = this 34 | setTimeout(() => { 35 | const success = ipcRenderer.sendSync('auth', this.authForm.value) 36 | if (success) { 37 | self.router.navigateByUrl('/') 38 | } else { 39 | self.hideLoading = true 40 | } 41 | }, 500) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/client/components/issue.component.js: -------------------------------------------------------------------------------- 1 | import { Component, View, ComponentInstruction } from 'angular2/core' 2 | import { FormBuilder, Validators } from 'angular2/common' 3 | import { RouteParams, CanActivate } from 'angular2/router' 4 | import { appInjector } from '../app.injector' 5 | import JiraService from '../services/jira.service' 6 | import NavService from '../services/nav.service' 7 | import Suggest from './suggest.component' 8 | import '../scss/modules/_issue' 9 | import '../scss/modules/_issue-comments' 10 | import { shell } from 'electron' 11 | 12 | @Component({ 13 | selector: 'issue' 14 | }) 15 | @View({ 16 | directives: [Suggest], 17 | template: require('../templates/issue.template') 18 | }) 19 | @CanActivate((next: ComponentInstruction) => { 20 | let jira = appInjector().get(JiraService) 21 | jira.getIssue(next.params.issueId) 22 | 23 | return new Promise((resolve, reject) => { 24 | jira.issue$.subscribe((issue) => { 25 | jira.currentIssue = issue 26 | resolve(true) 27 | }) 28 | }) 29 | }) 30 | export default class IssueComponent { 31 | constructor (jira: JiraService, routeParams: RouteParams, fb: FormBuilder, nav: NavService) { 32 | this.jira = jira 33 | this.params = routeParams.params 34 | this.issue = this.jira.currentIssue 35 | this.assignable = [] 36 | this.fb = fb 37 | this.hideTransitions = true 38 | this.hideComments = true 39 | this.addingComment = false 40 | this.comments = null 41 | 42 | nav.show('settings').show('issues').show('shadow') 43 | } 44 | 45 | getAssignable () { 46 | const users = [] 47 | for (let i = 0, len = this.assignable.length; i < len; i++) { 48 | users.push(this.assignable[i].name) 49 | } 50 | return users 51 | } 52 | 53 | assignUser () { 54 | if (!this.assignForm.valid) return 55 | const user = this.assignForm.controls.assigned.value.toLowerCase() 56 | if (user === this.issue.fields.assignee.name) return 57 | this.jira.assignUser(this.issue.self, user) 58 | } 59 | 60 | getPossibleTransitions () { 61 | let transitions = [] 62 | let len = this.issue.transitions.length 63 | for (let i = 0; i < len; i++) { 64 | if (this.issue.transitions[i].to.id !== this.issue.fields.status.id) { 65 | transitions.push(this.issue.transitions[i]) 66 | } 67 | } 68 | return transitions 69 | } 70 | 71 | doTransition (iId, t) { 72 | this.jira.doTransition(iId, t.id) 73 | this.issue.fields.status = t.to 74 | this.transitions = this.getPossibleTransitions() 75 | } 76 | 77 | openIssue () { 78 | const URLParts = this.issue.self.split('/') 79 | let issueURL = URLParts[0] + '//' + URLParts[2] 80 | issueURL += '/projects/' + this.issue.fields.project.key 81 | issueURL += '/issues/' + this.issue.key 82 | shell.openExternal(issueURL) 83 | } 84 | 85 | showComments () { 86 | this.jira.getComments(this.issue.key) 87 | 88 | let self = this 89 | this.comments = null 90 | this.jira.comments$.subscribe((comments) => { 91 | if (comments.comments) { 92 | self.comments = comments.comments 93 | } else { 94 | self.comments.push(JSON.parse(comments)) 95 | self.addingComment = false 96 | setTimeout(() => { document.querySelector('.issue__comments .issue__comment:last-child').scrollIntoView() }, 100) 97 | } 98 | self.hideComments = false 99 | }) 100 | } 101 | 102 | addComment () { 103 | this.addingComment = true 104 | let comment = this.commentForm.value 105 | comment.issueKey = this.issue.key 106 | comment.body = comment.body.replace(/\n/g, '\\n') 107 | this.jira.addComment(comment) 108 | this.commentForm.controls.body.updateValue('') 109 | } 110 | 111 | ngOnInit () { 112 | let self = this 113 | this.jira.getAssignable(this.issue.key, (data) => { 114 | self.assignable = data 115 | }) 116 | 117 | this.transitions = this.getPossibleTransitions() 118 | 119 | let assignee = (this.issue.fields.assignee) ? this.issue.fields.assignee.name : '' 120 | this.assignForm = this.fb.group({ 121 | assigned: [assignee, Validators.required] 122 | }) 123 | 124 | this.commentForm = this.fb.group({ 125 | body: ['', Validators.required] 126 | }) 127 | } 128 | 129 | _strAsDate (str) { 130 | return new Date(str) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/client/components/issues.component.js: -------------------------------------------------------------------------------- 1 | import { Component, View } from 'angular2/core' 2 | import { ROUTER_DIRECTIVES } from 'angular2/router' 3 | import JiraService from '../services/jira.service' 4 | import NavService from '../services/nav.service' 5 | import { ipcRenderer } from 'electron' 6 | import '../scss/modules/_issues' 7 | 8 | @Component({ 9 | selector: 'issues' 10 | }) 11 | @View({ 12 | directives: [ROUTER_DIRECTIVES], 13 | template: require('../templates/issues.template') 14 | }) 15 | export default class IssuesComponent { 16 | constructor (jira: JiraService, nav: NavService) { 17 | this.jira = jira 18 | this.issues = [] 19 | this.hideZero = true 20 | this.hideLoading = false 21 | 22 | nav.show('settings').hide('issues').show('shadow') 23 | } 24 | 25 | fillLoad (event) { 26 | this.loadingPosition = 'top: ' + (event.clientY - 50) + 'px; left: ' + event.clientX + 'px;' 27 | this.hideLoading = false 28 | } 29 | 30 | ngOnInit () { 31 | let self = this 32 | this.jira.issues$.subscribe((issues) => { 33 | self.issues = issues 34 | self.hideLoading = true 35 | if (!issues.length) self.hideZero = false 36 | }) 37 | this.settings = ipcRenderer.sendSync('getSettings') 38 | this.jira.getIssues(this.settings.issueQuery) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/client/components/settings.component.js: -------------------------------------------------------------------------------- 1 | import { Component, View } from 'angular2/core' 2 | import { FormBuilder } from 'angular2/common' 3 | import { Router } from 'angular2/router' 4 | import NavService from '../services/nav.service' 5 | import { ipcRenderer } from 'electron' 6 | import '../scss/modules/_settings' 7 | 8 | @Component({ 9 | selector: 'settings' 10 | }) 11 | @View({ 12 | template: require('../templates/settings.template') 13 | }) 14 | export default class SettingsComponent { 15 | constructor (fb: FormBuilder, nav: NavService, router: Router) { 16 | this.fb = fb 17 | this.router = router 18 | this.user = ipcRenderer.sendSync('isAuthed') 19 | this.settings = ipcRenderer.sendSync('getSettings') 20 | 21 | this.settingsForm = this.fb.group({ 22 | issueQuery: [this.settings.issueQuery] 23 | }) 24 | 25 | nav.show('settings').show('issues').show('shadow') 26 | } 27 | 28 | unlink () { 29 | let done = ipcRenderer.sendSync('unAuth') 30 | if (done) this.router.navigateByUrl('/auth') 31 | } 32 | 33 | updateSettings () { 34 | ipcRenderer.send('updateSettings', this.settingsForm.value) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/client/components/suggest.component.js: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectorRef } from 'angular2/core' 2 | import '../scss/modules/_suggest' 3 | 4 | @Component({ 5 | selector: 'suggest', 6 | inputs: ['haystack', 'control', 'value'], 7 | template: ` 8 | ` 9 | }) 10 | export default class Suggest { 11 | constructor (cdr: ChangeDetectorRef) { 12 | this.cdr = cdr 13 | this.suggest = '' 14 | } 15 | 16 | ngOnInit () { 17 | this.suggest = this.control.value 18 | this.currentValue = this.control.value 19 | } 20 | 21 | getSuggestion (event) { 22 | const enterKey = 13 23 | 24 | let regex = new RegExp('^' + event.target.value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'), 'i') 25 | this.suggestFound = false 26 | for (let i = 0, len = this.haystack.length; i < len; i++) { 27 | if (regex.test(this.haystack[i]) && event.target.value) { 28 | this.suggestFound = this.haystack[i] 29 | } 30 | } 31 | 32 | if (this.suggestFound) { 33 | this.suggest = this.suggestFound 34 | } else { 35 | this.suggest = null 36 | } 37 | 38 | if (event.which === enterKey) { 39 | event.target.value = this.suggest || this.currentValue 40 | this.currentValue = this.suggest || this.currentValue 41 | } 42 | 43 | this.cdr.detectChanges() 44 | } 45 | 46 | fixValue (event) { 47 | event.target.value = this.currentValue 48 | this.suggest = this.currentValue 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/client/directives/authRouterOutlet.directive.js: -------------------------------------------------------------------------------- 1 | import { Directive, AttributeMetadata, ElementRef, DynamicComponentLoader } from 'angular2/core' 2 | import { Router, RouterOutlet, ComponentInstruction } from 'angular2/router' 3 | import JiraService from '../services/jira.service' 4 | 5 | @Directive({ 6 | selector: 'router-outlet' 7 | }) 8 | @Reflect.metadata('parameters', [[new AttributeMetadata('name')]]) 9 | export class AuthRouterOutlet extends RouterOutlet { 10 | constructor (nameAttr, _elementRef: ElementRef, _loader: DynamicComponentLoader, _parentRouter: Router, jira: JiraService) { 11 | super(_elementRef, _loader, _parentRouter, nameAttr) 12 | 13 | this.parentRouter = _parentRouter 14 | this.jira = jira 15 | } 16 | 17 | activate (instruction: ComponentInstruction) { 18 | if (!this.parentRouter.isRouteActive(this.parentRouter.generate(['/AuthComponent'])) && 19 | !this.jira.isAuthed()) { 20 | this.parentRouter.navigateByUrl('/auth') 21 | } 22 | 23 | return super.activate(instruction) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/client/scss/base/_base.scss: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-font-smoothing: antialiased; 3 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 4 | box-sizing: border-box; 5 | outline: 0; 6 | } 7 | 8 | html { 9 | font-size: 62.5%; 10 | min-height: 100%; 11 | font-family: 'Montserrat', sans-serif; 12 | overflow: hidden; 13 | } 14 | 15 | input, 16 | form, 17 | select, 18 | button { 19 | -webkit-font-smoothing: antialiased; 20 | } 21 | 22 | button, 23 | [type=submit] { 24 | border: 0; 25 | } 26 | 27 | [hidden] { 28 | display: none; 29 | } 30 | -------------------------------------------------------------------------------- /src/client/scss/components/_btn.scss: -------------------------------------------------------------------------------- 1 | .frame__header__button_wrapper { 2 | width: 100px; 3 | 4 | &--right { 5 | text-align: right; 6 | } 7 | } 8 | 9 | .frame__header__button { 10 | border: 1px solid #614385; 11 | border-radius: 3px; 12 | color: #614385; 13 | background: transparent; 14 | transition: all .1s; 15 | text-transform: uppercase; 16 | outline: 0; 17 | 18 | &:hover { 19 | opacity: .4; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/client/scss/components/_labels.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .label { 4 | background: #CCCCCC; 5 | color: #333333; 6 | border-radius: 2px; 7 | text-transform: uppercase; 8 | font-size: 1rem; 9 | 10 | &.blue-gray { 11 | background: #4A6785; 12 | color: $color-white; 13 | } 14 | 15 | &.yellow { 16 | background: #FFD351; 17 | color: #594300; 18 | } 19 | 20 | &.medium-gray { 21 | background: #CCCCCC; 22 | color: #333333; 23 | } 24 | 25 | &.green { 26 | background: #14892c; 27 | color: $color-white; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/client/scss/components/_loading.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | /** 4 | * Thanks George Hastings <3 5 | * http://codepen.io/georgehastings/pen/QyXeNR 6 | */ 7 | .loading { 8 | position: relative; 9 | background-color: #F8E71C; 10 | width: 10px; 11 | height: 10px; 12 | margin: 0 auto; 13 | border-radius: 50%; 14 | 15 | &--auth { 16 | margin: 50px 0; 17 | } 18 | 19 | &--issues { 20 | margin: 140px auto; 21 | animation: fadeIn .5s; 22 | animation-delay: .2s; 23 | animation-fill-mode: forwards; 24 | position: fixed; 25 | left: 50%; 26 | margin-left: -8px; 27 | z-index: 10; 28 | opacity: 0; 29 | } 30 | 31 | &:after, &:before { 32 | content: ""; 33 | position: absolute; 34 | width: 5px; 35 | height: 5px; 36 | border-radius: 50%; 37 | } 38 | 39 | &:after { 40 | left: -10px; 41 | top: -5px; 42 | background-color: hotpink; 43 | transform-origin: 15px 10px; 44 | animation: axis 1s linear infinite; 45 | } 46 | 47 | &:before { 48 | left: -25px; 49 | top: -15px; 50 | background-color: lightblue; 51 | transform-origin: 30px 20px; 52 | animation: axis 2s linear infinite; 53 | } 54 | } 55 | 56 | @keyframes axis { 57 | 0% { 58 | transform: rotateZ(0deg) translate3d(0,0,0); 59 | } 60 | 100% { 61 | transform: rotateZ(360deg) translate3d(0,0,0); 62 | } 63 | } 64 | 65 | @keyframes fadeIn { 66 | 0% { 67 | opacity: 0; 68 | } 69 | 100% { 70 | opacity: 1; 71 | } 72 | } 73 | 74 | // http://codepen.io/mrrocks/pen/EiplA <3 75 | 76 | .loading__material { 77 | position: absolute; 78 | top: 0; 79 | left: 0; 80 | right: 0; 81 | bottom: 0; 82 | 83 | &--add-comment { 84 | background-color: darken($color-bg-blue, 10%); 85 | z-index: 5; 86 | 87 | .spinner { 88 | margin-top: 15px; 89 | } 90 | } 91 | 92 | .spinner { 93 | animation: material-rotator 1.4s linear infinite; 94 | } 95 | 96 | .path { 97 | stroke-dasharray: 187; 98 | stroke-dashoffset: 0; 99 | transform-origin: center; 100 | animation: material-dash 1.4s ease-in-out infinite; 101 | stroke: $color-bg-blue-text; 102 | } 103 | } 104 | 105 | @keyframes material-rotator { 106 | 0% { 107 | transform: rotate(0deg); 108 | } 109 | 110 | 100% { 111 | transform: rotate(270deg); 112 | } 113 | } 114 | 115 | @keyframes material-dash { 116 | 0% { 117 | stroke-dashoffset: 187; 118 | } 119 | 120 | 50% { 121 | stroke-dashoffset: (187/4); 122 | transform:rotate(135deg); 123 | } 124 | 125 | 100% { 126 | stroke-dashoffset: 187; 127 | transform:rotate(450deg); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/client/scss/elements/_logo.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | font-family: 'Playfair Display', serif; 3 | font-size: 1.4rem; 4 | background: linear-gradient(to left, #614385 , #516395); 5 | -webkit-background-clip: text; 6 | -webkit-text-fill-color: transparent; 7 | font-weight: 700; 8 | } 9 | -------------------------------------------------------------------------------- /src/client/scss/layout/_frame.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .frame { 4 | width: 100%; 5 | height: 385px; 6 | position: absolute; 7 | bottom: 0; 8 | } 9 | 10 | .frame__header { 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-between; 14 | width: 100%; 15 | height: 40px; 16 | background: $color-white; 17 | border-top-left-radius: 5px; 18 | border-top-right-radius: 5px; 19 | position: relative; 20 | z-index: 6; 21 | padding-left: 10px; 22 | padding-right: 10px; 23 | box-shadow: 0 2px 3px darken($color-bg-blue, 10%); 24 | 25 | &--no-shadow { 26 | box-shadow: none; 27 | } 28 | 29 | &:before { 30 | bottom: 100%; 31 | left: 50%; 32 | border: solid transparent; 33 | content: ""; 34 | height: 0; 35 | width: 0; 36 | position: absolute; 37 | pointer-events: none; 38 | border-bottom-color: #aaa; 39 | border-width: 11px; 40 | margin-left: -11px; 41 | } 42 | 43 | &:after { 44 | bottom: 100%; 45 | left: 50%; 46 | border: solid transparent; 47 | content: ""; 48 | height: 0; 49 | width: 0; 50 | position: absolute; 51 | pointer-events: none; 52 | border-bottom-color: $color-white; 53 | border-width: 10px; 54 | margin-left: -10px; 55 | } 56 | } 57 | 58 | .frame__content { 59 | overflow-y: auto; 60 | height: calc(100% - 40px); 61 | background: $color-bg-blue; 62 | color: $color-bg-blue-text; 63 | } 64 | -------------------------------------------------------------------------------- /src/client/scss/modules/_auth.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .auth { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | width: 100%; 8 | height: 100%; 9 | background: url('./graphics/auth-bg.png'); 10 | position: relative; 11 | z-index: 1; 12 | border-top: 1px solid darken(#003463, 15%); 13 | box-shadow: inset 0 3px 5px darken(#003463, 5%); 14 | 15 | h4 { 16 | font-size: 1.2rem; 17 | color: $color-white; 18 | margin-top: 0; 19 | margin-bottom: 30px; 20 | } 21 | } 22 | 23 | .auth__form { 24 | display: flex; 25 | flex-direction: column; 26 | justify-content: center; 27 | align-items: center; 28 | width: 100%; 29 | 30 | input[type=text], 31 | input[type=password] { 32 | width: 60%; 33 | border: 2px solid $color-white; 34 | border-radius: 2px; 35 | color: $color-white; 36 | padding: 10px; 37 | font-size: 1.4rem; 38 | background: transparent; 39 | margin-bottom: 25px; 40 | outline: 0; 41 | 42 | &::-webkit-input-placeholder { 43 | color: $color-white; 44 | } 45 | 46 | &.auth__form__no-margin { 47 | margin-bottom: 0; 48 | } 49 | } 50 | } 51 | 52 | .auth__submit { 53 | width: 170px; 54 | height: 35px; 55 | border-radius: 30px; 56 | color: #fff; 57 | text-transform: uppercase; 58 | background: linear-gradient(to bottom, #80d44e, #47ae0e); 59 | font-size: 1.4rem; 60 | outline: 0; 61 | 62 | &:hover { 63 | background: linear-gradient(to bottom, lighten(#80d44e, 10%), lighten(#47ae0e, 10%)); 64 | } 65 | } 66 | 67 | .auth__loading { 68 | display: flex; 69 | flex-direction: column; 70 | align-items: center; 71 | justify-content: center; 72 | position: absolute; 73 | background: rgba(#000, .75); 74 | top: 0; 75 | left: 0; 76 | right: 0; 77 | bottom: 0; 78 | 79 | h5 { 80 | font-size: 1.2rem; 81 | text-transform: uppercase; 82 | color: $color-white; 83 | } 84 | } 85 | 86 | .auth__form__description { 87 | color: $color-white; 88 | margin: 10px 0 15px; 89 | font-style: italic; 90 | width: 60%; 91 | } 92 | -------------------------------------------------------------------------------- /src/client/scss/modules/_issue-comments.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .issue__comments__wrapper { 4 | background: rgba($color-bg-blue, .95); 5 | position: absolute; 6 | left: 0; 7 | right: 0; 8 | bottom: 0; 9 | top: 40px; 10 | padding: 15px; 11 | padding-bottom: 75px; 12 | z-index: 3; 13 | font-size: 1.4rem; 14 | opacity: 0; 15 | display: none; 16 | 17 | &.active { 18 | display: block; 19 | animation: fadeZoomIn .5s; 20 | animation-fill-mode: forwards; 21 | } 22 | 23 | h2 { 24 | margin-top: 0; 25 | } 26 | } 27 | 28 | .issue__comments { 29 | overflow-y: scroll; 30 | position: absolute; 31 | left: 0; 32 | right: 0; 33 | bottom: 60px; 34 | top: 40px; 35 | padding: 15px; 36 | } 37 | 38 | .issue__comments__show { 39 | width: 35px; 40 | height: 24px; 41 | border-radius: 100%; 42 | background: url('./graphics/comments.svg') center center no-repeat; 43 | margin: 11px 5px 0; 44 | transition: transform .2s; 45 | 46 | &:hover { 47 | transform: scale(1.1); 48 | } 49 | } 50 | 51 | .issue__comments__hide { 52 | position: fixed; 53 | right: 10px; 54 | top: 14px; 55 | height: 25px; 56 | border-radius: 3px; 57 | background: darken($color-bg-blue, 10%); 58 | z-index: 5; 59 | } 60 | 61 | .issue__comments__add { 62 | position: fixed; 63 | bottom: 0; 64 | left: 0; 65 | right: 0; 66 | height: 60px; 67 | 68 | textarea { 69 | width: 80%; 70 | padding: 5px; 71 | float: left; 72 | border: 0; 73 | border-top: 1px solid darken($color-bg-blue, 10%); 74 | } 75 | 76 | button[type="submit"] { 77 | width: 20%; 78 | height: 60px; 79 | background-color: darken($color-bg-blue, 10%); 80 | color: $color-bg-blue-text; 81 | transition: background-color .2s; 82 | position: relative; 83 | 84 | &:hover { 85 | background-color: darken($color-bg-blue, 15%); 86 | } 87 | 88 | &.adding { 89 | background-image: url('./graphics/spinn-loader.svg'); 90 | background-position: center; 91 | background-repeat: no-repeat; 92 | font-size: 0; 93 | } 94 | } 95 | } 96 | 97 | .issue__comment { 98 | margin-bottom: 15px; 99 | } 100 | 101 | .issue__comment__name { 102 | text-transform: uppercase; 103 | font-size: 1.1rem; 104 | margin-bottom: 5px; 105 | } 106 | 107 | .issue__comment__body { 108 | background: $color-white; 109 | border-radius: 5px; 110 | padding: 10px; 111 | box-shadow: 0 1px 3px darken($color-bg-blue, 10%); 112 | 113 | p { 114 | margin: 0; 115 | margin-bottom: 10px; 116 | } 117 | } 118 | 119 | @keyframes fadeZoomIn { 120 | 0% { 121 | opacity: 0; 122 | transform: scale(0); 123 | } 124 | 100% { 125 | opacity: 1; 126 | transform: scale(1); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/client/scss/modules/_issue.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .issue { 4 | padding-bottom: 45px; 5 | } 6 | 7 | .issue__header { 8 | border-bottom: 1px solid darken($color-bg-blue, 10%); 9 | color: $color-bg-blue-text; 10 | height: 140px; 11 | padding: 0 10px; 12 | 13 | h1 { 14 | font-size: 1.8rem; 15 | margin-left: 10px; 16 | font-weight: normal; 17 | 18 | &:hover { 19 | text-decoration: underline; 20 | cursor: pointer; 21 | } 22 | } 23 | } 24 | 25 | .issue__header__top { 26 | display: flex; 27 | flex-direction: row; 28 | align-items: center; 29 | border-bottom: 2px solid darken($color-bg-blue, 10%); 30 | } 31 | 32 | .issue__header__avatars { 33 | display: flex; 34 | flex-direction: row; 35 | justify-content: center; 36 | align-items: center; 37 | padding: 20px 0; 38 | } 39 | 40 | .issue__header__avatars__avatar { 41 | width: 50%; 42 | font-size: 1.2rem; 43 | text-transform: uppercase; 44 | text-overflow: ellipsis; 45 | white-space: nowrap; 46 | overflow: hidden; 47 | margin: 0 5px; 48 | 49 | span { 50 | color: darken($color-bg-blue-text, 5%); 51 | font-size: 1.1rem; 52 | display: block; 53 | margin-top: 10px; 54 | margin-bottom: 3px; 55 | } 56 | 57 | img { 58 | width: 40px; 59 | height: 40px; 60 | border-radius: 100%; 61 | float: left; 62 | margin-right: 12px; 63 | margin-top: 5px; 64 | } 65 | } 66 | 67 | .issue__content { 68 | background: $color-bg-white; 69 | color: $color-bg-white-text; 70 | font-size: 1.4rem; 71 | padding: 10px; 72 | min-height: 160px; 73 | } 74 | 75 | .issue__footer { 76 | display: flex; 77 | flex-direction: row; 78 | position: absolute; 79 | bottom: 0; 80 | left: 0; 81 | right: 0; 82 | height: 45px; 83 | background: #fff; 84 | border-top: 1px solid darken(#F7F8FB, 15%); 85 | 86 | input[type="submit"] { 87 | display: none; 88 | } 89 | 90 | input[type="text"] { 91 | border: 0; 92 | } 93 | 94 | form:first-child { 95 | margin-right: auto; 96 | } 97 | 98 | form:last-child { 99 | margin-left: auto; 100 | } 101 | } 102 | 103 | .issue__transition { 104 | display: flex; 105 | justify-content: center; 106 | align-items: center; 107 | padding-right: 10px; 108 | } 109 | 110 | .issue__transitions { 111 | background: $color-white; 112 | position: absolute; 113 | bottom: 45px; 114 | right: 0; 115 | left: 0; 116 | padding: 10px; 117 | display: flex; 118 | flex-direction: row; 119 | align-items: flex-start; 120 | flex-wrap: wrap; 121 | border-top: 1px solid darken(#F7F8FB, 15%); 122 | 123 | .label { 124 | margin-right: 10px; 125 | margin-bottom: 5px; 126 | margin-top: 5px; 127 | 128 | &:last-child { 129 | margin-right: 0; 130 | } 131 | } 132 | } 133 | 134 | .issue__priority { 135 | display: flex; 136 | justify-content: center; 137 | align-items: center; 138 | margin-left: auto; 139 | width: 22px; 140 | height: 22px; 141 | background: $color-white; 142 | border-radius: 100%; 143 | border: 1px solid #e98641; 144 | 145 | img { 146 | width: 14px; 147 | height: auto; 148 | } 149 | } 150 | 151 | .issue__footer__label { 152 | position: absolute; 153 | z-index: 2; 154 | top: -10px; 155 | background: darken(#F7F8FB, 15%); 156 | padding: 3px; 157 | font-size: .9rem; 158 | text-transform: uppercase; 159 | left: 8px; 160 | } 161 | -------------------------------------------------------------------------------- /src/client/scss/modules/_issues.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .issues { 4 | display: flex; 5 | flex-direction: column; 6 | position: relative; 7 | 8 | &--hideOverflow { 9 | overflow: hidden; 10 | } 11 | } 12 | 13 | .issues__item { 14 | display: flex; 15 | flex-direction: row; 16 | align-items: center; 17 | justify-content: space-between; 18 | width: calc(100% - 20px); 19 | height: 60px; 20 | cursor: pointer; 21 | padding-right: 10px; 22 | padding-bottom: 15px; 23 | padding-top: 15px; 24 | background: $color-white; 25 | margin-left: 10px; 26 | margin-right: 10px; 27 | margin-bottom: 10px; 28 | box-shadow: 0 1px 3px darken($color-bg-blue, 10%); 29 | border-radius: 3px; 30 | 31 | &:nth-child(3) { 32 | margin-top: 10px; 33 | } 34 | } 35 | 36 | .issues__item__content { 37 | display: flex; 38 | flex-direction: column; 39 | width: 250px; 40 | } 41 | 42 | .issues__item__title { 43 | display: block; 44 | max-width: 250px; 45 | white-space: nowrap; 46 | overflow: hidden; 47 | text-overflow: ellipsis; 48 | color: darken($color-bg-white-text, 10%); 49 | font-size: 1.4rem; 50 | } 51 | 52 | .issues__item__project { 53 | display: block; 54 | margin-top: 5px; 55 | color: $color-bg-white-text; 56 | text-transform: uppercase; 57 | font-size: 1.2rem; 58 | } 59 | 60 | .issues__item__status { 61 | width: 10px; 62 | height: 10px; 63 | border-radius: 100%; 64 | background: green; 65 | margin-right: 10px; 66 | } 67 | 68 | .issues__item__avatar { 69 | display: flex; 70 | width: 60px; 71 | height: 60px; 72 | align-items: center; 73 | justify-content: center; 74 | position: relative; 75 | 76 | .issues__item__avatar__img { 77 | width: 38px; 78 | height: 38px; 79 | border-radius: 100%; 80 | } 81 | } 82 | 83 | .issues__item__priority { 84 | width: 20px; 85 | height: 20px; 86 | position: absolute; 87 | bottom: 6px; 88 | right: 6px; 89 | background: $color-white; 90 | padding: 5px; 91 | border-radius: 100%; 92 | } 93 | 94 | .issues__item__type { 95 | background: darken($color-bg-blue, 10%); 96 | padding: 5px; 97 | border-radius: 100%; 98 | margin-left: auto; 99 | } 100 | 101 | .issues__zero { 102 | position: relative; 103 | width: 100%; 104 | height: 100%; 105 | display: flex; 106 | flex-direction: column; 107 | align-items: center; 108 | margin-top: 50px; 109 | 110 | &:after { 111 | display: block; 112 | content: ''; 113 | position: absolute; 114 | top: 0; 115 | left: 0; 116 | right: 0; 117 | bottom: 0; 118 | background-size: cover; 119 | opacity: .1; 120 | z-index: 1; 121 | } 122 | 123 | &[hidden] { 124 | display: none; 125 | } 126 | 127 | h2 { 128 | color: $color-bg-blue-text; 129 | } 130 | } 131 | 132 | .issues__zero__done { 133 | width: 150px; 134 | height: 150px; 135 | border-radius: 100%; 136 | background: url('./graphics/done.gif'); 137 | background-size: cover; 138 | background-position: center; 139 | position: relative; 140 | z-index: 2; 141 | } 142 | 143 | .issues__loading { 144 | position: absolute; 145 | animation: scaleFill .5s; 146 | animation-fill-mode: forwards; 147 | width: 10px; 148 | height: 10px; 149 | background: $color-bg-blue; 150 | z-index: 5; 151 | border-radius: 100%; 152 | } 153 | 154 | @keyframes scaleFill { 155 | from { 156 | transform: scale(1); 157 | } 158 | 159 | to { 160 | transform: scale(100); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/client/scss/modules/_settings.scss: -------------------------------------------------------------------------------- 1 | @import 'src/client/scss/utils/_colors'; 2 | 3 | .settings { 4 | padding: 20px; 5 | height: 100%; 6 | 7 | fieldset { 8 | border: 0; 9 | border-top: 1px solid $color-bg-blue-text; 10 | padding: 0; 11 | padding-top: 15px; 12 | margin-bottom: 15px; 13 | } 14 | 15 | legend { 16 | font-size: 1.4rem; 17 | text-transform: uppercase; 18 | padding: 10px 20px; 19 | background: $color-bg-blue; 20 | margin: 0 auto; 21 | } 22 | 23 | label { 24 | font-size: 1.4rem; 25 | display: block; 26 | margin-bottom: 5px; 27 | } 28 | 29 | input[type="text"] { 30 | display: block; 31 | border: 2px solid $color-bg-blue-text; 32 | border-radius: 2px; 33 | padding: 10px; 34 | font-size: 1.4rem; 35 | color: $color-bg-blue-text; 36 | outline: 0; 37 | width: 100%; 38 | background: transparent; 39 | } 40 | } 41 | 42 | .settings__account { 43 | display: flex; 44 | flex-direction: column; 45 | align-items: center; 46 | justify-content: center; 47 | font-size: 1.4rem; 48 | text-align: center; 49 | 50 | img { 51 | width: 48px; 52 | height: 48px; 53 | border-radius: 100%; 54 | } 55 | 56 | input[type="submit"] { 57 | background: #e39a9a; 58 | color: #833737; 59 | border-radius: 2px; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/client/scss/modules/_suggest.scss: -------------------------------------------------------------------------------- 1 | suggest { 2 | display: block; 3 | width: 200px; 4 | height: 40px; 5 | position: relative; 6 | } 7 | 8 | .suggest__input { 9 | border: 1px solid #000; 10 | background: transparent; 11 | width: 100%; 12 | height: 40px; 13 | padding: 10px; 14 | color: #333; 15 | font-size: 14px; 16 | position: relative; 17 | z-index: 2; 18 | text-transform: lowercase; 19 | } 20 | 21 | .suggest__suggestion { 22 | position: absolute; 23 | width: 100%; 24 | height: 40px; 25 | padding: 10px; 26 | color: #888; 27 | font-size: 14px; 28 | line-height: 1.5; 29 | top: 0; 30 | left: 0; 31 | z-index: 1; 32 | text-transform: lowercase; 33 | } 34 | -------------------------------------------------------------------------------- /src/client/scss/utils/_colors.scss: -------------------------------------------------------------------------------- 1 | $color-white: #fff; 2 | 3 | $color-bg-blue: #f1f4f7; 4 | $color-bg-blue-text: #697583; 5 | 6 | $color-bg-white: $color-white; 7 | $color-bg-white-text: #8b8b8b; 8 | -------------------------------------------------------------------------------- /src/client/services/jira.service.js: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs/Subject' 2 | import { ipcRenderer } from 'electron' 3 | import { NgZone } from 'angular2/core' 4 | 5 | export default class JiraService { 6 | constructor (zone: NgZone) { 7 | this.issues = new Subject() 8 | this.issue = new Subject() 9 | this.comments = new Subject() 10 | this.issues$ = this.issues.asObservable() 11 | this.issue$ = this.issue.asObservable() 12 | this.comments$ = this.comments.asObservable() 13 | this.zone = zone 14 | 15 | this.onIssues() 16 | this.onIssue() 17 | this.onComments() 18 | } 19 | 20 | getIssues (jql) { 21 | ipcRenderer.send('getIssues', jql) 22 | } 23 | 24 | onIssues () { 25 | let self = this 26 | ipcRenderer.on('issues', (event, issues) => { 27 | self.zone.run(() => { 28 | self.issues.next(issues) 29 | }) 30 | }) 31 | } 32 | 33 | getIssue (id) { 34 | ipcRenderer.send('getIssue', id) 35 | } 36 | 37 | onIssue () { 38 | let self = this 39 | ipcRenderer.on('issue', (event, issue) => { 40 | self.zone.run(() => { 41 | self.issue.next(issue) 42 | }) 43 | }) 44 | } 45 | 46 | getComments (issueId) { 47 | ipcRenderer.send('getComments', issueId) 48 | } 49 | 50 | onComments () { 51 | let self = this 52 | ipcRenderer.on('comments', (event, comments) => { 53 | self.zone.run(() => { 54 | self.comments.next(comments) 55 | }) 56 | }) 57 | 58 | ipcRenderer.on('commentAdded', (event, newComment) => { 59 | self.zone.run(() => { 60 | self.comments.next(newComment) 61 | }) 62 | }) 63 | } 64 | 65 | addComment (comment) { 66 | ipcRenderer.send('addComment', comment) 67 | } 68 | 69 | getAssignable (issueId, cb) { 70 | let self = this 71 | ipcRenderer.send('getAssignable', issueId) 72 | ipcRenderer.on('assignable', (event, data) => { 73 | self.zone.run(() => { 74 | cb(data) 75 | }) 76 | }) 77 | } 78 | 79 | assignUser (issue, user) { 80 | ipcRenderer.send('assignUser', {issue: issue, user: user}) 81 | } 82 | 83 | getTransitions (issueId) { 84 | return ipcRenderer.sendSync('getTransitions', issueId) 85 | } 86 | 87 | doTransition (issueId, transitionId) { 88 | ipcRenderer.send('doTransition', {issueId, transitionId}) 89 | } 90 | 91 | isAuthed () { 92 | return ipcRenderer.sendSync('isAuthed') 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/client/services/nav.service.js: -------------------------------------------------------------------------------- 1 | export default class NavService { 2 | constructor () { 3 | this.button = {} 4 | this.button.settings = true 5 | this.button.issues = true 6 | this.button.shadow = true 7 | } 8 | 9 | show (btn) { 10 | this.button[btn] = false 11 | return this 12 | } 13 | 14 | hide (btn) { 15 | this.button[btn] = true 16 | return this 17 | } 18 | 19 | get () { 20 | return this.button 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/client/templates/app.template.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 |
12 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /src/client/templates/auth.template.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Login to your JIRA account

4 | 5 | (e.g. https://NAME.atlassian.net) 6 | 7 | 8 | 11 |
12 |
13 |
14 |
Trying to auth..
15 |
16 |
17 | -------------------------------------------------------------------------------- /src/client/templates/issue.template.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |

{{issue.fields.summary}}

6 |
7 |
8 | 9 |
10 |
11 | 12 | Reporter 13 | {{issue.fields.reporter.displayName}} 14 |
15 |
16 | 17 | Project 18 | {{issue.fields.project.name}} 19 |
20 |
21 |
22 | 23 |
24 | 25 | 43 | 44 |
45 |

Comments

46 | 47 | 48 |
49 |
50 |
{{comment.author.displayName}} - {{_strAsDate(comment.updated) | date: 'dd MMM - HH:mm'}}
51 |
52 |
53 |
54 | 55 |
56 | 57 | 65 |
66 |
67 |
68 | -------------------------------------------------------------------------------- /src/client/templates/issues.template.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 7 | 8 |
9 |
10 | {{issue.fields.summary}} 11 | {{issue.fields.project.name}} 12 |
13 | 14 |
15 |
16 |
17 |

No issues found

18 |
19 |
20 | -------------------------------------------------------------------------------- /src/client/templates/settings.template.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Settings 5 | 6 | 7 |
8 |
9 | 10 |
11 |
12 | Account settings 13 | 18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /webpack-production.config.js: -------------------------------------------------------------------------------- 1 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | entry: { 6 | app: ['./src/client/app.js'] 7 | }, 8 | module: { 9 | loaders: [ 10 | { test: /\.js$/, 11 | exclude: /node_modules/, 12 | loader: 'babel-loader' 13 | }, 14 | { 15 | test: /\.scss$/, 16 | exclude: /node_modules/, 17 | loader: ExtractTextPlugin.extract('style-loader', 'css-loader?-url&minimize!sass-loader') 18 | }, 19 | { 20 | test: /\.html$/, 21 | exclude: /node_modules/, 22 | loader: 'html-loader?attrs=false' 23 | } 24 | ] 25 | }, 26 | output: { 27 | filename: 'bundle.js', 28 | path: './' 29 | }, 30 | plugins: [ 31 | new ExtractTextPlugin('bundle.css'), 32 | new webpack.IgnorePlugin(/vertx/), 33 | new webpack.optimize.UglifyJsPlugin({ 34 | mangle: false 35 | }) 36 | ], 37 | resolve: { 38 | extensions: ['', '.scss', '.js', '.html'], 39 | modulesDirectories: ['src', 'node_modules'] 40 | }, 41 | target: 'electron', 42 | htmlLoader: { 43 | minimize: true, 44 | removeAttributeQuotes: false, 45 | caseSensitive: true, 46 | customAttrSurround: [[/#/, /(?:)/], [/\*/, /(?:)/], [/\[?\(?/, /(?:)/]], 47 | customAttrAssign: [/\)?\]?=/] 48 | } 49 | } 50 | 51 | module.exports = config 52 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 2 | const webpack = require('webpack') 3 | 4 | const config = { 5 | entry: { 6 | app: ['./src/client/app.js'] 7 | }, 8 | module: { 9 | loaders: [ 10 | { test: /\.js$/, 11 | exclude: /node_modules/, 12 | loader: 'babel-loader' 13 | }, 14 | { 15 | test: /\.scss$/, 16 | exclude: /node_modules/, 17 | loader: ExtractTextPlugin.extract('style-loader', 'css-loader?-url&sourceMap!sass-loader') 18 | }, 19 | { 20 | test: /\.html$/, 21 | exclude: /node_modules/, 22 | loader: 'html-loader?attrs=false' 23 | } 24 | ] 25 | }, 26 | output: { 27 | filename: 'bundle.js', 28 | path: './' 29 | }, 30 | plugins: [ 31 | new ExtractTextPlugin('bundle.css'), 32 | new webpack.IgnorePlugin(/vertx/) 33 | ], 34 | resolve: { 35 | extensions: ['', '.scss', '.js', '.html'], 36 | modulesDirectories: ['src', 'node_modules'] 37 | }, 38 | devtool: '#inline-source-map', 39 | target: 'electron' 40 | } 41 | 42 | module.exports = config 43 | --------------------------------------------------------------------------------