diff --git a/images/connect-art.png b/images/connect-art.png
new file mode 100644
index 0000000000..49328bdbd8
Binary files /dev/null and b/images/connect-art.png differ
diff --git a/images/connect-art@2x.png b/images/connect-art@2x.png
new file mode 100644
index 0000000000..4132e5725e
Binary files /dev/null and b/images/connect-art@2x.png differ
diff --git a/images/connect-to-hub.png b/images/connect-to-hub.png
new file mode 100644
index 0000000000..8153879a58
Binary files /dev/null and b/images/connect-to-hub.png differ
diff --git a/images/connect-to-hub@2x.png b/images/connect-to-hub@2x.png
new file mode 100644
index 0000000000..743c5bbe34
Binary files /dev/null and b/images/connect-to-hub@2x.png differ
diff --git a/images/docker-terminal.png b/images/docker-terminal.png
deleted file mode 100644
index dbbff04b27..0000000000
Binary files a/images/docker-terminal.png and /dev/null differ
diff --git a/images/docker-terminal@2x.png b/images/docker-terminal@2x.png
deleted file mode 100644
index 36b5e240e9..0000000000
Binary files a/images/docker-terminal@2x.png and /dev/null differ
diff --git a/images/feedback.png b/images/feedback.png
new file mode 100644
index 0000000000..f4a0d8251d
Binary files /dev/null and b/images/feedback.png differ
diff --git a/images/feedback@2x.png b/images/feedback@2x.png
new file mode 100644
index 0000000000..bfe5e7f95d
Binary files /dev/null and b/images/feedback@2x.png differ
diff --git a/images/inspection.png b/images/inspection.png
new file mode 100644
index 0000000000..68f66aef01
Binary files /dev/null and b/images/inspection.png differ
diff --git a/images/inspection@2x.png b/images/inspection@2x.png
new file mode 100644
index 0000000000..ecebe23041
Binary files /dev/null and b/images/inspection@2x.png differ
diff --git a/images/logo-active.png b/images/logo-active.png
new file mode 100644
index 0000000000..ef310bcc11
Binary files /dev/null and b/images/logo-active.png differ
diff --git a/images/logo-active@2x.png b/images/logo-active@2x.png
new file mode 100644
index 0000000000..e36614aa02
Binary files /dev/null and b/images/logo-active@2x.png differ
diff --git a/images/preferences.png b/images/preferences.png
index 2cefc24c9c..c533e97dd9 100644
Binary files a/images/preferences.png and b/images/preferences.png differ
diff --git a/images/preferences@2x.png b/images/preferences@2x.png
index e6d041f75f..3aba6c049e 100644
Binary files a/images/preferences@2x.png and b/images/preferences@2x.png differ
diff --git a/images/private.png b/images/private.png
new file mode 100644
index 0000000000..639f54f60a
Binary files /dev/null and b/images/private.png differ
diff --git a/images/private@2x.png b/images/private@2x.png
new file mode 100644
index 0000000000..3009a51614
Binary files /dev/null and b/images/private@2x.png differ
diff --git a/images/report-issue.png b/images/report-issue.png
deleted file mode 100644
index b6c1f7be4e..0000000000
Binary files a/images/report-issue.png and /dev/null differ
diff --git a/images/report-issue@2x.png b/images/report-issue@2x.png
deleted file mode 100644
index 9f7146b662..0000000000
Binary files a/images/report-issue@2x.png and /dev/null differ
diff --git a/images/user.png b/images/user.png
new file mode 100644
index 0000000000..82cae572a0
Binary files /dev/null and b/images/user.png differ
diff --git a/images/user@2x.png b/images/user@2x.png
new file mode 100644
index 0000000000..0d0991cece
Binary files /dev/null and b/images/user@2x.png differ
diff --git a/images/userdropdown.png b/images/userdropdown.png
new file mode 100644
index 0000000000..ce2630e59c
Binary files /dev/null and b/images/userdropdown.png differ
diff --git a/images/userdropdown@2x.png b/images/userdropdown@2x.png
new file mode 100644
index 0000000000..dab8b11562
Binary files /dev/null and b/images/userdropdown@2x.png differ
diff --git a/images/whaleicon.png b/images/whaleicon.png
new file mode 100644
index 0000000000..410aef41fe
Binary files /dev/null and b/images/whaleicon.png differ
diff --git a/images/whaleicon@2x.png b/images/whaleicon@2x.png
new file mode 100644
index 0000000000..941ed32fe7
Binary files /dev/null and b/images/whaleicon@2x.png differ
diff --git a/package.json b/package.json
index cbd029472d..9695288c7f 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,7 @@
"classnames": "^1.2.0",
"coveralls": "^2.11.2",
"deep-extend": "^0.4.0",
- "dockerode": "^2.1.1",
+ "dockerode": "^2.1.4",
"exec": "0.2.0",
"install": "^0.1.8",
"jquery": "^2.1.3",
@@ -83,7 +83,8 @@
"request": "^2.55.0",
"request-progress": "^0.3.1",
"rimraf": "^2.3.2",
- "underscore": "^1.8.3"
+ "underscore": "^1.8.3",
+ "validator": "^3.39.0"
},
"devDependencies": {
"babel": "^5.1.10",
diff --git a/src/actions/AccountActions.js b/src/actions/AccountActions.js
new file mode 100644
index 0000000000..fce7e9ebd5
--- /dev/null
+++ b/src/actions/AccountActions.js
@@ -0,0 +1,31 @@
+import alt from '../alt';
+import hub from '../utils/HubUtil';
+
+class AccountActions {
+ login (username, password) {
+ this.dispatch({});
+ hub.login(username, password);
+ }
+
+ signup (username, password, email, subscribe) {
+ this.dispatch({});
+ hub.signup(username, password, email, subscribe);
+ }
+
+ logout () {
+ this.dispatch({});
+ hub.logout();
+ }
+
+ skip () {
+ this.dispatch({});
+ hub.setPrompted(true);
+ }
+
+ verify () {
+ this.dispatch({});
+ hub.verify();
+ }
+}
+
+export default alt.createActions(AccountActions);
diff --git a/src/actions/AccountServerActions.js b/src/actions/AccountServerActions.js
new file mode 100644
index 0000000000..873a6a37ae
--- /dev/null
+++ b/src/actions/AccountServerActions.js
@@ -0,0 +1,16 @@
+import alt from '../alt';
+
+class AccountServerActions {
+ constructor () {
+ this.generateActions(
+ 'signedup',
+ 'loggedin',
+ 'loggedout',
+ 'prompted',
+ 'errors',
+ 'verified'
+ );
+ }
+}
+
+export default alt.createActions(AccountServerActions);
diff --git a/src/actions/RepositoryActions.js b/src/actions/RepositoryActions.js
new file mode 100644
index 0000000000..f5bfc92f70
--- /dev/null
+++ b/src/actions/RepositoryActions.js
@@ -0,0 +1,21 @@
+import alt from '../alt';
+import regHubUtil from '../utils/RegHubUtil';
+
+class RepositoryActions {
+ recommended () {
+ this.dispatch({});
+ regHubUtil.recommended();
+ }
+
+ search (query) {
+ this.dispatch({});
+ regHubUtil.search(query);
+ }
+
+ repos () {
+ this.dispatch({});
+ regHubUtil.repos();
+ }
+}
+
+export default alt.createActions(RepositoryActions);
diff --git a/src/actions/RepositoryServerActions.js b/src/actions/RepositoryServerActions.js
new file mode 100644
index 0000000000..b49e4d60d6
--- /dev/null
+++ b/src/actions/RepositoryServerActions.js
@@ -0,0 +1,14 @@
+import alt from '../alt';
+
+class RepositoryServerActions {
+ constructor () {
+ this.generateActions(
+ 'reposLoading',
+ 'resultsUpdated',
+ 'recommendedUpdated',
+ 'reposUpdated'
+ );
+ }
+}
+
+export default alt.createActions(RepositoryServerActions);
diff --git a/src/actions/TagActions.js b/src/actions/TagActions.js
new file mode 100644
index 0000000000..36ffd843e1
--- /dev/null
+++ b/src/actions/TagActions.js
@@ -0,0 +1,11 @@
+import alt from '../alt';
+import regHubUtil from '../utils/RegHubUtil';
+
+class TagActions {
+ tags (repo) {
+ this.dispatch({repo});
+ regHubUtil.tags(repo);
+ }
+}
+
+export default alt.createActions(TagActions);
diff --git a/src/actions/TagServerActions.js b/src/actions/TagServerActions.js
new file mode 100644
index 0000000000..4149b81cb4
--- /dev/null
+++ b/src/actions/TagServerActions.js
@@ -0,0 +1,11 @@
+import alt from '../alt';
+
+class TagServerActions {
+ constructor () {
+ this.generateActions(
+ 'tagsUpdated'
+ );
+ }
+}
+
+export default alt.createActions(TagServerActions);
diff --git a/src/app.js b/src/app.js
index 3ef7360b02..fa6d1c840d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -9,10 +9,24 @@ var metrics = require('./utils/MetricsUtil');
var router = require('./router');
var template = require('./menutemplate');
var webUtil = require('./utils/WebUtil');
+var hubUtil = require('./utils/HubUtil');
var urlUtil = require ('./utils/URLUtil');
var app = remote.require('app');
var request = require('request');
var docker = require('./utils/DockerUtil');
+var hub = require('./utils/HubUtil');
+var Router = require('react-router');
+var routes = require('./routes');
+var routerContainer = require('./router');
+var repositoryActions = require('./actions/RepositoryActions');
+
+hubUtil.init();
+
+if (hubUtil.loggedin()) {
+ repositoryActions.repos();
+}
+
+repositoryActions.recommended();
webUtil.addWindowSizeSaving();
webUtil.addLiveReload();
@@ -27,12 +41,20 @@ setInterval(function () {
metrics.track('app heartbeat');
}, 14400000);
+var router = Router.create({
+ routes: routes
+});
router.run(Handler => React.render(, document.body));
+routerContainer.set(router);
SetupStore.setup().then(() => {
Menu.setApplicationMenu(Menu.buildFromTemplate(template()));
docker.init();
- router.transitionTo('search');
+ if (!hub.prompted() && !hub.loggedin()) {
+ router.transitionTo('login');
+ } else {
+ router.transitionTo('search');
+ }
}).catch(err => {
metrics.track('Setup Failed', {
step: 'catch',
@@ -63,3 +85,7 @@ ipc.on('application:open-url', opts => {
urlUtil.openUrl(opts.url, flags, app.getVersion());
});
});
+
+module.exports = {
+ router: router
+};
diff --git a/src/browser.js b/src/browser.js
index ced95b1054..b731e338ef 100644
--- a/src/browser.js
+++ b/src/browser.js
@@ -18,7 +18,6 @@ try {
settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'settings.json'), 'utf8'));
} catch (err) {}
-
var openURL = null;
app.on('open-url', function (event, url) {
event.preventDefault();
@@ -28,13 +27,13 @@ app.on('open-url', function (event, url) {
app.on('ready', function () {
var mainWindow = new BrowserWindow({
width: size.width || 1000,
- height: size.height || 700,
+ height: size.height || 780,
'min-width': 1000,
- 'min-height': 700,
+ 'min-height': 600,
'standard-window': false,
resizable: true,
frame: false,
- show: true,
+ show: false,
});
mainWindow.loadUrl(path.normalize('file://' + path.join(__dirname, '..', 'build/index.html')));
diff --git a/src/components/Account.react.js b/src/components/Account.react.js
new file mode 100644
index 0000000000..bf5c486d4c
--- /dev/null
+++ b/src/components/Account.react.js
@@ -0,0 +1,75 @@
+var React = require('react/addons');
+var Router = require('react-router');
+var RetinaImage = require('react-retina-image');
+var Header = require('./Header.react');
+var metrics = require('../utils/MetricsUtil');
+var accountStore = require('../stores/AccountStore');
+var accountActions = require('../actions/AccountActions');
+
+module.exports = React.createClass({
+ mixins: [Router.Navigation],
+
+ getInitialState: function () {
+ return accountStore.getState();
+ },
+
+ componentDidMount: function () {
+ document.addEventListener('keyup', this.handleDocumentKeyUp, false);
+ accountStore.listen(this.update);
+ },
+
+ componentWillUnmount: function () {
+ document.removeEventListener('keyup', this.handleDocumentKeyUp, false);
+ accountStore.unlisten(this.update);
+ },
+
+ componentWillUpdate: function (nextProps, nextState) {
+ if (!this.state.username && nextState.username) {
+ if (nextState.prompted) {
+ this.goBack();
+ } else {
+ this.transitionTo('search');
+ }
+ }
+ },
+
+ handleSkip: function () {
+ accountActions.skip();
+ this.transitionTo('search');
+ metrics.track('Skipped Login');
+ },
+
+ handleClose: function () {
+ this.goBack();
+ metrics.track('Closed Login');
+ },
+
+ update: function () {
+ this.setState(accountStore.getState());
+ },
+
+ render: function () {
+ let close = this.state.prompted ?
+ Close :
+ Skip For Now;
+
+ return (
+
+
+
+ {close}
+
+
+
+
+
+
+
Connect to Docker Hub
+
Pull and run private Docker Hub images by connecting your Docker Hub account to Kitematic.
+
+
+
+
+ );
+ }
+});
diff --git a/src/components/AccountLogin.react.js b/src/components/AccountLogin.react.js
new file mode 100644
index 0000000000..d189f1c7a6
--- /dev/null
+++ b/src/components/AccountLogin.react.js
@@ -0,0 +1,85 @@
+var _ = require('underscore');
+var React = require('react/addons');
+var Router = require('react-router');
+var validator = require('validator');
+var accountActions = require('../actions/AccountActions');
+var metrics = require('../utils/MetricsUtil');
+var shell = require('shell');
+
+module.exports = React.createClass({
+ mixins: [Router.Navigation, React.addons.LinkedStateMixin],
+
+ getInitialState: function () {
+ return {
+ username: '',
+ password: '',
+ errors: {}
+ };
+ },
+
+ componentDidMount: function () {
+ React.findDOMNode(this.refs.usernameInput).focus();
+ },
+
+ componentWillReceiveProps: function (nextProps) {
+ this.setState({errors: nextProps.errors});
+ },
+
+ validate: function () {
+ let errors = {};
+ if (!validator.isLowercase(this.state.username) || !validator.isAlphanumeric(this.state.username) || !validator.isLength(this.state.username, 4, 30)) {
+ errors.username = 'Must be 4-30 lower case letters or numbers';
+ }
+
+ if (!validator.isLength(this.state.password, 5)) {
+ errors.password = 'Must be at least 5 characters long';
+ }
+
+ return errors;
+ },
+
+ handleBlur: function () {
+ this.setState({errors: _.omit(this.validate(), (val, key) => !this.state[key].length)});
+ },
+
+ handleLogin: function () {
+ let errors = this.validate();
+ this.setState({errors});
+
+ if (_.isEmpty(errors)) {
+ accountActions.login(this.state.username, this.state.password);
+ metrics.track('Clicked Log In');
+ }
+ },
+
+ handleClickSignup: function () {
+ if (!this.props.loading) {
+ this.replaceWith('signup');
+ metrics.track('Switched to Sign Up');
+ }
+ },
+
+ handleClickForgotPassword: function () {
+ shell.openExternal('https://hub.docker.com/account/forgot-password/');
+ },
+
+ render: function () {
+ let loading = this.props.loading ? : null;
+ return (
+
+ );
+ }
+});
diff --git a/src/components/AccountSignup.react.js b/src/components/AccountSignup.react.js
new file mode 100644
index 0000000000..8858a451c0
--- /dev/null
+++ b/src/components/AccountSignup.react.js
@@ -0,0 +1,91 @@
+var _ = require('underscore');
+var React = require('react/addons');
+var Router = require('react-router');
+var validator = require('validator');
+var accountActions = require('../actions/AccountActions');
+var metrics = require('../utils/MetricsUtil');
+
+module.exports = React.createClass({
+ mixins: [Router.Navigation, React.addons.LinkedStateMixin],
+
+ getInitialState: function () {
+ return {
+ username: '',
+ password: '',
+ email: '',
+ subscribe: true,
+ errors: {}
+ };
+ },
+
+ componentDidMount: function () {
+ React.findDOMNode(this.refs.usernameInput).focus();
+ },
+
+ componentWillReceiveProps: function (nextProps) {
+ this.setState({errors: nextProps.errors});
+ },
+
+ validate: function () {
+ let errors = {};
+ if (!validator.isLowercase(this.state.username) || !validator.isAlphanumeric(this.state.username) || !validator.isLength(this.state.username, 4, 30)) {
+ errors.username = 'Must be 4-30 lower case letters or numbers';
+ }
+
+ if (!validator.isLength(this.state.password, 5)) {
+ errors.password = 'Must be at least 5 characters long';
+ }
+
+ if (!validator.isEmail(this.state.email)) {
+ errors.email = 'Must be a valid email address';
+ }
+ return errors;
+ },
+
+ handleBlur: function () {
+ this.setState({errors: _.omit(this.validate(), (val, key) => !this.state[key].length)});
+ },
+
+ handleSignUp: function () {
+ let errors = this.validate();
+ this.setState({errors});
+
+ if (_.isEmpty(errors)) {
+ accountActions.signup(this.state.username, this.state.password, this.state.email, this.state.subscribe);
+ metrics.track('Clicked Signed Up');
+ }
+ },
+
+ handleClickLogin: function () {
+ if (!this.props.loading) {
+ this.replaceWith('login');
+ metrics.track('Switched to Log In');
+ }
+ },
+
+ render: function () {
+ let loading = this.props.loading ? : null;
+ return (
+
+ );
+ }
+});
diff --git a/src/components/ContainerHome.react.js b/src/components/ContainerHome.react.js
index 4a9d7618ea..6b63bab4b3 100644
--- a/src/components/ContainerHome.react.js
+++ b/src/components/ContainerHome.react.js
@@ -51,13 +51,11 @@ var ContainerHome = React.createClass({
);
} else if (this.props.container && this.props.container.State.Downloading) {
- if (this.props.container.Progress !== undefined) {
-
+ if (this.props.container.Progress) {
let values = [];
let sum = 0.0;
for (let i = 0; i < this.props.container.Progress.amount; i++) {
-
values.push(Math.round(this.props.container.Progress.progress[i].value));
sum += this.props.container.Progress.progress[i].value;
}
@@ -67,9 +65,9 @@ var ContainerHome = React.createClass({
body = (
Downloading Image
-
{Math.round(sum*100)/100}%
+
{(Math.round(sum*100)/100).toFixed(2)}%
-
+
);
diff --git a/src/components/ContainerHomeLogs.react.js b/src/components/ContainerHomeLogs.react.js
index f8d7ac4570..d31a4df92e 100644
--- a/src/components/ContainerHomeLogs.react.js
+++ b/src/components/ContainerHomeLogs.react.js
@@ -64,7 +64,7 @@ module.exports = React.createClass({
},
render: function () {
var logs = this.state.logs.map(function (l, i) {
- return ;
+ return ;
});
if (logs.length === 0) {
logs = "No logs for this container.";
diff --git a/src/components/Containers.react.js b/src/components/Containers.react.js
index a588db73c9..b7ef82dcb5 100644
--- a/src/components/Containers.react.js
+++ b/src/components/Containers.react.js
@@ -5,10 +5,7 @@ var Router = require('react-router');
var containerStore = require('../stores/ContainerStore');
var ContainerList = require('./ContainerList.react');
var Header = require('./Header.react');
-var ipc = require('ipc');
-var remote = require('remote');
var metrics = require('../utils/MetricsUtil');
-var autoUpdater = remote.require('auto-updater');
var RetinaImage = require('react-retina-image');
var shell = require('shell');
var machine = require('../utils/DockerMachineUtil');
@@ -21,31 +18,21 @@ var Containers = React.createClass({
getInitialState: function () {
return {
sidebarOffset: 0,
- containers: {},
- sorted: [],
- updateAvailable: false,
- currentButtonLabel: ''
+ containers: containerStore.getState().containers,
+ sorted: this.sorted(containerStore.getState().containers)
};
},
componentDidMount: function () {
containerStore.listen(this.update);
-
- ipc.on('application:update-available', () => {
- this.setState({
- updateAvailable: true
- });
- });
- autoUpdater.checkForUpdates();
},
componentDidUnmount: function () {
containerStore.unlisten(this.update);
},
- update: function () {
- let containers = containerStore.getState().containers;
- let sorted = _.values(containers).sort(function (a, b) {
+ sorted: function (containers) {
+ return _.values(containers).sort(function (a, b) {
if (a.State.Downloading && !b.State.Downloading) {
return -1;
} else if (!a.State.Downloading && b.State.Downloading) {
@@ -60,6 +47,11 @@ var Containers = React.createClass({
}
}
});
+ },
+
+ update: function () {
+ let containers = containerStore.getState().containers;
+ let sorted = this.sorted(containerStore.getState().containers);
let name = this.context.router.getCurrentParams().name;
if (containerStore.getState().pending) {
@@ -97,11 +89,6 @@ var Containers = React.createClass({
metrics.track('Pressed New Container');
},
- handleAutoUpdateClick: function () {
- metrics.track('Restarted to Update');
- ipc.send('application:quit-install');
- },
-
handleClickPreferences: function () {
metrics.track('Opened Preferences', {
from: 'app'
@@ -164,12 +151,6 @@ var Containers = React.createClass({
if (this.state.sidebarOffset) {
sidebarHeaderClass += ' sep';
}
- var updateWidget;
- if (this.state.updateAvailable) {
- updateWidget = (
- New Update
- );
- }
var container = this.context.router.getCurrentParams().name ? this.state.containers[this.context.router.getCurrentParams().name] : {};
return (
@@ -187,14 +168,11 @@ var Containers = React.createClass({
-
-
{this.state.currentButtonLabel}
-
-
-
- {updateWidget}
-
-
+
+
diff --git a/src/components/Header.react.js b/src/components/Header.react.js
index c608d710fa..cc54ce9803 100644
--- a/src/components/Header.react.js
+++ b/src/components/Header.react.js
@@ -1,18 +1,49 @@
var React = require('react/addons');
var remote = require('remote');
var RetinaImage = require('react-retina-image');
+var remote = require('remote');
+var ipc = require('ipc');
+var autoUpdater = remote.require('auto-updater');
+var metrics = require('../utils/MetricsUtil');
+var Menu = remote.require('menu');
+var MenuItem = remote.require('menu-item');
+var accountStore = require('../stores/AccountStore');
+var accountActions = require('../actions/AccountActions');
+var Router = require('react-router');
+var classNames = require('classNames');
var Header = React.createClass({
+ mixins: [Router.Navigation],
getInitialState: function () {
return {
- fullscreen: false
+ fullscreen: false,
+ updateAvailable: false,
+ username: accountStore.getState().username,
+ verified: accountStore.getState().verified
};
},
componentDidMount: function () {
document.addEventListener('keyup', this.handleDocumentKeyUp, false);
+
+ accountStore.listen(this.update);
+
+ ipc.on('application:update-available', () => {
+ this.setState({
+ updateAvailable: true
+ });
+ });
+ autoUpdater.checkForUpdates();
},
componentWillUnmount: function () {
document.removeEventListener('keyup', this.handleDocumentKeyUp, false);
+ accountStore.unlisten(this.update);
+ },
+ update: function () {
+ let accountState = accountStore.getState();
+ this.setState({
+ username: accountState.username,
+ verified: accountState.verified
+ });
},
handleDocumentKeyUp: function (e) {
if (e.keyCode === 27 && remote.getCurrentWindow().isFullScreen()) {
@@ -35,30 +66,89 @@ var Header = React.createClass({
handleFullscreenHover: function () {
this.update();
},
+ handleAutoUpdateClick: function () {
+ metrics.track('Restarted to Update');
+ ipc.send('application:quit-install');
+ },
+ handleUserClick: function (e) {
+ let menu = new Menu();
+
+ if (!this.state.verified) {
+ menu.append(new MenuItem({ label: 'I\'ve Verified My Email Address', click: this.handleVerifyClick}));
+ }
+
+ menu.append(new MenuItem({ label: 'Sign Out', click: this.handleLogoutClick}));
+ menu.popup(remote.getCurrentWindow(), e.currentTarget.offsetLeft, e.currentTarget.offsetTop + e.currentTarget.clientHeight + 10);
+ },
+ handleLoginClick: function () {
+ this.transitionTo('login');
+ metrics.track('Opened Log In Screen');
+ },
+ handleLogoutClick: function () {
+ metrics.track('Logged Out');
+ accountActions.logout();
+ },
+ handleVerifyClick: function () {
+ metrics.track('Verified Account', {
+ from: 'header'
+ });
+ accountActions.verify();
+ },
render: function () {
+ let updateWidget = this.state.updateAvailable ? UPDATE NOW : null;
+ let buttons;
if (this.state.fullscreen) {
- return (
-
-
-
+ buttons = (
+
);
} else {
- return (
-
-
-
+ buttons = (
+
);
}
+
+ let username;
+ if (this.props.hideLogin) {
+ username = null;
+ } else if (this.state.username) {
+ username = (
+
+ {this.state.username} {this.state.verified ? null : '(Unverified)'}
+
+ );
+ } else {
+ username = (
+
+ Log In
+
+ );
+ }
+
+ let headerClasses = classNames({
+ bordered: !this.props.hideLogin,
+ header: true,
+ 'no-drag': true
+ });
+
+ return (
+
+ {buttons}
+
+ {updateWidget}
+
+
+ {username}
+
+
+ );
}
});
diff --git a/src/components/ImageCard.react.js b/src/components/ImageCard.react.js
index 67a0a0a229..5a94d48859 100644
--- a/src/components/ImageCard.react.js
+++ b/src/components/ImageCard.react.js
@@ -1,13 +1,15 @@
var $ = require('jquery');
var React = require('react/addons');
var Router = require('react-router');
+var shell = require('shell');
var RetinaImage = require('react-retina-image');
var metrics = require('../utils/MetricsUtil');
var OverlayTrigger = require('react-bootstrap').OverlayTrigger;
var Tooltip = require('react-bootstrap').Tooltip;
-var util = require('../utils/Util');
var containerActions = require('../actions/ContainerActions');
var containerStore = require('../stores/ContainerStore');
+var tagStore = require('../stores/TagStore');
+var tagActions = require('../actions/TagActions');
var ImageCard = React.createClass({
mixins: [Router.Navigation],
@@ -17,6 +19,23 @@ var ImageCard = React.createClass({
chosenTag: 'latest'
};
},
+ componentDidMount: function () {
+ tagStore.listen(this.update);
+ },
+ componentWillUnmount: function () {
+ tagStore.unlisten(this.update);
+ },
+ update: function () {
+ let repo = this.props.image.namespace + '/' + this.props.image.name;
+ let state = tagStore.getState();
+ if (this.state.tags.length && !state.tags[repo]) {
+ $(this.getDOMNode()).find('.tag-overlay').fadeOut(300);
+ }
+ this.setState({
+ loading: tagStore.getState().loading[repo] || false,
+ tags: tagStore.getState().tags[repo] || []
+ });
+ },
handleTagClick: function (tag) {
this.setState({
chosenTag: tag
@@ -25,72 +44,55 @@ var ImageCard = React.createClass({
$tagOverlay.fadeOut(300);
metrics.track('Selected Image Tag');
},
- handleClick: function (repository) {
+ handleClick: function () {
metrics.track('Created Container', {
- from: 'search'
+ from: 'search',
+ private: this.props.image.is_private,
+ official: this.props.image.namespace === 'library',
+ userowned: this.props.image.is_user_repo,
+ recommended: this.props.image.is_recommended
});
- let name = containerStore.generateName(repository);
- containerActions.run(name, repository, this.state.chosenTag);
+ let name = containerStore.generateName(this.props.image.name);
+ let repo = this.props.image.namespace === 'library' ? this.props.image.name : this.props.image.namespace + '/' + this.props.image.name;
+ containerActions.run(name, repo, this.state.chosenTag);
this.transitionTo('containerHome', {name});
},
- handleTagOverlayClick: function (name) {
- var $tagOverlay = $(this.getDOMNode()).find('.tag-overlay');
+ handleTagOverlayClick: function () {
+ let $tagOverlay = $(this.getDOMNode()).find('.tag-overlay');
$tagOverlay.fadeIn(300);
- $.get('https://registry.hub.docker.com/v1/repositories/' + name + '/tags', result => {
- this.setState({
- tags: result
- });
- });
+ tagActions.tags(this.props.image.namespace + '/' + this.props.image.name);
},
handleCloseTagOverlay: function () {
var $tagOverlay = $(this.getDOMNode()).find('.tag-overlay');
$tagOverlay.fadeOut(300);
},
handleRepoClick: function () {
- var $repoUri = 'https://registry.hub.docker.com/';
- if (this.props.image.is_official) {
- $repoUri = $repoUri + "_/";
+ var repoUri = 'https://registry.hub.docker.com/';
+ if (this.props.image.namespace === 'library') {
+ repoUri = repoUri + '_/' + this.props.image.name;
} else {
- $repoUri = $repoUri + "u/";
+ repoUri = repoUri + 'u/' + this.props.image.namespace + '/' + this.props.image.name;
}
- util.exec(['open', $repoUri + this.props.image.name]);
- },
- componentDidMount: function() {
- $.get('https://registry.hub.docker.com/v1/repositories/' + this.props.image.name + '/tags', result => {
- this.setState({
- tags: result,
- chosenTag: result[0].name
- });
- });
+ shell.openExternal(repoUri);
},
render: function () {
var self = this;
- var name;
- var imageNameTokens = this.props.image.name.split('/');
- var namespace;
- var repo;
- if (imageNameTokens.length > 1) {
- namespace = imageNameTokens[0];
- repo = imageNameTokens[1];
- } else {
- namespace = "official";
- repo = imageNameTokens[0];
- }
- if (this.props.image.is_official) {
+ let name;
+ if (this.props.image.namespace === 'library') {
name = (
-
{namespace}
+
official
View on Docker Hub}>
- {repo}
+ {this.props.image.name}
);
} else {
name = (
-
{namespace}
+
{this.props.image.namespace}
View on Docker Hub}>
- {repo}
+ {this.props.image.name}
);
@@ -111,12 +113,16 @@ var ImageCard = React.createClass({
imgsrc = 'http://kitematic.com/recommended/kitematic_html.png';
}
var tags;
- if (self.state.tags.length > 0) {
+ if (self.state.loading) {
+ tags =
;
+ } else if (self.state.tags.length === 0) {
+ tags =
No Tags;
+ } else {
var tagDisplay = self.state.tags.map(function (t) {
- if (t.name === self.state.chosenTag) {
- return
{t.name}
;
+ if (t === self.state.chosenTag) {
+ return
{t}
;
} else {
- return
{t.name}
;
+ return
{t}
;
}
});
tags = (
@@ -124,13 +130,15 @@ var ImageCard = React.createClass({
{tagDisplay}
);
- } else {
- tags =
;
}
- var officialBadge;
- if (this.props.image.is_official) {
- officialBadge = (
-
+ var badge = null;
+ if (this.props.image.namespace === 'library') {
+ badge = (
+
+ );
+ } else if (this.props.image.is_private) {
+ badge = (
+
);
}
return (
@@ -143,7 +151,7 @@ var ImageCard = React.createClass({
- {officialBadge}
+ {badge}
{name}
@@ -152,20 +160,16 @@ var ImageCard = React.createClass({
{description}
-
Favorites}>
-
-
- {this.props.image.star_count}
-
-
+
+
+ {this.props.image.star_count}
+
- Change Tag}>
- {this.state.chosenTag}
-
+ {this.state.chosenTag}
diff --git a/src/components/NewContainer.react.js b/src/components/NewContainer.react.js
deleted file mode 100644
index cc6d7520c1..0000000000
--- a/src/components/NewContainer.react.js
+++ /dev/null
@@ -1,171 +0,0 @@
-var _ = require('underscore');
-var $ = require('jquery');
-var React = require('react');
-var RetinaImage = require('react-retina-image');
-var Radial = require('./Radial.react');
-var ImageCard = require('./ImageCard.react');
-var Promise = require('bluebird');
-var metrics = require('../utils/MetricsUtil');
-var classNames = require('classnames');
-
-var _recommended = [];
-var _searchPromise = null;
-
-var NewContainer = React.createClass({
- getInitialState: function () {
- return {
- query: '',
- loading: false,
- results: _recommended
- };
- },
- componentDidMount: function () {
- this.refs.searchInput.getDOMNode().focus();
- this.recommended();
- },
- componentWillUnmount: function () {
- if (_searchPromise) {
- _searchPromise.cancel();
- }
- },
- search: function (query) {
- if (_searchPromise) {
- _searchPromise.cancel();
- _searchPromise = null;
- }
-
- if (!query.length) {
- this.setState({
- query: query,
- results: _recommended,
- loading: false
- });
- return;
- }
-
- this.setState({
- query: query,
- loading: true
- });
-
- _searchPromise = Promise.delay(200).then(() => Promise.resolve($.get('https://registry.hub.docker.com/v1/search?q=' + query))).cancellable().then(data => {
- metrics.track('Searched for Images');
- this.setState({
- results: data.results,
- query: query,
- loading: false
- });
- _searchPromise = null;
- }).catch(Promise.CancellationError, () => {
- });
- },
- recommended: function () {
- if (_recommended.length) {
- return;
- }
- Promise.resolve($.ajax({
- url: 'https://kitematic.com/recommended.json',
- cache: false,
- dataType: 'json',
- })).then(res => res.repos).map(repo => {
- var query = repo.repo;
- var vals = query.split('/');
- if (vals.length === 1) {
- query = 'library/' + vals[0];
- }
- return $.get('https://registry.hub.docker.com/v1/repositories_info/' + query).then(data => {
- var res = _.extend(data, repo);
- res.description = data.short_description;
- res.is_official = data.namespace === 'library';
- res.name = data.repo;
- res.star_count = data.stars;
- return res;
- });
- }).then(results => {
- _recommended = results.filter(r => !!r);
- if (!this.state.query.length && this.isMounted()) {
- this.setState({
- results: _recommended
- });
- }
- }).catch(err => {
- console.log(err);
- });
- },
- handleChange: function (e) {
- var query = e.target.value;
- if (query === this.state.query) {
- return;
- }
- this.search(query);
- },
- render: function () {
- var title = this.state.query ? 'Results' : 'Recommended';
- var data = this.state.results;
- var results;
- if (data.length) {
- var items = data.map(function (image) {
- return (
-
- );
- });
-
- results = (
-
- {items}
-
- );
- } else {
- if (this.state.results.length === 0 && this.state.query === '') {
- results = (
-
- );
- } else {
- results = (
-
-
Cannot find a matching image.
-
- );
- }
- }
- var loadingClasses = classNames({
- hidden: !this.state.loading,
- loading: true
- });
- var magnifierClasses = classNames({
- hidden: this.state.loading,
- icon: true,
- 'icon-magnifier': true,
- 'search-icon': true
- });
- return (
-
-
-
-
- Select a Docker image to create a new container.
-
-
-
-
-
{title}
- {results}
-
-
-
- );
- }
-});
-
-module.exports = NewContainer;
diff --git a/src/components/NewContainerSearch.react.js b/src/components/NewContainerSearch.react.js
index 88d656f984..959885592a 100644
--- a/src/components/NewContainerSearch.react.js
+++ b/src/components/NewContainerSearch.react.js
@@ -1,32 +1,57 @@
var _ = require('underscore');
-var $ = require('jquery');
var React = require('react/addons');
+var Router = require('react-router');
var RetinaImage = require('react-retina-image');
-var Radial = require('./Radial.react');
var ImageCard = require('./ImageCard.react');
var Promise = require('bluebird');
var metrics = require('../utils/MetricsUtil');
var classNames = require('classnames');
+var repositoryActions = require('../actions/RepositoryActions');
+var repositoryStore = require('../stores/RepositoryStore');
+var accountStore = require('../stores/AccountStore');
+var accountActions = require('../actions/AccountActions');
-var _recommended = [];
var _searchPromise = null;
module.exports = React.createClass({
+ mixins: [Router.Navigation, Router.State],
getInitialState: function () {
return {
query: '',
- loading: false,
- results: _recommended
+ loading: repositoryStore.loading(),
+ repos: repositoryStore.all(),
+ username: accountStore.getState().username,
+ verified: accountStore.getState().verified,
+ accountLoading: accountStore.getState().loading,
+ error: repositoryStore.getState().error
};
},
componentDidMount: function () {
this.refs.searchInput.getDOMNode().focus();
- this.recommended();
+ repositoryStore.listen(this.update);
+ accountStore.listen(this.updateAccount);
+ repositoryActions.search();
},
componentWillUnmount: function () {
if (_searchPromise) {
_searchPromise.cancel();
}
+
+ repositoryStore.unlisten(this.update);
+ accountStore.unlisten(this.updateAccount);
+ },
+ update: function () {
+ this.setState({
+ loading: repositoryStore.loading(),
+ repos: repositoryStore.all()
+ });
+ },
+ updateAccount: function () {
+ this.setState({
+ username: accountStore.getState().username,
+ verified: accountStore.getState().verified,
+ accountLoading: accountStore.getState().loading
+ });
},
search: function (query) {
if (_searchPromise) {
@@ -34,63 +59,16 @@ module.exports = React.createClass({
_searchPromise = null;
}
- if (!query.length) {
- this.setState({
- query: query,
- results: _recommended,
- loading: false
- });
- return;
- }
-
this.setState({
query: query,
loading: true
});
- _searchPromise = Promise.delay(200).cancellable().then(() => Promise.resolve($.get('https://registry.hub.docker.com/v1/search?q=' + query))).then(data => {
+ _searchPromise = Promise.delay(200).cancellable().then(() => {
metrics.track('Searched for Images');
- this.setState({
- results: data.results,
- query: query,
- loading: false
- });
_searchPromise = null;
- }).catch(Promise.CancellationError, () => {
- });
- },
- recommended: function () {
- if (_recommended.length) {
- return;
- }
- Promise.resolve($.ajax({
- url: 'https://kitematic.com/recommended.json',
- cache: false,
- dataType: 'json',
- })).then(res => res.repos).map(repo => {
- var query = repo.repo;
- var vals = query.split('/');
- if (vals.length === 1) {
- query = 'library/' + vals[0];
- }
- return $.get('https://registry.hub.docker.com/v1/repositories_info/' + query).then(data => {
- var res = _.extend(data, repo);
- res.description = data.short_description;
- res.is_official = data.namespace === 'library';
- res.name = data.repo;
- res.star_count = data.stars;
- return res;
- });
- }).then(results => {
- _recommended = results.filter(r => !!r);
- if (!this.state.query.length && this.isMounted()) {
- this.setState({
- results: _recommended
- });
- }
- }).catch(err => {
- console.log(err);
- });
+ repositoryActions.search(query);
+ }).catch(Promise.CancellationError, () => {});
},
handleChange: function (e) {
var query = e.target.value;
@@ -99,67 +77,162 @@ module.exports = React.createClass({
}
this.search(query);
},
+ handleFilter: function (filter) {
+
+ // If we're clicking on the filter again - refresh
+ if (filter === 'userrepos' && this.getQuery().filter === 'userrepos') {
+ repositoryActions.repos();
+ }
+
+ if (filter === 'recommended' && this.getQuery().filter === 'recommended') {
+ repositoryActions.recommended();
+ }
+
+ this.transitionTo('search', {}, {filter: filter});
+
+ metrics.track('Filtered Results', {
+ filter: filter
+ });
+ },
+ handleCheckVerification: function () {
+ accountActions.verify();
+ metrics.track('Verified Account', {
+ from: 'search'
+ });
+ },
render: function () {
- var title = this.state.query ? 'Results' : 'Recommended';
- var data = this.state.results;
- var results;
- if (data.length) {
- var items = data.map(function (image) {
- return (
-
- );
- });
+ let filter = this.getQuery().filter || 'all';
+ let repos = _.values(this.state.repos)
+ .filter(repo => repo.name.toLowerCase().indexOf(this.state.query.toLowerCase()) !== -1 || repo.namespace.toLowerCase().indexOf(this.state.query.toLowerCase()) !== -1)
+ .filter(repo => filter === 'all' || (filter === 'recommended' && repo.is_recommended) || (filter === 'userrepos' && repo.is_user_repo));
+
+ let results;
+ if (this.state.error) {
+ results = (
+
+
There was an error contacting Docker Hub.
+
+ );
+ } else if (filter === 'userrepos' && !accountStore.getState().username) {
+ results = (
+
+
Log In or Sign Up to access your Docker Hub repositories.
+
+
+ );
+ } else if (filter === 'userrepos' && !accountStore.getState().verified) {
+ let spinner = this.state.accountLoading ? : null;
+ results = (
+
+
Please verify your Docker Hub account email address
+
+ {spinner}
+
+
+
+ );
+ } else if (this.state.loading) {
+ results = (
+
+ );
+ } else if (repos.length) {
+ let recommendedItems = repos.filter(repo => repo.is_recommended).map(image => );
+ let otherItems = repos.filter(repo => !repo.is_recommended && !repo.is_user_repo).map(image => );
+
+ let recommendedResults = recommendedItems.length ? (
+
+
Recommended
+
+ {recommendedItems}
+
+
+ ) : null;
+
+ let userRepoItems = repos.filter(repo => repo.is_user_repo).map(image => );
+ let userRepoResults = userRepoItems.length ? (
+
+
My Repositories
+
+ {userRepoItems}
+
+
+ ) : null;
+
+ let otherResults = otherItems.length ? (
+
+
Other Repositories
+
+ {otherItems}
+
+
+ ) : null;
results = (
-
- {items}
+
+ {recommendedResults}
+ {userRepoResults}
+ {otherResults}
);
} else {
- if (this.state.results.length === 0 && this.state.query === '') {
+ if (this.state.query.length) {
results = (
-
-
Loading Images
-
-
+
Cannot find a matching image.
);
} else {
results = (
-
Cannot find a matching image.
+ No Images
);
}
}
- var loadingClasses = classNames({
+
+ let loadingClasses = classNames({
hidden: !this.state.loading,
- loading: true
+ spinner: true,
+ loading: true,
+ 'la-ball-clip-rotate': true,
+ 'la-dark': true,
+ 'la-sm': true
});
- var magnifierClasses = classNames({
+
+ let magnifierClasses = classNames({
hidden: this.state.loading,
icon: true,
'icon-magnifier': true,
'search-icon': true
});
+
return (
- Select a Docker image to create a new container.
+ Select a Docker image to create a container.
-
{title}
+
+ FILTER BY
+ All
+ Recommended
+ My Repositories
+
{results}
diff --git a/src/components/Setup.react.js b/src/components/Setup.react.js
index 881900d541..fb0cfab3e2 100644
--- a/src/components/Setup.react.js
+++ b/src/components/Setup.react.js
@@ -76,15 +76,17 @@ var Setup = React.createClass({
renderStep: function () {
return (
-
-
- {this.renderContents()}
-
-
-
-
Step {SetupStore.number()} out of {SetupStore.stepCount()}
-
{SetupStore.step().title}
-
{SetupStore.step().message}
+
@@ -93,17 +95,19 @@ var Setup = React.createClass({
renderCancelled: function () {
return (
-
-
- {this.renderContents()}
-
-
-
-
Setup Cancelled
-
Couldn't Install Requirements
-
Kitematic didn't receive the administrative privileges required to install or upgrade VirtualBox & Docker.
-
Please click retry. If VirtualBox is not installed, you can download & install it manually from the official Oracle website.
-
+
@@ -112,24 +116,26 @@ var Setup = React.createClass({
renderError: function () {
return (
-
-
-
-
-
+
+
-
-
-
Setup Error
-
We're Sorry!
-
There seems to have been an unexpected error with Kitematic:
-
{this.state.error.message || this.state.error}
-
-
-
-
+
+
+
Setup Error
+
We're Sorry!
+
There seems to have been an unexpected error with Kitematic:
+
{this.state.error.message || this.state.error}
+
+
+
+
+
diff --git a/src/router.js b/src/router.js
index d419f62176..ac32a86106 100644
--- a/src/router.js
+++ b/src/router.js
@@ -1,8 +1,11 @@
-var Router = require('react-router');
-var routes = require('./routes');
+module.exports = {
+ router: null,
-var router = Router.create({
- routes: routes
-});
+ get: function () {
+ return this.router;
+ },
-module.exports = router;
+ set: function (router) {
+ this.router = router;
+ }
+};
diff --git a/src/routes.js b/src/routes.js
index 63bdda0f0b..d946cdbbda 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -1,5 +1,8 @@
var React = require('react/addons');
var Setup = require('./components/Setup.react');
+var Account = require('./components/Account.react');
+var AccountSignup = require('./components/AccountSignup.react');
+var AccountLogin = require('./components/AccountLogin.react');
var Containers = require('./components/Containers.react');
var ContainerDetails = require('./components/ContainerDetails.react');
var ContainerHome = require('./components/ContainerHome.react');
@@ -16,7 +19,6 @@ var Router = require('react-router');
var Route = Router.Route;
var DefaultRoute = Router.DefaultRoute;
var RouteHandler = Router.RouteHandler;
-var Redirect = Router.Redirect;
var App = React.createClass({
render: function () {
@@ -28,6 +30,10 @@ var App = React.createClass({
var routes = (
+
+
+
+
@@ -45,7 +51,6 @@ var routes = (
-
);
diff --git a/src/stores/AccountStore.js b/src/stores/AccountStore.js
new file mode 100644
index 0000000000..7fd1e01044
--- /dev/null
+++ b/src/stores/AccountStore.js
@@ -0,0 +1,72 @@
+import alt from '../alt';
+import accountServerActions from '../actions/AccountServerActions';
+import accountActions from '../actions/AccountActions';
+
+class AccountStore {
+ constructor () {
+ this.bindActions(accountServerActions);
+ this.bindActions(accountActions);
+
+ this.prompted = false;
+ this.loading = false;
+ this.errors = {};
+
+ this.verified = false;
+ this.username = null;
+ }
+
+ skip () {
+ this.setState({
+ prompted: true
+ });
+ }
+
+ login () {
+ this.setState({
+ loading: true,
+ errors: {}
+ });
+ }
+
+ logout () {
+ this.setState({
+ loading: false,
+ errors: {},
+ username: null,
+ verified: false
+ });
+ }
+
+ signup () {
+ this.setState({
+ loading: true,
+ errors: {}
+ });
+ }
+
+ loggedin ({username, verified}) {
+ this.setState({username, verified, errors: {}, loading: false});
+ }
+
+ signedup ({username}) {
+ this.setState({username, errors: {}, loading: false});
+ }
+
+ verify () {
+ this.setState({loading: true});
+ }
+
+ verified ({verified}) {
+ this.setState({verified, loading: false});
+ }
+
+ prompted ({prompted}) {
+ this.setState({prompted});
+ }
+
+ errors ({errors}) {
+ this.setState({errors, loading: false});
+ }
+}
+
+export default alt.createStore(AccountStore);
diff --git a/src/stores/RepositoryStore.js b/src/stores/RepositoryStore.js
new file mode 100644
index 0000000000..dcaec5e1ba
--- /dev/null
+++ b/src/stores/RepositoryStore.js
@@ -0,0 +1,76 @@
+import _ from 'underscore';
+import alt from '../alt';
+import repositoryServerActions from '../actions/RepositoryServerActions';
+import repositoryActions from '../actions/RepositoryActions';
+import accountServerActions from '../actions/AccountServerActions';
+import accountStore from './AccountStore';
+
+class RepositoryStore {
+ constructor () {
+ this.bindActions(repositoryActions);
+ this.bindActions(repositoryServerActions);
+ this.bindActions(accountServerActions);
+ this.results = [];
+ this.recommended = [];
+ this.repos = [];
+ this.reposLoading = false;
+ this.recommendedLoading = false;
+ this.resultsLoading = false;
+ this.error = null;
+ }
+
+ error ({error}) {
+ this.setState({error: error, reposLoading: false, recommendedLoading: false, resultsLoading: false});
+ }
+
+ repos () {
+ this.setState({reposError: null, reposLoading: true});
+ }
+
+ reposLoading () {
+ this.setState({reposLoading: true});
+ }
+
+ reposUpdated ({repos}) {
+ let accountState = accountStore.getState();
+
+ if (accountState.username && accountState.verified) {
+ this.setState({repos, reposLoading: false});
+ } else {
+ this.setState({repos: [], reposLoading: false});
+ }
+ }
+
+ search () {
+ this.setState({error: null, resultsLoading: true});
+ }
+
+ resultsUpdated ({repos}) {
+ this.setState({results: repos, resultsLoading: false});
+ }
+
+ recommended () {
+ this.setState({error: null, recommendedLoading: true});
+ }
+
+ recommendedUpdated ({repos}) {
+ this.setState({recommended: repos, recommendedLoading: false});
+ }
+
+ loggedout () {
+ this.setState({repos: []});
+ }
+
+ static all () {
+ let state = this.getState();
+ let all = state.recommended.concat(state.repos).concat(state.results);
+ return _.uniq(all, false, repo => repo.namespace + '/' + repo.name);
+ }
+
+ static loading () {
+ let state = this.getState();
+ return state.recommendedLoading || state.resultsLoading || state.reposLoading;
+ }
+}
+
+export default alt.createStore(RepositoryStore);
diff --git a/src/stores/SetupStore.js b/src/stores/SetupStore.js
index 3930bc47fc..be11126a6e 100644
--- a/src/stores/SetupStore.js
+++ b/src/stores/SetupStore.js
@@ -59,7 +59,7 @@ var _steps = [{
message: 'To run Docker containers on your computer, Kitematic is starting a Linux virtual machine. This may take a minute...',
totalPercent: 60,
percent: 0,
- seconds: 58,
+ seconds: 72,
run: Promise.coroutine(function* (progressCallback) {
setupUtil.simulateProgress(this.seconds, progressCallback);
var exists = yield machine.exists();
diff --git a/src/stores/TagStore.js b/src/stores/TagStore.js
new file mode 100644
index 0000000000..9293bb16c6
--- /dev/null
+++ b/src/stores/TagStore.js
@@ -0,0 +1,43 @@
+import alt from '../alt';
+import tagActions from '../actions/TagActions';
+import tagServerActions from '../actions/TagServerActions';
+import accountServerActions from '../actions/AccountServerActions';
+
+class TagStore {
+ constructor () {
+ this.bindActions(tagActions);
+ this.bindActions(tagServerActions);
+ this.bindActions(accountServerActions);
+
+ // maps 'namespace/name' => [list of tags]
+ this.tags = {};
+
+ // maps 'namespace/name' => true / false
+ this.loading = {};
+ }
+
+ tags ({repo}) {
+ this.loading[repo] = true;
+ this.emitChange();
+ }
+
+ tagsUpdated ({repo, tags}) {
+ this.tags[repo] = tags;
+ this.loading[repo] = false;
+ this.emitChange();
+ }
+
+ remove ({repo}) {
+ delete this.tags[repo];
+ delete this.loading[repo];
+ this.emitChange();
+ }
+
+ loggedout () {
+ this.loading = {};
+ this.tags = {};
+ this.emitChange();
+ }
+}
+
+export default alt.createStore(TagStore);
diff --git a/src/utils/DockerUtil.js b/src/utils/DockerUtil.js
index f46b0be808..9a7e14c0ae 100644
--- a/src/utils/DockerUtil.js
+++ b/src/utils/DockerUtil.js
@@ -4,7 +4,7 @@ import path from 'path';
import dockerode from 'dockerode';
import _ from 'underscore';
import util from './Util';
-import registry from '../utils/RegistryUtil';
+import hubUtil from './HubUtil';
import metrics from '../utils/MetricsUtil';
import containerServerActions from '../actions/ContainerServerActions';
import Promise from 'bluebird';
@@ -317,7 +317,6 @@ export default {
stream.setEncoding('utf8');
stream.on('data', json => {
let data = JSON.parse(json);
- // console.log(data);
if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete') {
return;
@@ -335,160 +334,117 @@ export default {
},
pullImage (repository, tag, callback, progressCallback, blockedCallback) {
- registry.layers(repository, tag, (err, layerSizes) => {
+ let opts = {}, config = hubUtil.config();
+ if (!hubUtil.config()) {
+ opts = {};
+ } else {
+ let [username, password] = hubUtil.creds(config);
+ opts = {
+ authconfig: {
+ username,
+ password,
+ auth: ''
+ }
+ };
+ }
- // TODO: Support v2 registry API
- // TODO: clean this up- It's messy to work with pulls from both the v1 and v2 registry APIs
- // Use the per-layer pull progress % to update the total progress.
- this.client.listImages({all: 1}, (err, images) => {
- images = images || [];
+ this.client.pull(repository + ':' + tag, opts, (err, stream) => {
+ if (err) {
+ callback(err);
+ return;
+ }
- let existingIds = new Set(images.map(function (image) {
- return image.Id.slice(0, 12);
- }));
+ stream.setEncoding('utf8');
- let layersToDownload = layerSizes.filter(function (layerSize) {
- return !existingIds.has(layerSize.Id);
- });
+ // scheduled to inform about progression at given interval
+ let tick = null;
+ let layerProgress = {};
- console.log("existingIds:" + existingIds.size)
- console.log("layersToDownload:" + layersToDownload.length)
+ // Split the loading in a few columns for more feedback
+ let columns = {};
+ columns.amount = 4; // arbitrary
+ columns.toFill = 0; // the current column index, waiting for layer IDs to be displayed
- this.client.pull(repository + ':' + tag, (err, stream) => {
- if (err) {
- callback(err);
- return;
- }
- stream.setEncoding('utf8');
+ // data is associated with one layer only (can be identified with id)
+ stream.on('data', str => {
+ var data = JSON.parse(str);
- // layerProgress contains progression infos for all layers
- let layerProgress = layersToDownload.reduce(function (r, layer) {
- if (_.findWhere(images, {Id: layer.Id})) {
- // If the layer is already here, we set current and total to 1
- r[layer.Id] = {current:1, total:1, column:-1};
- } else {
- // At this point, the total layer size is unknown
- // so we set total to -1 to avoid displaying it
- r[layer.Id] = {current:0, total:-1, column:-1};
- }
- return r;
- }, {});
+ if (data.error) {
+ callback(data.error);
+ return;
+ }
+ if (data.status && (data.status === 'Pulling dependent layers' || data.status.indexOf('already being pulled by another client') !== -1)) {
+ blockedCallback();
+ return;
+ }
-
- let layersToLoad = _.keys(layerProgress).length;
-
- console.log("nbLayers:" + layersToLoad);
-
- // Split the loading in a few columns for more feedback
- let columns = {};
- columns.amount = 4; // arbitrary
- columns.progress = []; // layerIDs, nbLayers, maxLayers, progress value
- columns.toFill = 0; // the current column index, waiting for layer IDs to be displayed
-
- for (let i = 0; i < columns.amount; i++)
- {
- let layerAmount = Math.ceil(layersToLoad / (columns.amount - i));
- layersToLoad -= layerAmount;
- columns.progress[i] = { layerIDs:[], nbLayers:0 , maxLayers:layerAmount , value:0.0 };
- }
-
-
-
- // scheduled to inform about progression at given interval
- let tick = null;
-
-
- // data is associated with one layer only (can be identified with id)
- stream.on('data', str => {
- var data = JSON.parse(str);
-
- if (data.error) {
- return;
- }
-
- if (data.status && (data.status === 'Pulling dependent layers' || data.status.indexOf('already being pulled by another client') !== -1)) {
- blockedCallback();
- return;
- }
-
- if (data.status === 'Already exists') {
-
- console.log("Already exists.");
-
- //layerProgress[data.id].current = 1;
- //layerProgress[data.id].total = 1;
-
- } else if (data.status === 'Downloading') {
-
- // aduermael: How total could be <= 0 ?
-
- // if (data.progressDetail.total <= 0) {
- // progressCallback(0);
- // return;
- // } else {
-
- layerProgress[data.id].current = data.progressDetail.current;
- layerProgress[data.id].total = data.progressDetail.total;
-
- // Assign to a column if not done yet
- if (layerProgress[data.id].column == -1)
- {
- // test if we can still add layers to that column
- if (columns.progress[columns.toFill].nbLayers == columns.progress[columns.toFill].maxLayers) columns.toFill++;
-
- layerProgress[data.id].column = columns.toFill;
- columns.progress[columns.toFill].layerIDs.push(data.id);
- columns.progress[columns.toFill].nbLayers++;
-
+ if (data.status === 'Pulling fs layer') {
+ layerProgress[data.id] = {
+ current: 0,
+ total: 1
+ };
+ } else if (data.status === 'Downloading') {
+ if (!columns.progress) {
+ columns.progress = []; // layerIDs, nbLayers, maxLayers, progress value
+ let layersToLoad = _.keys(layerProgress).length;
+ let layersPerColumn = Math.floor(layersToLoad / columns.amount);
+ let leftOverLayers = layersToLoad % columns.amount;
+ for (let i = 0; i < columns.amount; i++) {
+ let layerAmount = layersPerColumn;
+ if (i < leftOverLayers) {
+ layerAmount += 1;
}
+ columns.progress[i] = {layerIDs: [], nbLayers:0 , maxLayers: layerAmount, value: 0.0};
+ }
+ }
- //}
+ layerProgress[data.id].current = data.progressDetail.current;
+ layerProgress[data.id].total = data.progressDetail.total;
- if (!tick) {
- tick = setInterval( function(){
- // console.log(JSON.stringify(layerProgress))
+ // Assign to a column if not done yet
+ if (!layerProgress[data.id].column) {
+ // test if we can still add layers to that column
+ if (columns.progress[columns.toFill].nbLayers === columns.progress[columns.toFill].maxLayers && columns.toFill < columns.amount - 1) {
+ columns.toFill++;
+ }
- // update values
- for (let i = 0; i < columns.amount; i++)
- {
- columns.progress[i].value = 0.0;
+ layerProgress[data.id].column = columns.toFill;
+ columns.progress[columns.toFill].layerIDs.push(data.id);
+ columns.progress[columns.toFill].nbLayers++;
+ }
- // Start only if the column has accurate values for all layers
- if (columns.progress[i].nbLayers == columns.progress[i].maxLayers)
- {
- let layer;
- let totalSum = 0;
- let currentSum = 0;
+ if (!tick) {
+ tick = setTimeout(() => {
+ clearInterval(tick);
+ tick = null;
+ for (let i = 0; i < columns.amount; i++) {
+ columns.progress[i].value = 0.0;
+ if (columns.progress[i].nbLayers > 0) {
+ let layer;
+ let totalSum = 0;
+ let currentSum = 0;
- for (let j = 0; j < columns.progress[i].nbLayers; j++)
- {
- layer = layerProgress[columns.progress[i].layerIDs[j]];
- totalSum += layer.total;
- currentSum += layer.current;
- }
-
- if (totalSum > 0) columns.progress[i].value = 100.0 * currentSum / totalSum;
- else columns.progress[i].value = 0.0;
- }
+ for (let j = 0; j < columns.progress[i].nbLayers; j++) {
+ layer = layerProgress[columns.progress[i].layerIDs[j]];
+ totalSum += layer.total;
+ currentSum += layer.current;
}
- progressCallback(columns);
-
- },33);
+ if (totalSum > 0) {
+ columns.progress[i].value = Math.min(100.0 * currentSum / totalSum, 100);
+ } else {
+ columns.progress[i].value = 0.0;
+ }
+ }
}
- }
-
- });
-
- stream.on('end', function () {
-
- clearInterval(tick);
- callback();
-
- });
-
- });
+ progressCallback(columns);
+ }, 16);
+ }
+ }
+ });
+ stream.on('end', function () {
+ callback();
});
});
},
diff --git a/src/utils/HubUtil.js b/src/utils/HubUtil.js
new file mode 100644
index 0000000000..a4db7131f7
--- /dev/null
+++ b/src/utils/HubUtil.js
@@ -0,0 +1,181 @@
+var _ = require('underscore');
+var request = require('request');
+var accountServerActions = require('../actions/AccountServerActions');
+
+module.exports = {
+ init: function () {
+ accountServerActions.prompted({prompted: localStorage.getItem('auth.prompted')});
+ let username = localStorage.getItem('auth.username');
+ let verified = localStorage.getItem('auth.verified') === 'true';
+ if (username) {
+ accountServerActions.loggedin({username, verified});
+ }
+ },
+
+ username: function () {
+ return localStorage.getItem('auth.username') || null;
+ },
+
+ // Returns the base64 encoded index token or null if no token exists
+ config: function () {
+ let config = localStorage.getItem('auth.config');
+ if (!config) {
+ return null;
+ }
+ return config;
+ },
+
+ // Retrives the current jwt hub token or null if no token exists
+ jwt: function () {
+ let jwt = localStorage.getItem('auth.jwt');
+ if (!jwt) {
+ return null;
+ }
+ return jwt;
+ },
+
+ prompted: function () {
+ return localStorage.getItem('auth.prompted');
+ },
+
+ setPrompted: function (prompted) {
+ localStorage.setItem('auth.prompted', true);
+ accountServerActions.prompted({prompted});
+ },
+
+ request: function (req, callback) {
+ let jwt = this.jwt();
+
+ if (jwt) {
+ _.extend(req, {
+ headers: {
+ Authorization: `JWT ${jwt}`
+ }
+ });
+ }
+
+ // First attempt with existing JWT
+ request(req, (error, response, body) => {
+ let data = JSON.parse(body);
+
+ // If the JWT has expired, then log in again to get a new JWT
+ if (data && data.detail === 'Signature has expired.') {
+ let config = this.config();
+ if (!this.config()) {
+ this.logout();
+ return;
+ }
+
+ let [username, password] = this.creds(config);
+ this.auth(username, password, (error, response, body) => {
+ let data = JSON.parse(body);
+ if (response.statusCode === 200 && data && data.token) {
+ localStorage.setItem('auth.jwt', data.token);
+ } else {
+ this.logout();
+ }
+
+ this.request(req, callback);
+ });
+ } else {
+ callback(error, response, body);
+ }
+ });
+ },
+
+ loggedin: function () {
+ return this.jwt() && this.config();
+ },
+
+ logout: function () {
+ accountServerActions.loggedout();
+ localStorage.removeItem('auth.jwt');
+ localStorage.removeItem('auth.username');
+ localStorage.removeItem('auth.verified');
+ localStorage.removeItem('auth.config');
+ },
+
+ login: function (username, password) {
+ this.auth(username, password, (error, response, body) => {
+ if (error) {
+ accountServerActions.errors({errors: {detail: error.message}});
+ return;
+ }
+
+ let data = JSON.parse(body);
+
+ if (response.statusCode === 200) {
+ if (data.token) {
+ localStorage.setItem('auth.jwt', data.token);
+ localStorage.setItem('auth.username', username);
+ localStorage.setItem('auth.verified', true);
+ localStorage.setItem('auth.config', new Buffer(username + ':' + password).toString('base64'));
+ accountServerActions.loggedin({username, verified: true});
+ accountServerActions.prompted({prompted: true});
+ require('./RegHubUtil').repos();
+ } else {
+ accountServerActions.errors({errors: {detail: 'Did not receive login token.'}});
+ }
+ } else if (response.statusCode === 401) {
+ if (data && data.detail && data.detail.indexOf('Account not active yet') !== -1) {
+ accountServerActions.loggedin({username, verified: false});
+ accountServerActions.prompted({prompted: true});
+ localStorage.setItem('auth.username', username);
+ localStorage.setItem('auth.verified', false);
+ localStorage.setItem('auth.config', new Buffer(username + ':' + password).toString('base64'));
+ } else {
+ accountServerActions.errors({errors: data});
+ }
+ }
+ });
+ },
+
+ auth: function (username, password, callback) {
+ request.post('https://hub.docker.com/v2/users/login/', {form: {username, password}}, (error, response, body) => {
+ callback(error, response, body);
+ });
+ },
+
+ verify: function () {
+ let config = this.config();
+ if (!config) {
+ this.logout();
+ return;
+ }
+
+ let [username, password] = this.creds(config);
+ this.login(username, password);
+ },
+
+ creds: function (config) {
+ return new Buffer(config, 'base64').toString().split(/:(.+)?/).slice(0, 2);
+ },
+
+ // Signs up and places a token under ~/.dockercfg and saves a jwt to localstore
+ signup: function (username, password, email, subscribe) {
+ request.post('https://hub.docker.com/v2/users/signup/', {
+ form: {
+ username,
+ password,
+ email,
+ subscribe
+ }
+ }, (err, response, body) => {
+ // TODO: save username to localstorage
+ if (response.statusCode === 204) {
+ accountServerActions.signedup({username, verified: false});
+ accountServerActions.prompted({prompted: true});
+ localStorage.setItem('auth.username', username);
+ localStorage.setItem('auth.verified', false);
+ localStorage.setItem('auth.config', new Buffer(username + ':' + password).toString('base64'));
+ } else {
+ let data = JSON.parse(body);
+ let errors = {};
+ for (let key in data) {
+ errors[key] = data[key][0];
+ }
+ accountServerActions.errors({errors});
+ }
+ });
+ },
+};
diff --git a/src/utils/RegHubUtil.js b/src/utils/RegHubUtil.js
new file mode 100644
index 0000000000..3975ce96aa
--- /dev/null
+++ b/src/utils/RegHubUtil.js
@@ -0,0 +1,142 @@
+var _ = require('underscore');
+var request = require('request');
+var async = require('async');
+var util = require('../utils/Util');
+var hubUtil = require('../utils/HubUtil');
+var repositoryServerActions = require('../actions/RepositoryServerActions');
+var tagServerActions = require('../actions/TagServerActions');
+
+let searchReq = null;
+
+module.exports = {
+ // Normalizes results from search to v2 repository results
+ normalize: function (repo) {
+ let obj = _.clone(repo);
+ if (obj.is_official) {
+ obj.namespace = 'library';
+ } else {
+ let [namespace, name] = repo.name.split('/');
+ obj.namespace = namespace;
+ obj.name = name;
+ }
+
+ return obj;
+ },
+
+ search: function (query, page) {
+ if (searchReq) {
+ searchReq.abort();
+ searchReq = null;
+ }
+
+ if (!query) {
+ repositoryServerActions.resultsUpdated({repos: []});
+ }
+
+ searchReq = request.get({
+ url: 'https://registry.hub.docker.com/v1/search?',
+ qs: {q: query, page}
+ }, (error, response, body) => {
+ if (error) {
+ repositoryServerActions.searchError({error});
+ }
+
+ let data = JSON.parse(body);
+ let repos = _.map(data.results, result => {
+ return this.normalize(result);
+ });
+ if (response.statusCode === 200) {
+ repositoryServerActions.resultsUpdated({repos});
+ }
+ });
+ },
+
+ recommended: function () {
+ request.get('https://kitematic.com/recommended.json', (error, response, body) => {
+ if (error) {
+ repositoryServerActions.recommendedError({error});
+ }
+
+ let data = JSON.parse(body);
+ let repos = data.repos;
+ async.map(repos, (repo, cb) => {
+ let name = repo.repo;
+ if (util.isOfficialRepo(name)) {
+ name = 'library/' + name;
+ }
+
+ request.get({
+ url: `https://registry.hub.docker.com/v2/repositories/${name}`,
+ }, (error, response, body) => {
+ if (error) {
+ repositoryServerActions.error({error});
+ return;
+ }
+
+ if (response.statusCode === 200) {
+ let data = JSON.parse(body);
+ data.is_recommended = true;
+ _.extend(data, repo);
+ cb(null, data);
+ }
+ });
+ }, (error, repos) => {
+ repositoryServerActions.recommendedUpdated({repos});
+ });
+ });
+ },
+
+ tags: function (repo) {
+ hubUtil.request({
+ url: `https://registry.hub.docker.com/v2/repositories/${repo}/tags`
+ }, (error, response, body) => {
+ if (response.statusCode === 200) {
+ let data = JSON.parse(body);
+ tagServerActions.tagsUpdated({repo, tags: data.tags});
+ } else if (response.statusCude === 401) {
+ return;
+ }
+ });
+ },
+
+ // Returns the base64 encoded index token or null if no token exists
+ repos: function () {
+ repositoryServerActions.reposLoading({repos: []});
+
+ hubUtil.request({
+ url: 'https://registry.hub.docker.com/v2/namespaces/',
+ }, (error, response, body) => {
+ if (error) {
+ repositoryServerActions.reposError({error});
+ return;
+ }
+
+ let data = JSON.parse(body);
+ let namespaces = data.namespaces;
+ async.map(namespaces, (namespace, cb) => {
+ hubUtil.request({
+ url: `https://registry.hub.docker.com/v2/repositories/${namespace}`
+ }, (error, response, body) => {
+ if (error) {
+ repositoryServerActions.reposError({error});
+ return;
+ }
+
+ let data = JSON.parse(body);
+ cb(null, data.results);
+ });
+ }, (error, lists) => {
+ let repos = [];
+ for (let list of lists) {
+ repos = repos.concat(list);
+ }
+
+ _.each(repos, repo => {
+ repo.is_user_repo = true;
+ });
+
+ repositoryServerActions.reposUpdated({repos});
+ });
+ });
+ }
+};
diff --git a/src/utils/RegistryUtil.js b/src/utils/RegistryUtil.js
deleted file mode 100644
index 8b4352d9f5..0000000000
--- a/src/utils/RegistryUtil.js
+++ /dev/null
@@ -1,84 +0,0 @@
-var async = require('async');
-var $ = require('jquery');
-
-var Registry = {
- token: function(repository, callback) {
- $.ajax({
- url: 'https://registry.hub.docker.com/v1/repositories/' + repository + '/images',
- headers: {
- 'X-Docker-Token': true,
- },
- success: function (res, status, xhr) {
- callback(null, xhr.getResponseHeader('X-Docker-Token'));
- },
- error: function (err) {
- callback(err);
- }
- });
- },
- ancestry: function (imageId, token, callback) {
- $.ajax({
- url: 'https://registry-1.docker.io/v1/images/' + imageId + '/ancestry',
- headers: {
- Authorization: 'Token ' + token
- },
- success: function (layers) {
- callback(null, layers);
- },
- error: function (err) {
- callback(err);
- }
- });
- },
- imageId: function (repository, tag, token, callback) {
- $.ajax({
- url: 'https://registry-1.docker.io/v1/repositories/' + repository + '/tags/' + tag,
- headers: {
- Authorization: 'Token ' + token
- },
- success: function (res) {
- callback(null, res);
- },
- error: function (err) {
- callback(err);
- }
- });
- },
-
- // Returns an array [{Id: <12 character image ID, size: size of layer in bytes}]
- layers: function (repository, tag, callback) {
- var self = this;
- this.token(repository, function (err, token) {
- self.imageId(repository, tag, token, function (err, imageId) {
- self.ancestry(imageId, token, function (err, layers) {
- async.map(layers, function (layer, callback) {
- $.ajax({
- url: 'https://registry-1.docker.io/v1/images/' + layer + '/json',
- headers: {
- Authorization: 'Token ' + token
- },
- success: function (res, status, xhr) {
- var size = xhr.getResponseHeader('X-Docker-Size');
- callback(null, {
- Id: layer.slice(0, 12),
- size: parseInt(size, 10)
- });
- },
- error: function (err) {
- callback(err);
- }
- });
- }, function (err, results) {
- if (err) {
- callback('Could not sum' + err);
- return;
- }
- callback(null, results);
- });
- });
- });
- });
- }
-};
-
-module.exports = Registry;
diff --git a/styles/container-home.less b/styles/container-home.less
index bc808ea860..8a11e5d833 100644
--- a/styles/container-home.less
+++ b/styles/container-home.less
@@ -17,7 +17,7 @@
}
.subtext {
text-align: right;
- color: @gray-lightest;
+ color: @gray-lighter;
margin-top: 2px;
transition: all 0.25s;
&:hover {
diff --git a/styles/container-progress.less b/styles/container-progress.less
index a8e616b22d..faa5e72f9a 100644
--- a/styles/container-progress.less
+++ b/styles/container-progress.less
@@ -1,7 +1,3 @@
-.container-progress-wrapper {
- margin-left: 37%;
-}
-
.container-progress {
display: inline-block;
position: relative;
diff --git a/styles/header.less b/styles/header.less
index 004d149f85..5a4da6bfc3 100644
--- a/styles/header.less
+++ b/styles/header.less
@@ -1,25 +1,56 @@
.header {
- position: absolute;
- min-width: 100%;
- min-height: 40px;
+ min-height: 50px;
-webkit-app-region: drag;
-webkit-user-select: none;
- &.no-drag {
+
+ &.bordered {
+ border-bottom: 1px solid #E7E7E7;
+ }
+
+ display: flex;
+
+ .no-drag {
-webkit-app-region: no-drag;
}
- .logo {
- position: relative;
- float: right;
- top: 10px;
- right: 10px;
- z-index: 1000;
+ .updates {
+ flex: 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ margin-right: 20px;
+
+ img {
+ margin: 0 14px;
+ height: 16px;
+ width: 20px;
+ }
}
+
+ .login {
+ flex: 0 auto;
+ display: flex;
+ color: #88919C;
+ align-items: center;
+ justify-content: flex-end;
+ margin-right: 13px;
+
+ &:active {
+ img, span {
+ -webkit-filter: brightness(0.8);
+ }
+ }
+
+ img {
+ margin: 0 5px;
+ }
+ }
+
.buttons {
- display: inline-block;
- position: relative;
- top: 10px;
- left: 15px;
- z-index: 1000;
+ display: flex;
+ margin-left: 14px;
+
+ align-items: center;
+ justify-content: center;
&:hover {
.button-minimize.enabled {
.at2x('minimize.png', 10px, 10px);
@@ -35,6 +66,7 @@
}
}
.button {
+ flex: 0 auto;
.traffic-light();
&.button-close {
background-color: @traffic-light-red;
diff --git a/styles/left-panel.less b/styles/left-panel.less
index 3ceb44068a..160bce82d1 100644
--- a/styles/left-panel.less
+++ b/styles/left-panel.less
@@ -1,7 +1,7 @@
/* Sidebar */
.sidebar {
- padding-top: 28px;
+ padding-top: 10px;
background-color: white;
margin: 0;
border-right: 1px solid @color-divider;
@@ -9,6 +9,7 @@
flex-direction: column;
min-width: 260px;
box-sizing: border-box;
+ position: relative;
.sidebar-header {
flex: 0 auto;
min-width: 240px;
@@ -59,12 +60,15 @@
}
.sidebar-containers {
position: relative;
+ display: flex;
+ flex-direction: column;
flex: 1 auto;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
max-width: 260px;
ul {
+ flex: 1 auto;
margin: 0;
padding: 0;
display: flex;
@@ -240,13 +244,13 @@
/* Sidebar Buttons */
.sidebar-buttons {
+ border-top: 1px solid #F0F4F8;
+ min-height: 48px;
+ flex: 0 auto;
+ display: flex;
+ flex-direction: row;
background-color: white;
- width: 259px;
opacity: 0.9;
- position: fixed;
- bottom: 0;
- padding: 15px;
- padding-top: 5px;
z-index: 10000;
.btn-label {
color: @gray-lighter;
@@ -254,8 +258,35 @@
height: 18px;
}
.btn-sidebar {
- display: inline-block;
- margin-right: 7px;
+ font-size: 13px;
+ font-weight: 500;
+ color: @brand-primary;
+
+ flex: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 48px;
+
+ &:active {
+ img, .text {
+ -webkit-filter: brightness(0.8);
+ }
+ }
+ }
+
+ .btn-terminal {
+ flex: 1 auto;
+ border-right: 1px solid #F0F4F8;
+ img {
+ margin: 0 10px 0 0;
+ height: 21px;
+ width: 25px;
+ }
+ }
+
+ .btn-feedback {
+ border-right: 1px solid #F0F4F8;
}
.btn {
position: relative;
diff --git a/styles/main.less b/styles/main.less
index a1cee9e15d..2494e833aa 100644
--- a/styles/main.less
+++ b/styles/main.less
@@ -15,6 +15,7 @@
@import "container-home.less";
@import "container-logs.less";
@import "container-settings.less";
+@import "spinner.less";
@import "animation.less";
@import "container-progress.less";
@@ -24,6 +25,7 @@ html, body {
overflow: hidden;
background: none;
-webkit-user-select: none;
+ -webkit-user-drag: none;
font-family: @font-regular;
cursor: default;
img {
diff --git a/styles/mixins.less b/styles/mixins.less
index 3c27533854..2bc0374bb0 100644
--- a/styles/mixins.less
+++ b/styles/mixins.less
@@ -2,7 +2,7 @@
box-sizing: border-box;
display: inline-block;
background: white;
- margin-right: 9px;
+ margin-right: 8px;
height: 12px;
width: 12px;
border: 1px solid @traffic-light-gray-border;
diff --git a/styles/new-container.less b/styles/new-container.less
index 55d28f3921..f6dfb9281f 100644
--- a/styles/new-container.less
+++ b/styles/new-container.less
@@ -31,58 +31,103 @@
display: flex;
flex: 1 auto;
flex-direction: column;
- padding: 35px 20px 32px 25px;
+ padding: 25px 0 0;
+
+ .spinner {
+ display: inline-block;
+ }
+
.results {
display: flex;
flex-direction: column;
flex: 1 auto;
+ color: @gray-normal;
+
+ .results-filters {
+ flex: 0 auto;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ font-size: 13px;
+ margin-bottom: 10px;
+ margin: 0 20px;
+
+ .results-filter {
+ text-align: center;
+ margin: 0 10px;
+ min-width: 40px;
+ }
+
+ .results-filter-title {
+ color: @gray-lighter;
+ font-weight: 500;
+ padding-top: 6px;
+ }
+ }
+
.no-results {
flex: 1 auto;
display: flex;
+ flex-direction: column;
align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+
+ img {
+ width: 380px;
+ }
+
+ .verify {
+ margin: 15px 0;
+ position: relative;
+
+ .spinner {
+ position: absolute;
+ top: 0;
+ right: -50px;
+ }
+ }
+
.loader {
- margin: 0 auto;
- margin-top: -20%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
text-align: center;
width: 300px;
+ margin-top: -10%;
h2 {
+ color: @gray-normal;
margin-bottom: 20px;
}
}
h1 {
- color: @gray-lightest;
+ color: @gray-lighter;
font-size: 24px;
margin: 0 auto;
- margin-top: -20%;
}
}
}
.new-container-header {
- margin-bottom: 18px;
+ margin: 0 20px 8px;
display: flex;
flex: 0 auto;
+ flex-shrink: 0;
.text {
flex: 1 auto;
width: 50%;
font-size: 14px;
- color: @gray-lighter;
+ color: @gray-normal;
}
.search {
flex: 1 auto;
- margin-right: 30px;
.search-bar {
top: -7px;
position: relative;
.loading {
position: absolute;
- left: 10px;
top: 7px;
- width: 16px;
- height: 16px;
- -webkit-animation-name: spin;
- -webkit-animation-duration: 1.8s;
- -webkit-animation-iteration-count: infinite;
- -webkit-animation-timing-function: linear;
+ left: 10px;
}
.search-icon {
font-size: 18px;
@@ -105,7 +150,7 @@
border-color: @brand-primary;
}
&::-webkit-input-placeholder {
- color: @gray-lightest;
+ color: @gray-lighter;
font-weight: 400;
}
}
@@ -114,183 +159,187 @@
}
}
-.result-grid {
- display: flex;
- flex-flow: row wrap;
- justify-content: flex-start;
- margin-top: 10px;
+.result-grids {
+
overflow: auto;
- .image-item {
+ margin: 0 0 0 20px;
+ .result-grid {
display: flex;
- position: relative;
- width: 320px;
- height: 166px;
- border-radius: 4px;
- background-color: white;
- margin-right: 20px;
- margin-bottom: 20px;
- .tag-overlay {
- z-index: 999;
- background-color: rgba(0,0,0,0.8);
- border-radius: 4px;
+ flex-flow: row wrap;
+ justify-content: flex-start;
+ margin-top: 10px;
+ .image-item {
+ display: flex;
+ position: relative;
width: 320px;
height: 166px;
- position: absolute;
- color: white;
- font-size: 13px;
- display: none;
- padding: 10px;
- .tag-list {
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- align-content: flex-start;
- flex-flow: row wrap;
- height: 140px;
- overflow: auto;
- .tag {
- display: inline-block;
- flex: 0 auto;
- margin-right: 2px;
- padding: 3px 5px;
- &.active {
- background-color: rgba(255,255,255,0.2);
- border-radius: 20px;
- }
- &:hover {
- background-color: rgba(255,255,255,0.2);
- border-radius: 20px;
- }
- }
- }
- .tags-loading {
- position: relative;
- left: 42%;
- top: 20%;
- text-align: center;
- margin: 14px auto;
- -webkit-animation-name: spin;
- -webkit-animation-duration: 1.8s;
- -webkit-animation-iteration-count: infinite;
- -webkit-animation-timing-function: linear;
- }
- }
- .logo {
- flex: 1 auto;
- min-width: 90px;
- background-color: @brand-action;
- border-top-left-radius: 4px;
- border-bottom-left-radius: 4px;
- justify-content: center;
- text-align: center;
- box-shadow: inset 0px 0px 0px 1px rgba(0,0,0,0.2);
- img {
- margin-top: 15px;
- }
- }
- .card {
- padding: 10px 20px 10px 20px;
- position: relative;
- border: 1px solid @gray-lighter;
- border-left: 0;
- border-top-right-radius: 4px;
- border-bottom-right-radius: 4px;
- .badges {
+ border-radius: 4px;
+ background-color: white;
+ margin-right: 20px;
+ margin-bottom: 20px;
+ .tag-overlay {
+ z-index: 999;
+ background-color: rgba(0,0,0,0.8);
+ border-radius: 4px;
+ width: 320px;
+ height: 166px;
position: absolute;
- right: 15px;
- top: 8px;
- }
- .name {
- font-size: 18px;
- color: @gray-darkest;
- margin-bottom: 0px;
- position: relative;
- width: 190px;
- .namespace {
- font-size: 11px;
- color: @gray-lighter;
- margin-bottom: -3px;
- &.official {
- color: @brand-action;
- }
- }
- .repo {
- display: inline-block;
- max-width: 190px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
- .description {
- font-size: 12px;
- color: @gray-normal;
- height: 50px;
- width: 190px;
- text-overflow: ellipsis;
- overflow: hidden;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 3;
- display: -webkit-box;
- word-wrap: break-word;
- }
- .actions {
- width: 190px;
- position: absolute;
- bottom: 8px;
- .stars {
- height: 15px;
- font-size: 10px;
- color: @gray-darker;
- border-right: 1px solid @gray-lightest;
- padding-right: 10px;
- .icon {
- position: relative;
- font-size: 16px;
- margin-right: 3px;
- top: -1px;
- color: @gray-darkest;
- }
- .text {
- position: relative;
- top: -6px;
- }
- }
- .tags {
- height: 15px;
- font-size: 10px;
- color: @gray-darker;
- padding-left: 10px;
- .icon {
- position: relative;
- font-size: 12px;
+ color: white;
+ font-size: 13px;
+ display: none;
+ padding: 10px;
+ .tag-list {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ align-content: flex-start;
+ flex-flow: row wrap;
+ height: 140px;
+ overflow: auto;
+ .tag {
+ display: inline-block;
+ flex: 0 auto;
margin-right: 2px;
- top: 2px;
- color: @gray-darkest;
- }
- .text {
- position: relative;
- top: -2px;
padding: 3px 5px;
- text-decoration: underline;
+ &.active {
+ background-color: rgba(255,255,255,0.2);
+ border-radius: 20px;
+ }
&:hover {
- background-color: @brand-action;
- color: white;
+ background-color: rgba(255,255,255,0.2);
border-radius: 20px;
}
}
}
- .action {
- flex: 1 auto;
- .btn {
- text-align: right;
- position: relative;
- float: right;
- top: -7px;
+ .tags-loading {
+ position: relative;
+ left: 42%;
+ top: 20%;
+ text-align: center;
+ margin: 14px auto;
+ -webkit-animation-name: spin;
+ -webkit-animation-duration: 1.8s;
+ -webkit-animation-iteration-count: infinite;
+ -webkit-animation-timing-function: linear;
+ }
+ }
+ .logo {
+ flex: 1 auto;
+ min-width: 90px;
+ background-color: @brand-action;
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+ justify-content: center;
+ text-align: center;
+ box-shadow: inset 0px 0px 0px 1px rgba(0,0,0,0.2);
+ img {
+ margin-top: 15px;
+ }
+ }
+ .card {
+ padding: 10px 20px 10px 20px;
+ position: relative;
+ border: 1px solid @gray-lighter;
+ border-left: 0;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ .badges {
+ position: absolute;
+ right: 15px;
+ top: 8px;
+ }
+ .name {
+ font-size: 18px;
+ color: @gray-darkest;
+ margin-bottom: 0px;
+ position: relative;
+ width: 190px;
+ .namespace {
+ font-size: 11px;
+ color: @gray-lighter;
+ margin-bottom: -3px;
+ &.official {
+ color: @brand-action;
+ }
+ }
+ .repo {
+ display: inline-block;
+ max-width: 190px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
}
- display: flex;
- flex-direaction: row;
+ .description {
+ font-size: 12px;
+ color: @gray-normal;
+ height: 50px;
+ width: 190px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ display: -webkit-box;
+ word-wrap: break-word;
+ }
+ .actions {
+ width: 190px;
+ position: absolute;
+ bottom: 8px;
+ .stars {
+ height: 15px;
+ font-size: 10px;
+ color: @gray-darker;
+ border-right: 1px solid @gray-lightest;
+ padding-right: 10px;
+ .icon {
+ position: relative;
+ font-size: 16px;
+ margin-right: 3px;
+ top: -1px;
+ color: @gray-darkest;
+ }
+ .text {
+ position: relative;
+ top: -6px;
+ }
+ }
+ .tags {
+ height: 15px;
+ font-size: 10px;
+ color: @gray-darker;
+ padding-left: 10px;
+ .icon {
+ position: relative;
+ font-size: 12px;
+ margin-right: 2px;
+ top: 2px;
+ color: @gray-darkest;
+ }
+ .text {
+ position: relative;
+ top: -2px;
+ padding: 3px 5px;
+ text-decoration: underline;
+ &:hover {
+ background-color: @brand-action;
+ color: white;
+ border-radius: 20px;
+ }
+ }
+ }
+ .action {
+ flex: 1 auto;
+ .btn {
+ text-align: right;
+ position: relative;
+ float: right;
+ top: -7px;
+ }
+ }
+ display: flex;
+ flex-direaction: row;
+ }
}
}
}
diff --git a/styles/right-panel.less b/styles/right-panel.less
index 5472b925dc..d8dfba884d 100644
--- a/styles/right-panel.less
+++ b/styles/right-panel.less
@@ -65,38 +65,15 @@
text-align: right;
margin-right: 3px;
margin-top: 3px;
- .tab {
- margin-left: 16px;
- padding: 6px 10px;
- font-weight: 400;
- display: inline-block;
- &:hover {
- border-radius: 40px;
- background-color: darken(@color-background, 2%);
- }
- &.active {
- border-radius: 40px;
- color: white;
- .brand-gradient();
- }
- &.disabled {
- opacity: 0.5;
- &:hover {
- border-radius: 40px;
- background-color: transparent;
- }
- }
- }
}
}
.details-header {
flex: 0 auto;
display: flex;
flex-direction: row;
- padding: 31px 24px 18px 24px;
+ padding: 18px 24px 24px 24px;
position: relative;
background-color: white;
- height: 75px;
h1 {
margin: 0;
font-size: 20px;
@@ -124,9 +101,33 @@
}
}
}
+
+ .tab {
+ margin-left: 16px;
+ padding: 6px 10px;
+ font-weight: 400;
+ display: inline-block;
+ &.active {
+ border-radius: 40px;
+ color: white;
+ .brand-gradient();
+ }
+ &.disabled {
+ opacity: 0.5;
+ &:hover {
+ border-radius: 40px;
+ background-color: transparent;
+ }
+ }
+ }
+
.details-progress {
- margin: 20% auto 0;
- width: 400px;
+ flex: 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ margin-top: -70px;
h2 {
margin-bottom: 20px;
text-align: center;
diff --git a/styles/setup.less b/styles/setup.less
index af842c24e0..f6f1e44b3f 100644
--- a/styles/setup.less
+++ b/styles/setup.less
@@ -2,80 +2,220 @@
display: flex;
height: 100%;
width: 100%;
- flex-direction: row;
+ flex-direction: column;
//-webkit-app-region: drag;
- .image {
+ .setup-content {
display: flex;
- width: 50%;
- height: 100%;
- flex: 0 auto;
- align-items: center;
- justify-content: flex-end;
- padding-right: 40px;
- padding-left: 80px;
+ flex-direction: row;
- img {
- width: 399px;
- height: 340px;
+ flex: 1 auto;
+
+ .image {
+ display: flex;
+ flex: 1 auto;
+ align-items: center;
+ justify-content: flex-end;
+ padding-right: 40px;
+
+ img {
+ width: 399px;
+ height: 340px;
+ }
+
+ .contents {
+ position: relative;
+ .detail {
+ position: absolute;
+ right: -20px;
+ bottom: 0;
+ }
+ }
}
- .contents {
- position: relative;
- .detail {
- position: absolute;
- right: -20px;
- bottom: 0;
+ .form-section {
+ display: flex;
+ flex: 1 auto;
+ align-items: flex-end;
+ justify-content: center;
+ padding-right: 60px;
+ padding-left: 80px;
+ flex-direction: column;
+
+ img {
+ width: 323px;
+ height: 64px;
}
- }
- }
- .desc {
- display: flex;
- width: 50%;
- height: 100%;
+ form {
+ margin-top: 40px;
+ text-align: right;
+ input[type="text"], input[type="password"] {
+ display: block;
+ border: 0;
+ border-bottom: 1px solid @gray-lightest;
+ color: @gray-normal;
+ font-weight: 300;
+ padding: 10px 5px;
+ transition: all 0.25s;
+ font-size: 18px;
+ width: 340px;
+ &.error {
+ border-bottom: 1px solid @brand-negative;
+ &:focus {
+ border-bottom: 1px solid @brand-negative;
+ }
+ }
+ &:focus {
+ outline: 0;
+ border-bottom: 1px solid @brand-action;
+ }
+ &::-webkit-input-placeholder {
+ color: @gray-lighter;
+ font-weight: 400;
+ }
+ }
- align-items: center;
- padding-left: 40px;
+ div.checkbox {
+ text-align: left;
+ color: @gray-normal;
+ }
- .content {
- max-width: 320px;
+ div.submit {
+ margin-top: 10px;
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
- h1 {
- color: @gray-darkest;
- font-size: 24px;
- }
- h4 {
- color: @gray-lightest;
- font-size: 13px;
- margin-top: -30px;
- }
- p {
- font-size: 13px;
- color: @gray-normal;
- &.error {
+ .spinner {
+ margin-right: 10px;
+ flex: 0 auto;
+ }
+
+ button[type="submit"] {
+ flex: 0 auto;
+ display: block;
+ font-size: 18px;
+ padding: 10px 20px;
+ color: white;
+ background-color: @brand-action;
+ border: 0;
+ &:hover {
+ background-color: darken(@brand-action, 5%);
+ }
+ }
+ }
+
+ hr {
+ border-top: 1px solid #D7DFEA;
+ }
+
+ .extra {
+ text-align: center;
+ font-size: 14px;
+ color: @gray-normal;
+ margin-top: 16px;
+ .btn {
+ margin-left: 6px;
+ position: relative;
+ top: -3px;
+ }
+
+ a {
+ color: @brand-primary;
+ }
+ }
+ .link {
+ display: block;
+ font-size: 10px;
+ text-align: left;
+ position: relative;
+ top: -15px;
+ left: 5px;
+ }
+ .error-message {
+ font-size: 12px;
+ margin-top: 5px;
+ margin-bottom: 0;
+ min-height: 17px;
+ color: @brand-negative;
+ }
+ div.error {
+ font-size: 13px;
+ color: @gray-normal;
color: @brand-negative;
background-color: lighten(@brand-negative, 32%);
padding: 10px;
border-radius: 4px;
- max-height: 400px;
- overflow: auto;
- }
- }
- .setup-actions {
- button {
- margin-right: 12px;
+ width: 340px;
+ display: none;
}
}
}
- }
-
- p {
- &.error {
- color: @brand-danger;
- word-wrap: break-word;
+ .btn-skip {
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
+ font-size: 14px;
}
- margin-top: 20px;
+
+ .btn-close {
+ -webkit-app-region: no-drag;
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ font-size: 14px;
+ }
+
+ .desc {
+ flex: 1 auto;
+ display: flex;
+
+ align-items: center;
+ padding-left: 40px;
+
+ .content {
+ max-width: 320px;
+
+ h1 {
+ color: @gray-darkest;
+ font-size: 24px;
+ }
+ h4 {
+ color: @gray-lightest;
+ font-size: 13px;
+ margin-top: -30px;
+ }
+ p {
+ font-size: 13px;
+ color: @gray-normal;
+ &.error {
+ color: @brand-negative;
+ background-color: lighten(@brand-negative, 32%);
+ padding: 10px;
+ border-radius: 4px;
+ max-height: 400px;
+ overflow: auto;
+ }
+ }
+ .setup-actions {
+ button {
+ margin-right: 12px;
+ }
+ }
+ }
+
+ }
+
+ p {
+ &.error {
+ color: @brand-danger;
+ word-wrap: break-word;
+ }
+ margin-top: 20px;
+ }
+
}
}
diff --git a/styles/spinner.less b/styles/spinner.less
new file mode 100644
index 0000000000..b13ff7e726
--- /dev/null
+++ b/styles/spinner.less
@@ -0,0 +1,97 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) 2014-2015 Daniel Cardoso
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+@import "variables.less";
+
+.la-ball-clip-rotate {
+ display: block;
+}
+.la-ball-clip-rotate > div {
+ box-sizing: content-box;
+ color: #fff;
+ background: #fff;
+ border-color: #fff;
+ border-style: solid;
+ border-width: 0;
+}
+.la-ball-clip-rotate:after {
+ display: table;
+ clear: both;
+ line-height: 0;
+ content: "";
+}
+.la-ball-clip-rotate.la-dark > div {
+ color: @brand-primary;
+ background: @brand-primary;
+ border-color: @brand-primary;
+}
+/*
+ * Animation
+ */
+@-webkit-keyframes ball-clip-rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 50% {
+ transform: rotate(180deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+.la-ball-clip-rotate {
+ width: 32px;
+ height: 32px;
+}
+.la-ball-clip-rotate > div {
+ display: block;
+ float: left;
+ width: 28px;
+ height: 28px;
+ margin: 0;
+ background: transparent !important;
+ border-style: solid;
+ border-width: 1.8px;
+ border-bottom-color: transparent !important;
+ border-radius: 100%;
+ -webkit-animation: ball-clip-rotate 0.9s linear infinite;
+}
+.la-ball-clip-rotate.la-sm {
+ width: 16px;
+ height: 16px;
+}
+.la-ball-clip-rotate.la-sm > div {
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ border-width: 1px;
+}
+.la-ball-clip-rotate.la-lg {
+ width: 48px;
+ height: 48px;
+}
+.la-ball-clip-rotate.la-lg > div {
+ width: 42px;
+ height: 42px;
+ margin: 0;
+ border-width: 2px;
+}
+.la-ball-clip-rotate.la-2x {
+ width: 64px;
+ height: 64px;
+}
+.la-ball-clip-rotate.la-2x > div {
+ width: 56px;
+ height: 56px;
+ margin: 0;
+ border-width: 2px;
+}