From 58cda9cc7fb79ca9df6746de7f9662bc08dc156a Mon Sep 17 00:00:00 2001 From: Stefan Malzner Date: Fri, 13 Oct 2017 12:29:40 +0200 Subject: initial commit --- src/I18n.js | 28 + src/actions/app.js | 23 + src/actions/index.js | 28 + src/actions/lib/actions.js | 18 + src/actions/news.js | 7 + src/actions/payment.js | 8 + src/actions/recipe.js | 9 + src/actions/recipePreview.js | 7 + src/actions/requests.js | 3 + src/actions/service.js | 75 +++ src/actions/settings.js | 10 + src/actions/ui.js | 11 + src/actions/user.js | 30 ++ src/api/AppApi.js | 9 + src/api/LocalApi.js | 18 + src/api/NewsApi.js | 14 + src/api/PaymentApi.js | 22 + src/api/RecipePreviewsApi.js | 17 + src/api/RecipesApi.js | 17 + src/api/ServicesApi.js | 33 ++ src/api/UserApi.js | 49 ++ src/api/index.js | 19 + src/api/server/LocalApi.js | 33 ++ src/api/server/ServerApi.js | 574 +++++++++++++++++++++ src/app.js | 103 ++++ src/assets/fonts/OpenSans-Bold.ttf | Bin 0 -> 224592 bytes src/assets/fonts/OpenSans-BoldItalic.ttf | Bin 0 -> 213292 bytes src/assets/fonts/OpenSans-ExtraBold.ttf | Bin 0 -> 222584 bytes src/assets/fonts/OpenSans-ExtraBoldItalic.ttf | Bin 0 -> 213420 bytes src/assets/fonts/OpenSans-Light.ttf | Bin 0 -> 222412 bytes src/assets/fonts/OpenSans-Regular.ttf | Bin 0 -> 217360 bytes src/assets/images/adlk.svg | 53 ++ src/assets/images/emoji/dontknow.png | Bin 0 -> 29336 bytes src/assets/images/emoji/sad.png | Bin 0 -> 25270 bytes src/assets/images/emoji/star.png | Bin 0 -> 16093 bytes src/assets/images/logo.svg | 35 ++ src/assets/images/sm.png | Bin 0 -> 751417 bytes src/assets/images/taskbar/win32/taskbar-1.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-10.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-2.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-3.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-4.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-5.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-6.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-7.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-8.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-9.ico | Bin 0 -> 32038 bytes src/assets/images/taskbar/win32/taskbar-alert.ico | Bin 0 -> 32038 bytes src/assets/images/tray/darwin/tray-active.png | Bin 0 -> 396 bytes src/assets/images/tray/darwin/tray-active@2x.png | Bin 0 -> 1291 bytes .../images/tray/darwin/tray-unread-active.png | Bin 0 -> 424 bytes .../images/tray/darwin/tray-unread-active@2x.png | Bin 0 -> 1359 bytes src/assets/images/tray/darwin/tray-unread.png | Bin 0 -> 1264 bytes src/assets/images/tray/darwin/tray-unread@2x.png | Bin 0 -> 2026 bytes src/assets/images/tray/darwin/tray.png | Bin 0 -> 1230 bytes src/assets/images/tray/darwin/tray@2x.png | Bin 0 -> 1545 bytes src/assets/images/tray/linux/tray-unread.png | Bin 0 -> 1264 bytes src/assets/images/tray/linux/tray-unread@2x.png | Bin 0 -> 2026 bytes src/assets/images/tray/linux/tray.png | Bin 0 -> 1230 bytes src/assets/images/tray/linux/tray@2x.png | Bin 0 -> 1545 bytes src/assets/images/tray/win32/tray-unread.ico | Bin 0 -> 1150 bytes src/assets/images/tray/win32/tray-unread@2x.ico | Bin 0 -> 5430 bytes src/assets/images/tray/win32/tray.ico | Bin 0 -> 1150 bytes src/assets/images/tray/win32/tray@2x.ico | Bin 0 -> 5430 bytes src/components/auth/AuthLayout.js | 88 ++++ src/components/auth/Import.js | 168 ++++++ src/components/auth/Invite.js | 111 ++++ src/components/auth/Login.js | 161 ++++++ src/components/auth/Password.js | 135 +++++ src/components/auth/Pricing.js | 130 +++++ src/components/auth/Signup.js | 206 ++++++++ src/components/auth/Welcome.js | 69 +++ src/components/layout/AppLayout.js | 148 ++++++ src/components/layout/Sidebar.js | 75 +++ src/components/services/content/ServiceWebview.js | 73 +++ src/components/services/content/Services.js | 81 +++ src/components/services/tabs/TabBarSortableList.js | 44 ++ src/components/services/tabs/TabItem.js | 136 +++++ src/components/services/tabs/Tabbar.js | 77 +++ src/components/settings/SettingsLayout.js | 56 ++ .../settings/account/AccountDashboard.js | 286 ++++++++++ .../settings/navigation/SettingsNavigation.js | 84 +++ src/components/settings/recipes/RecipeItem.js | 34 ++ .../settings/recipes/RecipesDashboard.js | 151 ++++++ .../settings/services/EditServiceForm.js | 277 ++++++++++ src/components/settings/services/ServiceError.js | 68 +++ src/components/settings/services/ServiceItem.js | 98 ++++ .../settings/services/ServicesDashboard.js | 155 ++++++ .../settings/settings/EditSettingsForm.js | 148 ++++++ src/components/settings/user/EditUserForm.js | 145 ++++++ src/components/ui/AppLoader.js | 15 + src/components/ui/Button.js | 78 +++ src/components/ui/InfoBar.js | 88 ++++ src/components/ui/Infobox.js | 87 ++++ src/components/ui/Input.js | 148 ++++++ src/components/ui/Link.js | 78 +++ src/components/ui/Loader.js | 41 ++ src/components/ui/Radio.js | 89 ++++ src/components/ui/SearchInput.js | 124 +++++ src/components/ui/Select.js | 70 +++ src/components/ui/Subscription.js | 265 ++++++++++ src/components/ui/SubscriptionPopup.js | 84 +++ src/components/ui/Tabs/TabItem.js | 17 + src/components/ui/Tabs/Tabs.js | 69 +++ src/components/ui/Tabs/index.js | 6 + src/components/ui/Toggle.js | 67 +++ src/components/ui/effects/Appear.js | 51 ++ src/config.js | 5 + src/containers/auth/AuthLayoutContainer.js | 47 ++ src/containers/auth/ImportScreen.js | 41 ++ src/containers/auth/InviteScreen.js | 29 ++ src/containers/auth/LoginScreen.js | 45 ++ src/containers/auth/PasswordScreen.js | 38 ++ src/containers/auth/PricingScreen.js | 53 ++ src/containers/auth/SignupScreen.js | 43 ++ src/containers/auth/WelcomeScreen.js | 34 ++ src/containers/layout/AppLayoutContainer.js | 166 ++++++ src/containers/settings/AccountScreen.js | 114 ++++ src/containers/settings/EditServiceScreen.js | 208 ++++++++ src/containers/settings/EditSettingsScreen.js | 167 ++++++ src/containers/settings/EditUserScreen.js | 165 ++++++ src/containers/settings/RecipesScreen.js | 126 +++++ src/containers/settings/ServicesScreen.js | 75 +++ src/containers/settings/SettingsWindow.js | 43 ++ src/containers/ui/SubscriptionFormScreen.js | 126 +++++ src/containers/ui/SubscriptionPopupScreen.js | 43 ++ src/electron/Settings.js | 15 + src/electron/exception.js | 4 + src/electron/ipc-api/appIndicator.js | 80 +++ src/electron/ipc-api/autoUpdate.js | 54 ++ src/electron/ipc-api/index.js | 9 + src/electron/ipc-api/settings.js | 10 + src/electron/ipc-api/tray.js | 48 ++ src/electron/webview-ime-focus.js | 40 ++ src/environment.js | 22 + src/helpers/password-helpers.js | 36 ++ src/helpers/recipe-helpers.js | 39 ++ src/helpers/routing-helpers.js | 4 + src/helpers/validation-helpers.js | 48 ++ src/helpers/webview-ime-focus-helpers.js | 38 ++ src/i18n/globalMessages.js | 16 + src/i18n/languages.js | 4 + src/i18n/locales/en-US.json | 167 ++++++ src/i18n/translations.js | 13 + src/index.html | 30 ++ src/index.js | 147 ++++++ src/lib/Form.js | 31 ++ src/lib/Menu.js | 259 ++++++++++ src/lib/Miner.js | 72 +++ src/lib/TouchBar.js | 45 ++ src/lib/analytics.js | 42 ++ src/models/News.js | 19 + src/models/Order.js | 17 + src/models/Plan.js | 16 + src/models/Recipe.js | 52 ++ src/models/RecipePreview.js | 16 + src/models/Service.js | 132 +++++ src/models/User.js | 41 ++ src/prop-types.js | 14 + src/stores/AppStore.js | 309 +++++++++++ src/stores/GlobalErrorStore.js | 28 + src/stores/NewsStore.js | 42 ++ src/stores/PaymentStore.js | 47 ++ src/stores/RecipePreviewsStore.js | 50 ++ src/stores/RecipesStore.js | 96 ++++ src/stores/RequestStore.js | 59 +++ src/stores/ServicesStore.js | 503 ++++++++++++++++++ src/stores/SettingsStore.js | 55 ++ src/stores/UIStore.js | 34 ++ src/stores/UserStore.js | 272 ++++++++++ src/stores/index.js | 34 ++ src/stores/lib/CachedRequest.js | 106 ++++ src/stores/lib/Reaction.js | 22 + src/stores/lib/Request.js | 112 ++++ src/stores/lib/Store.js | 44 ++ src/styles/animations.scss | 90 ++++ src/styles/auth.scss | 144 ++++++ src/styles/badge.scss | 15 + src/styles/button.scss | 74 +++ src/styles/colors.scss | 22 + src/styles/config.scss | 1 + src/styles/content-tabs.scss | 52 ++ src/styles/fonts.scss | 44 ++ src/styles/info-bar.scss | 79 +++ src/styles/infobox.scss | 61 +++ src/styles/input.scss | 99 ++++ src/styles/layout.scss | 141 +++++ src/styles/main.scss | 36 ++ src/styles/mixins.scss | 9 + src/styles/radio.scss | 34 ++ src/styles/recipes.scss | 72 +++ src/styles/reset.scss | 95 ++++ src/styles/searchInput.scss | 4 + src/styles/select.scss | 19 + src/styles/service-table.scss | 62 +++ src/styles/services.scss | 60 +++ src/styles/settings.scss | 392 ++++++++++++++ src/styles/subscription-popup.scss | 20 + src/styles/subscription.scss | 72 +++ src/styles/tabs.scss | 72 +++ src/styles/toggle.scss | 47 ++ src/styles/tooltip.scss | 4 + src/styles/type.scss | 73 +++ src/styles/util.scss | 20 + src/styles/welcome.scss | 75 +++ src/webview/ime.js | 10 + src/webview/lib/RecipeWebview.js | 74 +++ src/webview/notifications.js | 45 ++ src/webview/plugin.js | 24 + src/webview/spellchecker.js | 14 + src/webview/zoom.js | 37 ++ 211 files changed, 12987 insertions(+) create mode 100644 src/I18n.js create mode 100644 src/actions/app.js create mode 100644 src/actions/index.js create mode 100644 src/actions/lib/actions.js create mode 100644 src/actions/news.js create mode 100644 src/actions/payment.js create mode 100644 src/actions/recipe.js create mode 100644 src/actions/recipePreview.js create mode 100644 src/actions/requests.js create mode 100644 src/actions/service.js create mode 100644 src/actions/settings.js create mode 100644 src/actions/ui.js create mode 100644 src/actions/user.js create mode 100644 src/api/AppApi.js create mode 100644 src/api/LocalApi.js create mode 100644 src/api/NewsApi.js create mode 100644 src/api/PaymentApi.js create mode 100644 src/api/RecipePreviewsApi.js create mode 100644 src/api/RecipesApi.js create mode 100644 src/api/ServicesApi.js create mode 100644 src/api/UserApi.js create mode 100644 src/api/index.js create mode 100644 src/api/server/LocalApi.js create mode 100644 src/api/server/ServerApi.js create mode 100644 src/app.js create mode 100755 src/assets/fonts/OpenSans-Bold.ttf create mode 100755 src/assets/fonts/OpenSans-BoldItalic.ttf create mode 100755 src/assets/fonts/OpenSans-ExtraBold.ttf create mode 100755 src/assets/fonts/OpenSans-ExtraBoldItalic.ttf create mode 100755 src/assets/fonts/OpenSans-Light.ttf create mode 100755 src/assets/fonts/OpenSans-Regular.ttf create mode 100644 src/assets/images/adlk.svg create mode 100644 src/assets/images/emoji/dontknow.png create mode 100644 src/assets/images/emoji/sad.png create mode 100755 src/assets/images/emoji/star.png create mode 100644 src/assets/images/logo.svg create mode 100644 src/assets/images/sm.png create mode 100644 src/assets/images/taskbar/win32/taskbar-1.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-10.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-2.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-3.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-4.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-5.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-6.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-7.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-8.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-9.ico create mode 100644 src/assets/images/taskbar/win32/taskbar-alert.ico create mode 100644 src/assets/images/tray/darwin/tray-active.png create mode 100644 src/assets/images/tray/darwin/tray-active@2x.png create mode 100644 src/assets/images/tray/darwin/tray-unread-active.png create mode 100644 src/assets/images/tray/darwin/tray-unread-active@2x.png create mode 100644 src/assets/images/tray/darwin/tray-unread.png create mode 100644 src/assets/images/tray/darwin/tray-unread@2x.png create mode 100644 src/assets/images/tray/darwin/tray.png create mode 100644 src/assets/images/tray/darwin/tray@2x.png create mode 100644 src/assets/images/tray/linux/tray-unread.png create mode 100644 src/assets/images/tray/linux/tray-unread@2x.png create mode 100644 src/assets/images/tray/linux/tray.png create mode 100644 src/assets/images/tray/linux/tray@2x.png create mode 100644 src/assets/images/tray/win32/tray-unread.ico create mode 100644 src/assets/images/tray/win32/tray-unread@2x.ico create mode 100644 src/assets/images/tray/win32/tray.ico create mode 100644 src/assets/images/tray/win32/tray@2x.ico create mode 100644 src/components/auth/AuthLayout.js create mode 100644 src/components/auth/Import.js create mode 100644 src/components/auth/Invite.js create mode 100644 src/components/auth/Login.js create mode 100644 src/components/auth/Password.js create mode 100644 src/components/auth/Pricing.js create mode 100644 src/components/auth/Signup.js create mode 100644 src/components/auth/Welcome.js create mode 100644 src/components/layout/AppLayout.js create mode 100644 src/components/layout/Sidebar.js create mode 100644 src/components/services/content/ServiceWebview.js create mode 100644 src/components/services/content/Services.js create mode 100644 src/components/services/tabs/TabBarSortableList.js create mode 100644 src/components/services/tabs/TabItem.js create mode 100644 src/components/services/tabs/Tabbar.js create mode 100644 src/components/settings/SettingsLayout.js create mode 100644 src/components/settings/account/AccountDashboard.js create mode 100644 src/components/settings/navigation/SettingsNavigation.js create mode 100644 src/components/settings/recipes/RecipeItem.js create mode 100644 src/components/settings/recipes/RecipesDashboard.js create mode 100644 src/components/settings/services/EditServiceForm.js create mode 100644 src/components/settings/services/ServiceError.js create mode 100644 src/components/settings/services/ServiceItem.js create mode 100644 src/components/settings/services/ServicesDashboard.js create mode 100644 src/components/settings/settings/EditSettingsForm.js create mode 100644 src/components/settings/user/EditUserForm.js create mode 100644 src/components/ui/AppLoader.js create mode 100644 src/components/ui/Button.js create mode 100644 src/components/ui/InfoBar.js create mode 100644 src/components/ui/Infobox.js create mode 100644 src/components/ui/Input.js create mode 100644 src/components/ui/Link.js create mode 100644 src/components/ui/Loader.js create mode 100644 src/components/ui/Radio.js create mode 100644 src/components/ui/SearchInput.js create mode 100644 src/components/ui/Select.js create mode 100644 src/components/ui/Subscription.js create mode 100644 src/components/ui/SubscriptionPopup.js create mode 100644 src/components/ui/Tabs/TabItem.js create mode 100644 src/components/ui/Tabs/Tabs.js create mode 100644 src/components/ui/Tabs/index.js create mode 100644 src/components/ui/Toggle.js create mode 100644 src/components/ui/effects/Appear.js create mode 100644 src/config.js create mode 100644 src/containers/auth/AuthLayoutContainer.js create mode 100644 src/containers/auth/ImportScreen.js create mode 100644 src/containers/auth/InviteScreen.js create mode 100644 src/containers/auth/LoginScreen.js create mode 100644 src/containers/auth/PasswordScreen.js create mode 100644 src/containers/auth/PricingScreen.js create mode 100644 src/containers/auth/SignupScreen.js create mode 100644 src/containers/auth/WelcomeScreen.js create mode 100644 src/containers/layout/AppLayoutContainer.js create mode 100644 src/containers/settings/AccountScreen.js create mode 100644 src/containers/settings/EditServiceScreen.js create mode 100644 src/containers/settings/EditSettingsScreen.js create mode 100644 src/containers/settings/EditUserScreen.js create mode 100644 src/containers/settings/RecipesScreen.js create mode 100644 src/containers/settings/ServicesScreen.js create mode 100644 src/containers/settings/SettingsWindow.js create mode 100644 src/containers/ui/SubscriptionFormScreen.js create mode 100644 src/containers/ui/SubscriptionPopupScreen.js create mode 100644 src/electron/Settings.js create mode 100644 src/electron/exception.js create mode 100644 src/electron/ipc-api/appIndicator.js create mode 100644 src/electron/ipc-api/autoUpdate.js create mode 100644 src/electron/ipc-api/index.js create mode 100644 src/electron/ipc-api/settings.js create mode 100644 src/electron/ipc-api/tray.js create mode 100644 src/electron/webview-ime-focus.js create mode 100644 src/environment.js create mode 100644 src/helpers/password-helpers.js create mode 100644 src/helpers/recipe-helpers.js create mode 100644 src/helpers/routing-helpers.js create mode 100644 src/helpers/validation-helpers.js create mode 100644 src/helpers/webview-ime-focus-helpers.js create mode 100644 src/i18n/globalMessages.js create mode 100644 src/i18n/languages.js create mode 100644 src/i18n/locales/en-US.json create mode 100644 src/i18n/translations.js create mode 100644 src/index.html create mode 100644 src/index.js create mode 100644 src/lib/Form.js create mode 100644 src/lib/Menu.js create mode 100644 src/lib/Miner.js create mode 100644 src/lib/TouchBar.js create mode 100644 src/lib/analytics.js create mode 100644 src/models/News.js create mode 100644 src/models/Order.js create mode 100644 src/models/Plan.js create mode 100644 src/models/Recipe.js create mode 100644 src/models/RecipePreview.js create mode 100644 src/models/Service.js create mode 100644 src/models/User.js create mode 100644 src/prop-types.js create mode 100644 src/stores/AppStore.js create mode 100644 src/stores/GlobalErrorStore.js create mode 100644 src/stores/NewsStore.js create mode 100644 src/stores/PaymentStore.js create mode 100644 src/stores/RecipePreviewsStore.js create mode 100644 src/stores/RecipesStore.js create mode 100644 src/stores/RequestStore.js create mode 100644 src/stores/ServicesStore.js create mode 100644 src/stores/SettingsStore.js create mode 100644 src/stores/UIStore.js create mode 100644 src/stores/UserStore.js create mode 100644 src/stores/index.js create mode 100644 src/stores/lib/CachedRequest.js create mode 100644 src/stores/lib/Reaction.js create mode 100644 src/stores/lib/Request.js create mode 100644 src/stores/lib/Store.js create mode 100644 src/styles/animations.scss create mode 100644 src/styles/auth.scss create mode 100644 src/styles/badge.scss create mode 100644 src/styles/button.scss create mode 100644 src/styles/colors.scss create mode 100644 src/styles/config.scss create mode 100644 src/styles/content-tabs.scss create mode 100644 src/styles/fonts.scss create mode 100644 src/styles/info-bar.scss create mode 100644 src/styles/infobox.scss create mode 100644 src/styles/input.scss create mode 100644 src/styles/layout.scss create mode 100644 src/styles/main.scss create mode 100644 src/styles/mixins.scss create mode 100644 src/styles/radio.scss create mode 100644 src/styles/recipes.scss create mode 100644 src/styles/reset.scss create mode 100644 src/styles/searchInput.scss create mode 100644 src/styles/select.scss create mode 100644 src/styles/service-table.scss create mode 100644 src/styles/services.scss create mode 100644 src/styles/settings.scss create mode 100644 src/styles/subscription-popup.scss create mode 100644 src/styles/subscription.scss create mode 100644 src/styles/tabs.scss create mode 100644 src/styles/toggle.scss create mode 100644 src/styles/tooltip.scss create mode 100644 src/styles/type.scss create mode 100644 src/styles/util.scss create mode 100644 src/styles/welcome.scss create mode 100644 src/webview/ime.js create mode 100644 src/webview/lib/RecipeWebview.js create mode 100644 src/webview/notifications.js create mode 100644 src/webview/plugin.js create mode 100644 src/webview/spellchecker.js create mode 100644 src/webview/zoom.js (limited to 'src') diff --git a/src/I18n.js b/src/I18n.js new file mode 100644 index 000000000..ae3ba2fa9 --- /dev/null +++ b/src/I18n.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { inject, observer } from 'mobx-react'; +import { IntlProvider } from 'react-intl'; + +import { oneOrManyChildElements } from './prop-types'; +import translations from './i18n/translations'; +import UserStore from './stores/UserStore'; + +@inject('stores') @observer +export default class I18N extends Component { + render() { + const { stores, children } = this.props; + const { locale } = stores.app; + return ( + + {children} + + ); + } +} + +I18N.wrappedComponent.propTypes = { + stores: PropTypes.shape({ + user: PropTypes.instanceOf(UserStore).isRequired, + }).isRequired, + children: oneOrManyChildElements.isRequired, +}; diff --git a/src/actions/app.js b/src/actions/app.js new file mode 100644 index 000000000..5db4b739e --- /dev/null +++ b/src/actions/app.js @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; + +export default { + setBadge: { + unreadDirectMessageCount: PropTypes.number.isRequired, + unreadIndirectMessageCount: PropTypes.number, + }, + notify: { + title: PropTypes.string.isRequired, + options: PropTypes.object.isRequired, + serviceId: PropTypes.string, + }, + launchOnStartup: { + enable: PropTypes.bool.isRequired, + }, + openExternalUrl: { + url: PropTypes.string.isRequired, + }, + checkForUpdates: {}, + resetUpdateStatus: {}, + installUpdate: {}, + healthCheck: {}, +}; diff --git a/src/actions/index.js b/src/actions/index.js new file mode 100644 index 000000000..59acabb0b --- /dev/null +++ b/src/actions/index.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; + +import defineActions from './lib/actions'; +import service from './service'; +import recipe from './recipe'; +import recipePreview from './recipePreview'; +import ui from './ui'; +import app from './app'; +import user from './user'; +import payment from './payment'; +import news from './news'; +import settings from './settings'; +import requests from './requests'; + +const actions = Object.assign({}, { + service, + recipe, + recipePreview, + ui, + app, + user, + payment, + news, + settings, + requests, +}); + +export default defineActions(actions, PropTypes.checkPropTypes); diff --git a/src/actions/lib/actions.js b/src/actions/lib/actions.js new file mode 100644 index 000000000..499018d70 --- /dev/null +++ b/src/actions/lib/actions.js @@ -0,0 +1,18 @@ +export default (definitions, validate) => { + const newActions = {}; + Object.keys(definitions).forEach((scopeName) => { + newActions[scopeName] = {}; + Object.keys(definitions[scopeName]).forEach((actionName) => { + const action = (params) => { + const schema = definitions[scopeName][actionName]; + validate(schema, params, actionName); + action.notify(params); + }; + newActions[scopeName][actionName] = action; + action.listeners = []; + action.listen = listener => action.listeners.push(listener); + action.notify = params => action.listeners.forEach(listener => listener(params)); + }); + }); + return newActions; +}; diff --git a/src/actions/news.js b/src/actions/news.js new file mode 100644 index 000000000..db106e84f --- /dev/null +++ b/src/actions/news.js @@ -0,0 +1,7 @@ +import PropTypes from 'prop-types'; + +export default { + hide: { + newsId: PropTypes.string.isRequired, + }, +}; diff --git a/src/actions/payment.js b/src/actions/payment.js new file mode 100644 index 000000000..2aaefc025 --- /dev/null +++ b/src/actions/payment.js @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types'; + +export default { + createHostedPage: { + planId: PropTypes.string.isRequired, + }, + createDashboardUrl: {}, +}; diff --git a/src/actions/recipe.js b/src/actions/recipe.js new file mode 100644 index 000000000..29b0a151f --- /dev/null +++ b/src/actions/recipe.js @@ -0,0 +1,9 @@ +import PropTypes from 'prop-types'; + +export default { + install: { + recipeId: PropTypes.string.isRequired, + update: PropTypes.bool, + }, + update: {}, +}; diff --git a/src/actions/recipePreview.js b/src/actions/recipePreview.js new file mode 100644 index 000000000..36de3d844 --- /dev/null +++ b/src/actions/recipePreview.js @@ -0,0 +1,7 @@ +import PropTypes from 'prop-types'; + +export default { + search: { + needle: PropTypes.string.isRequired, + }, +}; diff --git a/src/actions/requests.js b/src/actions/requests.js new file mode 100644 index 000000000..89296e7ec --- /dev/null +++ b/src/actions/requests.js @@ -0,0 +1,3 @@ +export default { + retryRequiredRequests: {}, +}; diff --git a/src/actions/service.js b/src/actions/service.js new file mode 100644 index 000000000..7f429ca32 --- /dev/null +++ b/src/actions/service.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; + +export default { + setActive: { + serviceId: PropTypes.string.isRequired, + }, + showAddServiceInterface: { + recipeId: PropTypes.string.isRequired, + }, + createService: { + recipeId: PropTypes.string.isRequired, + serviceData: PropTypes.object.isRequired, + }, + createFromLegacyService: { + data: PropTypes.object.isRequired, + }, + updateService: { + serviceId: PropTypes.string.isRequired, + serviceData: PropTypes.object.isRequired, + redirect: PropTypes.bool, + }, + deleteService: { + serviceId: PropTypes.string.isRequired, + redirect: PropTypes.string, + }, + setUnreadMessageCount: { + serviceId: PropTypes.string.isRequired, + count: PropTypes.object.isRequired, + }, + setWebviewReference: { + serviceId: PropTypes.string.isRequired, + webview: PropTypes.object.isRequired, + }, + focusService: { + serviceId: PropTypes.string.isRequired, + }, + focusActiveService: {}, + toggleService: { + serviceId: PropTypes.string.isRequired, + }, + handleIPCMessage: { + serviceId: PropTypes.string.isRequired, + channel: PropTypes.string.isRequired, + args: PropTypes.array.isRequired, + }, + sendIPCMessage: { + serviceId: PropTypes.string.isRequired, + channel: PropTypes.string.isRequired, + args: PropTypes.object.isRequired, + }, + openWindow: { + event: PropTypes.object.isRequired, + }, + reload: { + serviceId: PropTypes.string.isRequired, + }, + reloadActive: {}, + reloadAll: {}, + reloadUpdatedServices: {}, + filter: { + needle: PropTypes.string.isRequired, + }, + resetFilter: {}, + reorder: { + oldIndex: PropTypes.number.isRequired, + newIndex: PropTypes.number.isRequired, + }, + toggleNotifications: { + serviceId: PropTypes.string.isRequired, + }, + openDevTools: { + serviceId: PropTypes.string.isRequired, + }, + openDevToolsForActiveService: {}, +}; diff --git a/src/actions/settings.js b/src/actions/settings.js new file mode 100644 index 000000000..3d53cd674 --- /dev/null +++ b/src/actions/settings.js @@ -0,0 +1,10 @@ +import PropTypes from 'prop-types'; + +export default { + update: { + settings: PropTypes.object.isRequired, + }, + remove: { + key: PropTypes.string.isRequired, + }, +}; diff --git a/src/actions/ui.js b/src/actions/ui.js new file mode 100644 index 000000000..b913b430b --- /dev/null +++ b/src/actions/ui.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +export default { + openSettings: { + path: PropTypes.string, + }, + closeSettings: {}, + toggleServiceUpdatedInfoBar: { + visible: PropTypes.bool, + }, +}; diff --git a/src/actions/user.js b/src/actions/user.js new file mode 100644 index 000000000..fe32b8a05 --- /dev/null +++ b/src/actions/user.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; + +export default { + login: { + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + }, + logout: {}, + signup: { + firstname: PropTypes.string.isRequired, + lastname: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + accountType: PropTypes.string.isRequired, + company: PropTypes.string, + }, + retrievePassword: { + email: PropTypes.string.isRequired, + }, + invite: { + invites: PropTypes.array.isRequired, + }, + update: { + userData: PropTypes.object.isRequired, + }, + resetStatus: {}, + importLegacyServices: PropTypes.arrayOf(PropTypes.shape({ + recipe: PropTypes.string.isRequired, + })).isRequired, +}; diff --git a/src/api/AppApi.js b/src/api/AppApi.js new file mode 100644 index 000000000..411c187f4 --- /dev/null +++ b/src/api/AppApi.js @@ -0,0 +1,9 @@ +export default class AppApi { + constructor(server) { + this.server = server; + } + + health() { + return this.server.healthCheck(); + } +} diff --git a/src/api/LocalApi.js b/src/api/LocalApi.js new file mode 100644 index 000000000..6f2b049d6 --- /dev/null +++ b/src/api/LocalApi.js @@ -0,0 +1,18 @@ +export default class LocalApi { + constructor(server, local) { + this.server = server; + this.local = local; + } + + getSettings() { + return this.local.getAppSettings(); + } + + updateSettings(data) { + return this.local.updateAppSettings(data); + } + + removeKey(key) { + return this.local.removeKey(key); + } +} diff --git a/src/api/NewsApi.js b/src/api/NewsApi.js new file mode 100644 index 000000000..294957511 --- /dev/null +++ b/src/api/NewsApi.js @@ -0,0 +1,14 @@ +export default class NewsApi { + constructor(server, local) { + this.server = server; + this.local = local; + } + + latest() { + return this.server.getLatestNews(); + } + + hide(id) { + return this.server.hideNews(id); + } +} diff --git a/src/api/PaymentApi.js b/src/api/PaymentApi.js new file mode 100644 index 000000000..3f6bb442e --- /dev/null +++ b/src/api/PaymentApi.js @@ -0,0 +1,22 @@ +export default class PaymentApi { + constructor(server, local) { + this.server = server; + this.local = local; + } + + plans() { + return this.server.getPlans(); + } + + getHostedPage(planId) { + return this.server.getHostedPage(planId); + } + + getDashboardUrl() { + return this.server.getPaymentDashboardUrl(); + } + + getOrders() { + return this.server.getSubscriptionOrders(); + } +} diff --git a/src/api/RecipePreviewsApi.js b/src/api/RecipePreviewsApi.js new file mode 100644 index 000000000..d9c675d76 --- /dev/null +++ b/src/api/RecipePreviewsApi.js @@ -0,0 +1,17 @@ +export default class ServicesApi { + constructor(server) { + this.server = server; + } + + all() { + return this.server.getRecipePreviews(); + } + + featured() { + return this.server.getFeaturedRecipePreviews(); + } + + search(needle) { + return this.server.searchRecipePreviews(needle); + } +} diff --git a/src/api/RecipesApi.js b/src/api/RecipesApi.js new file mode 100644 index 000000000..0573dacaf --- /dev/null +++ b/src/api/RecipesApi.js @@ -0,0 +1,17 @@ +export default class ServicesApi { + constructor(server) { + this.server = server; + } + + all() { + return this.server.getInstalledRecipes(); + } + + install(recipeId) { + return this.server.getRecipePackage(recipeId); + } + + update(recipes) { + return this.server.getRecipeUpdates(recipes); + } +} diff --git a/src/api/ServicesApi.js b/src/api/ServicesApi.js new file mode 100644 index 000000000..3cb40ba0d --- /dev/null +++ b/src/api/ServicesApi.js @@ -0,0 +1,33 @@ +export default class ServicesApi { + constructor(server) { + this.server = server; + } + + all() { + return this.server.getServices(); + } + + // one(customerId) { + // return this.server.getCustomer(customerId); + // } + // + // search(needle) { + // return this.server.searchCustomers(needle); + // } + // + create(recipeId, data) { + return this.server.createService(recipeId, data); + } + + delete(serviceId) { + return this.server.deleteService(serviceId); + } + + update(serviceId, data) { + return this.server.updateService(serviceId, data); + } + + reorder(data) { + return this.server.reorderService(data); + } +} diff --git a/src/api/UserApi.js b/src/api/UserApi.js new file mode 100644 index 000000000..e8fd75bed --- /dev/null +++ b/src/api/UserApi.js @@ -0,0 +1,49 @@ +import { hash } from '../helpers/password-helpers'; + +export default class UserApi { + constructor(server, local) { + this.server = server; + this.local = local; + } + + login(email, password) { + return this.server.login(email, hash(password)); + } + + logout() { + return this; + } + + signup(data) { + Object.assign(data, { + password: hash(data.password), + }); + return this.server.signup(data); + } + + password(email) { + return this.server.retrievePassword(email); + } + + invite(data) { + return this.server.inviteUser(data); + } + + getInfo() { + return this.server.userInfo(); + } + + updateInfo(data) { + const userData = data; + if (userData.oldPassword && userData.newPassword) { + userData.oldPassword = hash(userData.oldPassword); + userData.newPassword = hash(userData.newPassword); + } + + return this.server.updateUserInfo(userData); + } + + getLegacyServices() { + return this.server.getLegacyServices(); + } +} diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 000000000..3fc18c4b5 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,19 @@ +import AppApi from './AppApi'; +import ServicesApi from './ServicesApi'; +import RecipePreviewsApi from './RecipePreviewsApi'; +import RecipesApi from './RecipesApi'; +import UserApi from './UserApi'; +import LocalApi from './LocalApi'; +import PaymentApi from './PaymentApi'; +import NewsApi from './NewsApi'; + +export default (server, local) => ({ + app: new AppApi(server, local), + services: new ServicesApi(server, local), + recipePreviews: new RecipePreviewsApi(server, local), + recipes: new RecipesApi(server, local), + user: new UserApi(server, local), + local: new LocalApi(server, local), + payment: new PaymentApi(server, local), + news: new NewsApi(server, local), +}); diff --git a/src/api/server/LocalApi.js b/src/api/server/LocalApi.js new file mode 100644 index 000000000..79ac6e12f --- /dev/null +++ b/src/api/server/LocalApi.js @@ -0,0 +1,33 @@ +export default class LocalApi { + // App + async updateAppSettings(data) { + const currentSettings = await this.getAppSettings(); + const settings = Object.assign(currentSettings, data); + + localStorage.setItem('app', JSON.stringify(settings)); + console.debug('LocalApi::updateAppSettings resolves', settings); + + return settings; + } + + async getAppSettings() { + const settingsString = localStorage.getItem('app'); + try { + const settings = JSON.parse(settingsString) || {}; + console.debug('LocalApi::getAppSettings resolves', settings); + + return settings; + } catch (err) { + return {}; + } + } + + async removeKey(key) { + const settings = await this.getAppSettings(); + + if (Object.hasOwnProperty.call(settings, key)) { + delete settings[key]; + localStorage.setItem('app', JSON.stringify(settings)); + } + } +} diff --git a/src/api/server/ServerApi.js b/src/api/server/ServerApi.js new file mode 100644 index 000000000..b369796e8 --- /dev/null +++ b/src/api/server/ServerApi.js @@ -0,0 +1,574 @@ +import os from 'os'; +import path from 'path'; +import targz from 'tar.gz'; +import fs from 'fs-extra'; +import { remote } from 'electron'; + +import ServiceModel from '../../models/Service'; +import RecipePreviewModel from '../../models/RecipePreview'; +import RecipeModel from '../../models/Recipe'; +import PlanModel from '../../models/Plan'; +import NewsModel from '../../models/News'; +import UserModel from '../../models/User'; +import OrderModel from '../../models/Order'; + +import { API } from '../../environment'; + +import { + getRecipeDirectory, + getDevRecipeDirectory, + loadRecipeConfig, +} from '../../helpers/recipe-helpers'; + +module.paths.unshift( + getDevRecipeDirectory(), + getRecipeDirectory(), +); + +const { app } = remote; +const fetch = remote.require('electron-fetch'); + +const SERVER_URL = API; +const API_VERSION = 'v1'; + +export default class ServerApi { + recipePreviews = []; + recipes = []; + + // User + async login(email, passwordHash) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/auth/login`, this._prepareAuthRequest({ + method: 'POST', + headers: { + Authorization: `Basic ${window.btoa(`${email}:${passwordHash}`)}`, + }, + }, false)); + if (!request.ok) { + throw request; + } + const u = await request.json(); + + console.debug('ServerApi::login resolves', u); + return u.token; + } + + async signup(data) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/auth/signup`, this._prepareAuthRequest({ + method: 'POST', + body: JSON.stringify(data), + }, false)); + if (!request.ok) { + throw request; + } + const u = await request.json(); + + console.debug('ServerApi::signup resolves', u); + return u.token; + } + + async inviteUser(data) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/invite`, this._prepareAuthRequest({ + method: 'POST', + body: JSON.stringify(data), + })); + if (!request.ok) { + throw request; + } + + console.debug('ServerApi::inviteUser'); + return true; + } + + async retrievePassword(email) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/auth/password`, this._prepareAuthRequest({ + method: 'POST', + body: JSON.stringify({ + email, + }), + }, false)); + if (!request.ok) { + throw request; + } + const r = await request.json(); + + console.debug('ServerApi::retrievePassword'); + return r; + } + + async userInfo() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + const user = new UserModel(data); + console.debug('ServerApi::userInfo resolves', user); + + return user; + } + + async updateUserInfo(data) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me`, this._prepareAuthRequest({ + method: 'PUT', + body: JSON.stringify(data), + })); + if (!request.ok) { + throw request; + } + const updatedData = await request.json(); + + const user = Object.assign(updatedData, { data: new UserModel(updatedData.data) }); + console.debug('ServerApi::updateUserInfo resolves', user); + return user; + } + + // Services + async getServices() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me/services`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + let services = await this._mapServiceModels(data); + services = services.filter(service => service !== null); + console.debug('ServerApi::getServices resolves', services); + return services; + } + + async createService(recipeId, data) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service`, this._prepareAuthRequest({ + method: 'POST', + body: JSON.stringify(Object.assign({ + recipeId, + }, data)), + })); + if (!request.ok) { + throw request; + } + const serviceData = await request.json(); + const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) }); + + console.debug('ServerApi::createService resolves', service); + return service; + } + + async updateService(recipeId, data) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${recipeId}`, this._prepareAuthRequest({ + method: 'PUT', + body: JSON.stringify(data), + })); + if (!request.ok) { + throw request; + } + const serviceData = await request.json(); + const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) }); + + console.debug('ServerApi::updateService resolves', service); + return service; + } + + async reorderService(data) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/reorder`, this._prepareAuthRequest({ + method: 'PUT', + body: JSON.stringify(data), + })); + if (!request.ok) { + throw request; + } + const serviceData = await request.json(); + console.debug('ServerApi::reorderService resolves', serviceData); + return serviceData; + } + + async deleteService(id) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${id}`, this._prepareAuthRequest({ + method: 'DELETE', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + console.debug('ServerApi::deleteService resolves', data); + return data; + } + + // Recipes + async getInstalledRecipes() { + const recipesDirectory = getRecipeDirectory(); + const paths = fs.readdirSync(recipesDirectory) + .filter(file => ( + fs.statSync(path.join(recipesDirectory, file)).isDirectory() + && file !== 'temp' + && file !== 'dev' + )); + + this.recipes = paths.map((id) => { + // eslint-disable-next-line + const Recipe = require(id)(RecipeModel); + return new Recipe(loadRecipeConfig(id)); + }).filter(recipe => recipe.id); + + this.recipes = this.recipes.concat(this._getDevRecipes()); + + console.debug('StubServerApi::getInstalledRecipes resolves', this.recipes); + return this.recipes; + } + + async getRecipeUpdates(recipeVersions) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/update`, this._prepareAuthRequest({ + method: 'POST', + body: JSON.stringify(recipeVersions), + })); + if (!request.ok) { + throw request; + } + const recipes = await request.json(); + console.debug('ServerApi::getRecipeUpdates resolves', recipes); + return recipes; + } + + // Recipes Previews + async getRecipePreviews() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + const recipePreviews = this._mapRecipePreviewModel(data); + console.debug('ServerApi::getRecipes resolves', recipePreviews); + + return recipePreviews; + } + + async getFeaturedRecipePreviews() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/popular`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + // data = this._addLocalRecipesToPreviews(data); + + const recipePreviews = this._mapRecipePreviewModel(data); + console.debug('ServerApi::getFeaturedRecipes resolves', recipePreviews); + return recipePreviews; + } + + async searchRecipePreviews(needle) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/search?needle=${needle}`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + const recipePreviews = this._mapRecipePreviewModel(data); + console.debug('ServerApi::searchRecipePreviews resolves', recipePreviews); + return recipePreviews; + } + + async getRecipePackage(recipeId) { + try { + const recipesDirectory = path.join(app.getPath('userData'), 'recipes'); + + const recipeTempDirectory = path.join(recipesDirectory, 'temp', recipeId); + const archivePath = path.join(recipeTempDirectory, 'recipe.tar.gz'); + const packageUrl = `${SERVER_URL}/${API_VERSION}/recipes/download/${recipeId}`; + + fs.ensureDirSync(recipeTempDirectory); + const res = await fetch(packageUrl); + const buffer = await res.buffer(); + fs.writeFileSync(archivePath, buffer); + + await targz().extract(archivePath, recipeTempDirectory); + + const { id } = fs.readJsonSync(path.join(recipeTempDirectory, 'package.json')); + const recipeDirectory = path.join(recipesDirectory, id); + + fs.copySync(recipeTempDirectory, recipeDirectory); + fs.remove(recipeTempDirectory); + fs.remove(path.join(recipesDirectory, recipeId, 'recipe.tar.gz')); + + return id; + } catch (err) { + console.error(err); + + return false; + } + } + + // Payment + async getPlans() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/payment/plans`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + const plan = new PlanModel(data); + console.debug('ServerApi::getPlans resolves', plan); + return plan; + } + + async getHostedPage(planId) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/payment/init`, this._prepareAuthRequest({ + method: 'POST', + body: JSON.stringify({ + planId, + }), + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + console.debug('ServerApi::getHostedPage resolves', data); + return data; + } + + async getPaymentDashboardUrl() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me/billing`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + + console.debug('ServerApi::getPaymentDashboardUrl resolves', data); + return data; + } + + async getSubscriptionOrders() { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me/subscription`, this._prepareAuthRequest({ + method: 'GET', + })); + if (!request.ok) { + throw request; + } + const data = await request.json(); + const orders = this._mapOrderModels(data); + console.debug('ServerApi::getSubscriptionOrders resolves', orders); + return orders; + } + + // News + async getLatestNews() { + // eslint-disable-next-line + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/news?platform=${os.platform()}&arch=${os.arch()}version=${app.getVersion()}`, + this._prepareAuthRequest({ + method: 'GET', + })); + + if (!request.ok) { + throw request; + } + const data = await request.json(); + const news = this._mapNewsModels(data); + console.debug('ServerApi::getLatestNews resolves', news); + return news; + } + + async hideNews(id) { + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/news/${id}/read`, + this._prepareAuthRequest({ + method: 'GET', + })); + + if (!request.ok) { + throw request; + } + + console.debug('ServerApi::hideNews resolves', id); + } + + // Health Check + async healthCheck() { + const request = await window.fetch(`${SERVER_URL}/health`, this._prepareAuthRequest({ + method: 'GET', + }, false)); + if (!request.ok) { + throw request; + } + console.debug('ServerApi::healthCheck resolves'); + } + + async getLegacyServices() { + const file = path.join(app.getPath('userData'), 'settings', 'services.json'); + + try { + const config = fs.readJsonSync(file); + + if (Object.prototype.hasOwnProperty.call(config, 'services')) { + const services = await Promise.all(config.services.map(async (s) => { + const service = s; + const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/${s.service}`, + this._prepareAuthRequest({ + method: 'GET', + }), + ); + + if (request.status === 200) { + const data = await request.json(); + service.recipe = new RecipePreviewModel(data); + } + + return service; + })); + + console.debug('ServerApi::getLegacyServices resolves', services); + return services; + } + } catch (err) { + throw (new Error('ServerApi::getLegacyServices no config found')); + } + + return []; + } + + // Helper + async _mapServiceModels(services) { + return Promise.all(services + .map(async service => await this._prepareServiceModel(service)) // eslint-disable-line + ); + } + + async _prepareServiceModel(service) { + let recipe; + try { + recipe = this.recipes.find(r => r.id === service.recipeId); + + if (!recipe) { + console.warn(`Recipe '${service.recipeId}' not installed, trying to fetch from server`); + + await this.getRecipePackage(service.recipeId); + + console.debug('Rerun ServerAPI::getInstalledRecipes'); + await this.getInstalledRecipes(); + + recipe = this.recipes.find(r => r.id === service.recipeId); + + if (!recipe) { + console.warn(`Could not load recipe ${service.recipeId}`); + return null; + } + } + + return new ServiceModel(service, recipe); + } catch (e) { + console.debug(e); + return null; + } + } + + _mapRecipePreviewModel(recipes) { + return recipes.map((recipe) => { + try { + return new RecipePreviewModel(recipe); + } catch (e) { + console.error(e); + return null; + } + }).filter(recipe => recipe !== null); + } + + _mapNewsModels(news) { + return news.map((newsItem) => { + try { + return new NewsModel(newsItem); + } catch (e) { + console.error(e); + return null; + } + }).filter(newsItem => newsItem !== null); + } + + _mapOrderModels(orders) { + return orders.map((orderItem) => { + try { + return new OrderModel(orderItem); + } catch (e) { + console.error(e); + return null; + } + }).filter(orderItem => orderItem !== null); + } + + _prepareAuthRequest(options, auth = true) { + const request = Object.assign(options, { + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + 'X-Franz-Source': 'desktop', + 'X-Franz-Version': app.getVersion(), + 'X-Franz-platform': process.platform, + 'X-Franz-Timezone-Offset': new Date().getTimezoneOffset(), + 'X-Franz-System-Locale': app.getLocale(), + }, + }); + + // const headers = new window.Headers(); + // headers.append('foo', 'bar'); + // console.log(headers, request.headers); + // + // + // // request.headers.map((value, header) => headers.append(header, value)); + // Object.keys(request.headers).map((key) => { + // console.log(key); + // return headers.append(key, request.headers[key]); + // }); + // request.headers = headers; + + // console.log(request); + + if (auth) { + request.headers.Authorization = `Bearer ${localStorage.getItem('authToken')}`; + } + + return request; + } + + _getDevRecipes() { + const recipesDirectory = getDevRecipeDirectory(); + try { + const paths = fs.readdirSync(recipesDirectory) + .filter(file => fs.statSync(path.join(recipesDirectory, file)).isDirectory() && file !== 'temp'); + + const recipes = paths.map((id) => { + // eslint-disable-next-line + const Recipe = require(id)(RecipeModel); + return new Recipe(loadRecipeConfig(id)); + }).filter(recipe => recipe.id).map((data) => { + const recipe = data; + + recipe.icons = { + svg: `${recipe.path}/icon.svg`, + png: `${recipe.path}/icon.png`, + }; + recipe.local = true; + + return data; + }); + + return recipes; + } catch (err) { + console.debug('Folder `recipe/dev` does not exist'); + return false; + } + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 000000000..b539ea494 --- /dev/null +++ b/src/app.js @@ -0,0 +1,103 @@ +import { webFrame } from 'electron'; + +import React from 'react'; +import { render } from 'react-dom'; +import { Provider } from 'mobx-react'; +import { syncHistoryWithStore, RouterStore } from 'mobx-react-router'; +import { Router, Route, hashHistory, IndexRedirect } from 'react-router'; + +import 'babel-polyfill'; +import smoothScroll from 'smoothscroll-polyfill'; + +import ServerApi from './api/server/ServerApi'; +import LocalApi from './api/server/LocalApi'; +import storeFactory from './stores'; +import apiFactory from './api'; +import actions from './actions'; +import MenuFactory from './lib/Menu'; +import TouchBarFactory from './lib/TouchBar'; +import * as analytics from './lib/analytics'; + +import I18N from './I18n'; +import AppLayoutContainer from './containers/layout/AppLayoutContainer'; +import SettingsWindow from './containers/settings/SettingsWindow'; +import RecipesScreen from './containers/settings/RecipesScreen'; +import ServicesScreen from './containers/settings/ServicesScreen'; +import EditServiceScreen from './containers/settings/EditServiceScreen'; +import AccountScreen from './containers/settings/AccountScreen'; +import EditUserScreen from './containers/settings/EditUserScreen'; +import EditSettingsScreen from './containers/settings/EditSettingsScreen'; +import WelcomeScreen from './containers/auth/WelcomeScreen'; +import LoginScreen from './containers/auth/LoginScreen'; +import PasswordScreen from './containers/auth/PasswordScreen'; +import SignupScreen from './containers/auth/SignupScreen'; +import ImportScreen from './containers/auth/ImportScreen'; +import PricingScreen from './containers/auth/PricingScreen'; +import InviteScreen from './containers/auth/InviteScreen'; +import AuthLayoutContainer from './containers/auth/AuthLayoutContainer'; +import SubscriptionPopupScreen from './containers/ui/SubscriptionPopupScreen'; + +// Add Polyfills +smoothScroll.polyfill(); + +// Basic electron Setup +webFrame.setVisualZoomLevelLimits(1, 1); +webFrame.setLayoutZoomLevelLimits(0, 0); + +window.addEventListener('load', () => { + const api = apiFactory(new ServerApi(), new LocalApi()); + const router = new RouterStore(); + const history = syncHistoryWithStore(hashHistory, router); + const stores = storeFactory(api, actions, router); + const menu = new MenuFactory(stores, actions); + const touchBar = new TouchBarFactory(stores, actions); + + window.franz = { + stores, + actions, + api, + menu, + touchBar, + analytics, + render() { + const preparedApp = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + render(preparedApp, document.getElementById('root')); + }, + }; + window.franz.render(); +}); diff --git a/src/assets/fonts/OpenSans-Bold.ttf b/src/assets/fonts/OpenSans-Bold.ttf new file mode 100755 index 000000000..fd79d43be Binary files /dev/null and b/src/assets/fonts/OpenSans-Bold.ttf differ diff --git a/src/assets/fonts/OpenSans-BoldItalic.ttf b/src/assets/fonts/OpenSans-BoldItalic.ttf new file mode 100755 index 000000000..9bc800958 Binary files /dev/null and b/src/assets/fonts/OpenSans-BoldItalic.ttf differ diff --git a/src/assets/fonts/OpenSans-ExtraBold.ttf b/src/assets/fonts/OpenSans-ExtraBold.ttf new file mode 100755 index 000000000..21f6f84a0 Binary files /dev/null and b/src/assets/fonts/OpenSans-ExtraBold.ttf differ diff --git a/src/assets/fonts/OpenSans-ExtraBoldItalic.ttf b/src/assets/fonts/OpenSans-ExtraBoldItalic.ttf new file mode 100755 index 000000000..31cb68834 Binary files /dev/null and b/src/assets/fonts/OpenSans-ExtraBoldItalic.ttf differ diff --git a/src/assets/fonts/OpenSans-Light.ttf b/src/assets/fonts/OpenSans-Light.ttf new file mode 100755 index 000000000..0d381897d Binary files /dev/null and b/src/assets/fonts/OpenSans-Light.ttf differ diff --git a/src/assets/fonts/OpenSans-Regular.ttf b/src/assets/fonts/OpenSans-Regular.ttf new file mode 100755 index 000000000..db433349b Binary files /dev/null and b/src/assets/fonts/OpenSans-Regular.ttf differ diff --git a/src/assets/images/adlk.svg b/src/assets/images/adlk.svg new file mode 100644 index 000000000..eb50f345a --- /dev/null +++ b/src/assets/images/adlk.svg @@ -0,0 +1,53 @@ + + + + adlk-group + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/emoji/dontknow.png b/src/assets/images/emoji/dontknow.png new file mode 100644 index 000000000..a4473d862 Binary files /dev/null and b/src/assets/images/emoji/dontknow.png differ diff --git a/src/assets/images/emoji/sad.png b/src/assets/images/emoji/sad.png new file mode 100644 index 000000000..b8b6ff69b Binary files /dev/null and b/src/assets/images/emoji/sad.png differ diff --git a/src/assets/images/emoji/star.png b/src/assets/images/emoji/star.png new file mode 100755 index 000000000..0b9aa67da Binary files /dev/null and b/src/assets/images/emoji/star.png differ diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg new file mode 100644 index 000000000..87188f4aa --- /dev/null +++ b/src/assets/images/logo.svg @@ -0,0 +1,35 @@ + + + + franz + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/sm.png b/src/assets/images/sm.png new file mode 100644 index 000000000..2bf169bee Binary files /dev/null and b/src/assets/images/sm.png differ diff --git a/src/assets/images/taskbar/win32/taskbar-1.ico b/src/assets/images/taskbar/win32/taskbar-1.ico new file mode 100644 index 000000000..32ba334f3 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-1.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-10.ico b/src/assets/images/taskbar/win32/taskbar-10.ico new file mode 100644 index 000000000..815a02ef7 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-10.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-2.ico b/src/assets/images/taskbar/win32/taskbar-2.ico new file mode 100644 index 000000000..975295f3c Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-2.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-3.ico b/src/assets/images/taskbar/win32/taskbar-3.ico new file mode 100644 index 000000000..a3798e486 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-3.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-4.ico b/src/assets/images/taskbar/win32/taskbar-4.ico new file mode 100644 index 000000000..cd01ce0bf Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-4.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-5.ico b/src/assets/images/taskbar/win32/taskbar-5.ico new file mode 100644 index 000000000..7119a5544 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-5.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-6.ico b/src/assets/images/taskbar/win32/taskbar-6.ico new file mode 100644 index 000000000..17f41f04b Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-6.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-7.ico b/src/assets/images/taskbar/win32/taskbar-7.ico new file mode 100644 index 000000000..c4aa16200 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-7.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-8.ico b/src/assets/images/taskbar/win32/taskbar-8.ico new file mode 100644 index 000000000..896ff7b4b Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-8.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-9.ico b/src/assets/images/taskbar/win32/taskbar-9.ico new file mode 100644 index 000000000..e3613e344 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-9.ico differ diff --git a/src/assets/images/taskbar/win32/taskbar-alert.ico b/src/assets/images/taskbar/win32/taskbar-alert.ico new file mode 100644 index 000000000..5b349c2b6 Binary files /dev/null and b/src/assets/images/taskbar/win32/taskbar-alert.ico differ diff --git a/src/assets/images/tray/darwin/tray-active.png b/src/assets/images/tray/darwin/tray-active.png new file mode 100644 index 000000000..489533dbf Binary files /dev/null and b/src/assets/images/tray/darwin/tray-active.png differ diff --git a/src/assets/images/tray/darwin/tray-active@2x.png b/src/assets/images/tray/darwin/tray-active@2x.png new file mode 100644 index 000000000..76f212b52 Binary files /dev/null and b/src/assets/images/tray/darwin/tray-active@2x.png differ diff --git a/src/assets/images/tray/darwin/tray-unread-active.png b/src/assets/images/tray/darwin/tray-unread-active.png new file mode 100644 index 000000000..e2fd1a822 Binary files /dev/null and b/src/assets/images/tray/darwin/tray-unread-active.png differ diff --git a/src/assets/images/tray/darwin/tray-unread-active@2x.png b/src/assets/images/tray/darwin/tray-unread-active@2x.png new file mode 100644 index 000000000..9a64b3ef8 Binary files /dev/null and b/src/assets/images/tray/darwin/tray-unread-active@2x.png differ diff --git a/src/assets/images/tray/darwin/tray-unread.png b/src/assets/images/tray/darwin/tray-unread.png new file mode 100644 index 000000000..a94ad81fb Binary files /dev/null and b/src/assets/images/tray/darwin/tray-unread.png differ diff --git a/src/assets/images/tray/darwin/tray-unread@2x.png b/src/assets/images/tray/darwin/tray-unread@2x.png new file mode 100644 index 000000000..56e74b16a Binary files /dev/null and b/src/assets/images/tray/darwin/tray-unread@2x.png differ diff --git a/src/assets/images/tray/darwin/tray.png b/src/assets/images/tray/darwin/tray.png new file mode 100644 index 000000000..583f34df8 Binary files /dev/null and b/src/assets/images/tray/darwin/tray.png differ diff --git a/src/assets/images/tray/darwin/tray@2x.png b/src/assets/images/tray/darwin/tray@2x.png new file mode 100644 index 000000000..479a2cf95 Binary files /dev/null and b/src/assets/images/tray/darwin/tray@2x.png differ diff --git a/src/assets/images/tray/linux/tray-unread.png b/src/assets/images/tray/linux/tray-unread.png new file mode 100644 index 000000000..a94ad81fb Binary files /dev/null and b/src/assets/images/tray/linux/tray-unread.png differ diff --git a/src/assets/images/tray/linux/tray-unread@2x.png b/src/assets/images/tray/linux/tray-unread@2x.png new file mode 100644 index 000000000..56e74b16a Binary files /dev/null and b/src/assets/images/tray/linux/tray-unread@2x.png differ diff --git a/src/assets/images/tray/linux/tray.png b/src/assets/images/tray/linux/tray.png new file mode 100644 index 000000000..583f34df8 Binary files /dev/null and b/src/assets/images/tray/linux/tray.png differ diff --git a/src/assets/images/tray/linux/tray@2x.png b/src/assets/images/tray/linux/tray@2x.png new file mode 100644 index 000000000..479a2cf95 Binary files /dev/null and b/src/assets/images/tray/linux/tray@2x.png differ diff --git a/src/assets/images/tray/win32/tray-unread.ico b/src/assets/images/tray/win32/tray-unread.ico new file mode 100644 index 000000000..a59428cfb Binary files /dev/null and b/src/assets/images/tray/win32/tray-unread.ico differ diff --git a/src/assets/images/tray/win32/tray-unread@2x.ico b/src/assets/images/tray/win32/tray-unread@2x.ico new file mode 100644 index 000000000..f6fe65093 Binary files /dev/null and b/src/assets/images/tray/win32/tray-unread@2x.ico differ diff --git a/src/assets/images/tray/win32/tray.ico b/src/assets/images/tray/win32/tray.ico new file mode 100644 index 000000000..d3aa25d68 Binary files /dev/null and b/src/assets/images/tray/win32/tray.ico differ diff --git a/src/assets/images/tray/win32/tray@2x.ico b/src/assets/images/tray/win32/tray@2x.ico new file mode 100644 index 000000000..c1b7a73a5 Binary files /dev/null and b/src/assets/images/tray/win32/tray@2x.ico differ diff --git a/src/components/auth/AuthLayout.js b/src/components/auth/AuthLayout.js new file mode 100644 index 000000000..2741b8a15 --- /dev/null +++ b/src/components/auth/AuthLayout.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { RouteTransition } from 'react-router-transition'; +import { intlShape } from 'react-intl'; + +import Link from '../ui/Link'; +import InfoBar from '../ui/InfoBar'; + +import { oneOrManyChildElements, globalError as globalErrorPropType } from '../../prop-types'; +import globalMessages from '../../i18n/globalMessages'; + +@observer +export default class AuthLayout extends Component { + static propTypes = { + children: oneOrManyChildElements.isRequired, + pathname: PropTypes.string.isRequired, + error: globalErrorPropType.isRequired, + isOnline: PropTypes.bool.isRequired, + isAPIHealthy: PropTypes.bool.isRequired, + retryHealthCheck: PropTypes.func.isRequired, + isHealthCheckLoading: PropTypes.bool.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + children, + pathname, + error, + isOnline, + isAPIHealthy, + retryHealthCheck, + isHealthCheckLoading, + } = this.props; + const { intl } = this.context; + + return ( +
+ {!isOnline && ( + + + {intl.formatMessage(globalMessages.notConnectedToTheInternet)} + + )} + {isOnline && !isAPIHealthy && ( + + + {intl.formatMessage(globalMessages.APIUnhealthy)} + + )} +
+ ({ + transform: `translateX(${styles.translateX}%)`, + opacity: styles.opacity, + })} + component="span" + > + {/* Inject globalError into children */} + {React.cloneElement(children, { + error, + })} + +
+ {/*
*/} + + + + + ); + } +} diff --git a/src/components/auth/Import.js b/src/components/auth/Import.js new file mode 100644 index 000000000..cf83aa9c8 --- /dev/null +++ b/src/components/auth/Import.js @@ -0,0 +1,168 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Link } from 'react-router'; +import classnames from 'classnames'; + +import Form from '../../lib/Form'; +import Toggle from '../ui/Toggle'; +import Button from '../ui/Button'; + +const messages = defineMessages({ + headline: { + id: 'import.headline', + defaultMessage: '!!!Import your Franz 4 services', + }, + notSupportedHeadline: { + id: 'import.notSupportedHeadline', + defaultMessage: '!!!Services not yet supported in Franz 5', + }, + submitButtonLabel: { + id: 'import.submit.label', + defaultMessage: '!!!Import {count} services', + }, + skipButtonLabel: { + id: 'import.skip.label', + defaultMessage: '!!!I want add services manually', + }, +}); + +@observer +export default class Import extends Component { + static propTypes = { + services: MobxPropTypes.arrayOrObservableArray.isRequired, + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + inviteRoute: PropTypes.string.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + prepareForm() { + const { services } = this.props; + + const config = { + fields: { + import: [...services.filter(s => s.recipe).map(s => ({ + add: { + default: true, + options: s, + }, + }))], + }, + }; + + return new Form(config, this.context.intl); + } + + submit(e) { + const { services } = this.props; + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + const servicesImport = form.values().import + .map((value, i) => !value.add || services.filter(s => s.recipe)[i]) + .filter(s => typeof s !== 'boolean'); + + this.props.onSubmit({ services: servicesImport }); + }, + onError: () => {}, + }); + } + + render() { + this.form = this.prepareForm(); + const { intl } = this.context; + const { services, isSubmitting, inviteRoute } = this.props; + + const availableServices = services.filter(s => s.recipe); + const unavailableServices = services.filter(s => !s.recipe); + + return ( +
+
+
this.submit(e)}> + +

+ {intl.formatMessage(messages.headline)} +

+ + + {this.form.$('import').map((service, i) => ( + service.$('add').set(!service.$('add').value)} + > + + + + + ))} + +
+ + + + + {availableServices[i].name !== '' + ? availableServices[i].name + : availableServices[i].recipe.name} +
+ {unavailableServices.length > 0 && ( +
+ {intl.formatMessage(messages.notSupportedHeadline)} +

+ {services.filter(s => !s.recipe).map((service, i) => ( + + {service.name !== '' ? service.name : service.service} + {unavailableServices.length > i + 1 ? ', ' : ''} + + ))} +

+
+ )} + + {isSubmitting ? ( +
+
+ ); + } +} diff --git a/src/components/auth/Invite.js b/src/components/auth/Invite.js new file mode 100644 index 000000000..c1d815dcd --- /dev/null +++ b/src/components/auth/Invite.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Link } from 'react-router'; + +import Form from '../../lib/Form'; +import { email } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Button from '../ui/Button'; + +const messages = defineMessages({ + headline: { + id: 'invite.headline.friends', + defaultMessage: '!!!Invite 3 of your friends or colleagues', + }, + nameLabel: { + id: 'invite.name.label', + defaultMessage: '!!!Name', + }, + emailLabel: { + id: 'invite.email.label', + defaultMessage: '!!!Email address', + }, + submitButtonLabel: { + id: 'invite.submit.label', + defaultMessage: '!!!Send invites', + }, + skipButtonLabel: { + id: 'invite.skip.label', + defaultMessage: '!!!I want to do this later', + }, +}); + +@observer +export default class Invite extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + invite: [...Array(3).fill({ + name: { + label: this.context.intl.formatMessage(messages.nameLabel), + // value: '', + placeholder: this.context.intl.formatMessage(messages.nameLabel), + }, + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + // value: '', + validate: [email], + placeholder: this.context.intl.formatMessage(messages.emailLabel), + }, + })], + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit({ invites: form.values().invite }); + }, + onError: () => {}, + }); + } + + render() { + const { form } = this; + const { intl } = this.context; + + return ( +
+
this.submit(e)}> + +

+ {intl.formatMessage(messages.headline)} +

+ {form.$('invite').map(invite => ( +
+
+ + +
+
+ ))} +
+ ); + } +} diff --git a/src/components/auth/Login.js b/src/components/auth/Login.js new file mode 100644 index 000000000..015079f02 --- /dev/null +++ b/src/components/auth/Login.js @@ -0,0 +1,161 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import Form from '../../lib/Form'; +import { required, email } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Button from '../ui/Button'; +import Link from '../ui/Link'; + +import { globalError as globalErrorPropType } from '../../prop-types'; + +// import Appear from '../ui/effects/Appear'; + +const messages = defineMessages({ + headline: { + id: 'login.headline', + defaultMessage: '!!!Sign in', + }, + emailLabel: { + id: 'login.email.label', + defaultMessage: '!!!Email address', + }, + passwordLabel: { + id: 'login.password.label', + defaultMessage: '!!!Password', + }, + submitButtonLabel: { + id: 'login.submit.label', + defaultMessage: '!!!Sign in', + }, + invalidCredentials: { + id: 'login.invalidCredentials', + defaultMessage: '!!!Email or password not valid', + }, + tokenExpired: { + id: 'login.tokenExpired', + defaultMessage: '!!!Your session expired, please login again.', + }, + serverLogout: { + id: 'login.serverLogout', + defaultMessage: '!!!Your session expired, please login again.', + }, + signupLink: { + id: 'login.link.signup', + defaultMessage: '!!!Create a free account', + }, + passwordLink: { + id: 'login.link.password', + defaultMessage: '!!!Forgot password', + }, +}); + +@observer +export default class Login extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + isTokenExpired: PropTypes.bool.isRequired, + isServerLogout: PropTypes.bool.isRequired, + signupRoute: PropTypes.string.isRequired, + passwordRoute: PropTypes.string.isRequired, + error: globalErrorPropType.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + value: '', + validate: [required, email], + }, + password: { + label: this.context.intl.formatMessage(messages.passwordLabel), + value: '', + validate: [required], + type: 'password', + }, + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit(form.values()); + }, + onError: () => {}, + }); + } + + emailField = null; + + render() { + const { form } = this; + const { intl } = this.context; + const { + isSubmitting, + isTokenExpired, + isServerLogout, + signupRoute, + passwordRoute, + error, + } = this.props; + + return ( +
+
this.submit(e)}> + +

{intl.formatMessage(messages.headline)}

+ {isTokenExpired && ( +

{intl.formatMessage(messages.tokenExpired)}

+ )} + {isServerLogout && ( +

{intl.formatMessage(messages.serverLogout)}

+ )} + { this.emailField = element; }} + focus + /> + + {error.code === 'invalid-credentials' && ( +

{intl.formatMessage(messages.invalidCredentials)}

+ )} + {isSubmitting ? ( +
+ ); + } +} diff --git a/src/components/auth/Password.js b/src/components/auth/Password.js new file mode 100644 index 000000000..d2b196853 --- /dev/null +++ b/src/components/auth/Password.js @@ -0,0 +1,135 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import Form from '../../lib/Form'; +import { required, email } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Button from '../ui/Button'; +import Link from '../ui/Link'; +import Infobox from '../ui/Infobox'; + +const messages = defineMessages({ + headline: { + id: 'password.headline', + defaultMessage: '!!!Forgot password', + }, + emailLabel: { + id: 'password.email.label', + defaultMessage: '!!!Email address', + }, + submitButtonLabel: { + id: 'password.submit.label', + defaultMessage: '!!!Submit', + }, + successInfo: { + id: 'password.successInfo', + defaultMessage: '!!!Your new password was sent to your email address', + }, + noUser: { + id: 'password.noUser', + defaultMessage: '!!!No user affiliated with that email address', + }, + signupLink: { + id: 'password.link.signup', + defaultMessage: '!!!Create a free account', + }, + loginLink: { + id: 'password.link.login', + defaultMessage: '!!!Sign in to your account', + }, +}); + +@observer +export default class Password extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + signupRoute: PropTypes.string.isRequired, + loginRoute: PropTypes.string.isRequired, + status: MobxPropTypes.arrayOrObservableArray.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + value: '', + validate: [required, email], + }, + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit(form.values()); + }, + onError: () => {}, + }); + } + + render() { + const { form } = this; + const { intl } = this.context; + const { + isSubmitting, + signupRoute, + loginRoute, + status, + } = this.props; + + return ( +
+
this.submit(e)}> + +

{intl.formatMessage(messages.headline)}

+ {status.length > 0 && status.includes('sent') && ( + + {intl.formatMessage(messages.successInfo)} + + )} + + {status.length > 0 && status.includes('no-user') && ( +

{intl.formatMessage(messages.noUser)}

+ )} + {isSubmitting ? ( +
+ ); + } +} diff --git a/src/components/auth/Pricing.js b/src/components/auth/Pricing.js new file mode 100644 index 000000000..761561a89 --- /dev/null +++ b/src/components/auth/Pricing.js @@ -0,0 +1,130 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +// import { Link } from 'react-router'; + +// import Button from '../ui/Button'; +import Loader from '../ui/Loader'; +import Appear from '../ui/effects/Appear'; +import SubscriptionForm from '../../containers/ui/SubscriptionFormScreen'; + +const messages = defineMessages({ + headline: { + id: 'pricing.headline', + defaultMessage: '!!!Support Franz', + }, + monthlySupportLabel: { + id: 'pricing.support.label', + defaultMessage: '!!!Select your support plan', + }, + submitButtonLabel: { + id: 'pricing.submit.label', + defaultMessage: '!!!Support the development of Franz', + }, + skipPayment: { + id: 'pricing.link.skipPayment', + defaultMessage: '!!!I don\'t want to support the development of Franz.', + }, +}); + +@observer +export default class Signup extends Component { + static propTypes = { + donor: MobxPropTypes.objectOrObservableObject.isRequired, + isLoading: PropTypes.bool.isRequired, + isLoadingUser: PropTypes.bool.isRequired, + onCloseSubscriptionWindow: PropTypes.func.isRequired, + skipAction: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + donor, + isLoading, + isLoadingUser, + onCloseSubscriptionWindow, + skipAction, + } = this.props; + const { intl } = this.context; + + return ( +
+
+
+ +

{intl.formatMessage(messages.headline)}

+
+ {isLoadingUser && ( +

Loading

+ )} + {!isLoadingUser && ( + donor.amount ? ( + +

+ Thank you so much for your previous donation of $ {donor.amount}. +
+ Your support allowed us to get where we are today. +
+

+

+ As an early supporter, you get a lifetime premium supporter license without any + additional charges. +

+

+ However, If you want to keep supporting us, you are more than welcome to subscribe to a plan. +

+

+
+ ) : ( + +

+ We built Franz with a lot of effort, manpower and love, + to bring you the best messaging experience. +
+

+

+ Getting a Franz Premium Supporter License will allow us to keep improving Franz for you. +

+
+ ) + )} +

+ Thanks for being a hero. +

+

+ Stefan Malzner +

+
+ + + {intl.formatMessage(messages.monthlySupportLabel)} + + {/* + {intl.formatMessage(messages.skipPayment)} + */} + + +
+
+
+ ); + } +} diff --git a/src/components/auth/Signup.js b/src/components/auth/Signup.js new file mode 100644 index 000000000..71ca16111 --- /dev/null +++ b/src/components/auth/Signup.js @@ -0,0 +1,206 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import Form from '../../lib/Form'; +import { required, email, minLength } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Radio from '../ui/Radio'; +import Button from '../ui/Button'; +import Link from '../ui/Link'; + +import { globalError as globalErrorPropType } from '../../prop-types'; + +const messages = defineMessages({ + headline: { + id: 'signup.headline', + defaultMessage: '!!!Sign up', + }, + firstnameLabel: { + id: 'signup.firstname.label', + defaultMessage: '!!!Firstname', + }, + lastnameLabel: { + id: 'signup.lastname.label', + defaultMessage: '!!!Lastname', + }, + emailLabel: { + id: 'signup.email.label', + defaultMessage: '!!!Email address', + }, + companyLabel: { + id: 'signup.company.label', + defaultMessage: '!!!Company', + }, + passwordLabel: { + id: 'signup.password.label', + defaultMessage: '!!!Password', + }, + legalInfo: { + id: 'signup.legal.info', + defaultMessage: '!!!By creating a Franz account you accept the', + }, + terms: { + id: 'signup.legal.terms', + defaultMessage: '!!!Terms of service', + }, + privacy: { + id: 'signup.legal.privacy', + defaultMessage: '!!!Privacy Statement', + }, + submitButtonLabel: { + id: 'signup.submit.label', + defaultMessage: '!!!Create account', + }, + loginLink: { + id: 'signup.link.login', + defaultMessage: '!!!Already have an account, sign in?', + }, + emailDuplicate: { + id: 'signup.emailDuplicate', + defaultMessage: '!!!A user with that email address already exists', + }, +}); + +@observer +export default class Signup extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + loginRoute: PropTypes.string.isRequired, + error: globalErrorPropType.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + accountType: { + value: 'individual', + validate: [required], + options: [{ + value: 'individual', + label: 'Individual', + }, { + value: 'non-profit', + label: 'Non-Profit', + }, { + value: 'company', + label: 'Company', + }], + }, + firstname: { + label: this.context.intl.formatMessage(messages.firstnameLabel), + value: '', + validate: [required], + }, + lastname: { + label: this.context.intl.formatMessage(messages.lastnameLabel), + value: '', + validate: [required], + }, + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + value: '', + validate: [required, email], + }, + organization: { + label: this.context.intl.formatMessage(messages.companyLabel), + value: '', // TODO: make required when accountType: company + }, + password: { + label: this.context.intl.formatMessage(messages.passwordLabel), + value: '', + validate: [required, minLength(6)], + type: 'password', + }, + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit(form.values()); + }, + onError: () => {}, + }); + } + + render() { + const { form } = this; + const { intl } = this.context; + const { isSubmitting, loginRoute, error } = this.props; + + return ( +
+
+
this.submit(e)}> + +

{intl.formatMessage(messages.headline)}

+ +
+ + +
+ + + {form.$('accountType').value === 'company' && ( + + )} + {error.code === 'email-duplicate' && ( +

{intl.formatMessage(messages.emailDuplicate)}

+ )} + {isSubmitting ? ( +
+
+ ); + } +} diff --git a/src/components/auth/Welcome.js b/src/components/auth/Welcome.js new file mode 100644 index 000000000..06b10ecfe --- /dev/null +++ b/src/components/auth/Welcome.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import Link from '../ui/Link'; + +const messages = defineMessages({ + signupButton: { + id: 'welcome.signupButton', + defaultMessage: '!!!Create a free account', + }, + loginButton: { + id: 'welcome.loginButton', + defaultMessage: '!!!Login to your account', + }, +}); + +@observer +export default class Login extends Component { + static propTypes = { + loginRoute: PropTypes.string.isRequired, + signupRoute: PropTypes.string.isRequired, + recipes: MobxPropTypes.arrayOrObservableArray.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { intl } = this.context; + const { + loginRoute, + signupRoute, + recipes, + } = this.props; + + return ( +
+
+ + {/* */} +
+

Franz

+
+
+
+ + {intl.formatMessage(messages.signupButton)} + + + {intl.formatMessage(messages.loginButton)} + +
+
+ {recipes.map(recipe => ( + + ))} +
+
+ ); + } +} diff --git a/src/components/layout/AppLayout.js b/src/components/layout/AppLayout.js new file mode 100644 index 000000000..f60c170a8 --- /dev/null +++ b/src/components/layout/AppLayout.js @@ -0,0 +1,148 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import InfoBar from '../ui/InfoBar'; +import globalMessages from '../../i18n/globalMessages'; + +function createMarkup(HTMLString) { + return { __html: HTMLString }; +} + +const messages = defineMessages({ + servicesUpdated: { + id: 'infobar.servicesUpdated', + defaultMessage: '!!!Your services have been updated.', + }, + updateAvailable: { + id: 'infobar.updateAvailable', + defaultMessage: '!!!A new update for Franz is available.', + }, + buttonReloadServices: { + id: 'infobar.buttonReloadServices', + defaultMessage: '!!!Reload services', + }, + buttonInstallUpdate: { + id: 'infobar.buttonInstallUpdate', + defaultMessage: '!!!Restart & install update', + }, + requiredRequestsFailed: { + id: 'infobar.requiredRequestsFailed', + defaultMessage: '!!!Could not load services and user information', + }, +}); + +@observer +export default class AppLayout extends Component { + static propTypes = { + sidebar: PropTypes.element.isRequired, + services: PropTypes.element.isRequired, + children: PropTypes.element, + news: MobxPropTypes.arrayOrObservableArray.isRequired, + isOnline: PropTypes.bool.isRequired, + showServicesUpdatedInfoBar: PropTypes.bool.isRequired, + appUpdateIsDownloaded: PropTypes.bool.isRequired, + removeNewsItem: PropTypes.func.isRequired, + reloadServicesAfterUpdate: PropTypes.func.isRequired, + installAppUpdate: PropTypes.func.isRequired, + showRequiredRequestsError: PropTypes.bool.isRequired, + areRequiredRequestsSuccessful: PropTypes.bool.isRequired, + retryRequiredRequests: PropTypes.func.isRequired, + areRequiredRequestsLoading: PropTypes.bool.isRequired, + }; + + static defaultProps = { + children: [], + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + sidebar, + services, + children, + isOnline, + news, + showServicesUpdatedInfoBar, + appUpdateIsDownloaded, + removeNewsItem, + reloadServicesAfterUpdate, + installAppUpdate, + showRequiredRequestsError, + areRequiredRequestsSuccessful, + retryRequiredRequests, + areRequiredRequestsLoading, + } = this.props; + + const { intl } = this.context; + + return ( +
+
+ {sidebar} +
+ {news.length > 0 && news.map(item => ( + removeNewsItem({ newsId: item.id })} + > + + + ))} + {!isOnline && ( + + + {intl.formatMessage(globalMessages.notConnectedToTheInternet)} + + )} + {!areRequiredRequestsSuccessful && showRequiredRequestsError && ( + + + {intl.formatMessage(messages.requiredRequestsFailed)} + + )} + {showServicesUpdatedInfoBar && ( + + + {intl.formatMessage(messages.servicesUpdated)} + + )} + {appUpdateIsDownloaded && ( + + + {intl.formatMessage(messages.updateAvailable)} + + )} + {services} +
+
+ {children} +
+ ); + } +} diff --git a/src/components/layout/Sidebar.js b/src/components/layout/Sidebar.js new file mode 100644 index 000000000..4aee1ec60 --- /dev/null +++ b/src/components/layout/Sidebar.js @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ReactTooltip from 'react-tooltip'; +import { defineMessages, intlShape } from 'react-intl'; + +import Tabbar from '../services/tabs/Tabbar'; +import { ctrlKey } from '../../environment'; + +const messages = defineMessages({ + settings: { + id: 'sidebar.settings', + defaultMessage: '!!!Settings', + }, +}); + +export default class Sidebar extends Component { + static propTypes = { + openSettings: PropTypes.func.isRequired, + isPremiumUser: PropTypes.bool, + } + + static defaultProps = { + isPremiumUser: false, + } + + static contextTypes = { + intl: intlShape, + }; + + state = { + tooltipEnabled: true, + }; + + enableToolTip() { + this.setState({ tooltipEnabled: true }); + } + + disableToolTip() { + this.setState({ tooltipEnabled: false }); + } + + render() { + const { openSettings, isPremiumUser } = this.props; + const { intl } = this.context; + return ( +
+ this.enableToolTip()} + disableToolTip={() => this.disableToolTip()} + /> + + {this.state.tooltipEnabled && ( + + )} +
+ ); + } +} diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js new file mode 100644 index 000000000..043ff42ea --- /dev/null +++ b/src/components/services/content/ServiceWebview.js @@ -0,0 +1,73 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { autorun } from 'mobx'; +import { observer } from 'mobx-react'; +import Webview from 'react-electron-web-view'; +import classnames from 'classnames'; + +import ServiceModel from '../../../models/Service'; + +@observer +export default class ServiceWebview extends Component { + static propTypes = { + service: PropTypes.instanceOf(ServiceModel).isRequired, + setWebviewReference: PropTypes.func.isRequired, + }; + + static defaultProps = { + isActive: false, + }; + + state = { + forceRepaint: false, + }; + + componentDidMount() { + autorun(() => { + if (this.props.service.isActive) { + this.setState({ forceRepaint: true }); + setTimeout(() => { + this.setState({ forceRepaint: false }); + }, 100); + } + }); + } + + webview = null; + + render() { + const { + service, + setWebviewReference, + } = this.props; + + const webviewClasses = classnames({ + services__webview: true, + 'is-active': service.isActive, + 'services__webview--force-repaint': this.state.forceRepaint, + }); + + return ( +
+ { this.webview = element; }} + + autosize + src={service.url} + preload="./webview/plugin.js" + partition={`persist:service-${service.id}`} + + onDidAttach={() => setWebviewReference({ + serviceId: service.id, + webview: this.webview.view, + })} + + useragent={service.userAgent} + + disablewebsecurity + allowpopups + /> +
+ ); + } +} diff --git a/src/components/services/content/Services.js b/src/components/services/content/Services.js new file mode 100644 index 000000000..03c68b06f --- /dev/null +++ b/src/components/services/content/Services.js @@ -0,0 +1,81 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { Link } from 'react-router'; +import { defineMessages, intlShape } from 'react-intl'; + +import Webview from './ServiceWebview'; +import Appear from '../../ui/effects/Appear'; + +const messages = defineMessages({ + welcome: { + id: 'services.welcome', + defaultMessage: '!!!Welcome to Franz', + }, + getStarted: { + id: 'services.getStarted', + defaultMessage: '!!!Get started', + }, +}); + +@observer +export default class Services extends Component { + static propTypes = { + services: MobxPropTypes.arrayOrObservableArray.isRequired, + setWebviewReference: PropTypes.func.isRequired, + handleIPCMessage: PropTypes.func.isRequired, + openWindow: PropTypes.func.isRequired, + }; + + static defaultProps = { + services: [], + activeService: '', + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + services, + handleIPCMessage, + setWebviewReference, + openWindow, + } = this.props; + const { intl } = this.context; + + return ( +
+ {services.length === 0 && ( + +
+ +

{intl.formatMessage(messages.welcome)}

+ + + {intl.formatMessage(messages.getStarted)} + + +
+
+ )} + {services.map(service => ( + + ))} +
+ ); + } +} diff --git a/src/components/services/tabs/TabBarSortableList.js b/src/components/services/tabs/TabBarSortableList.js new file mode 100644 index 000000000..c0a68d1a5 --- /dev/null +++ b/src/components/services/tabs/TabBarSortableList.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import { SortableContainer } from 'react-sortable-hoc'; + +import TabItem from './TabItem'; +import { ctrlKey } from '../../../environment'; + +export default SortableContainer(observer(({ + services, + setActive, + reload, + toggleNotifications, + deleteService, + disableService, + openSettings, +}) => ( + +))); diff --git a/src/components/services/tabs/TabItem.js b/src/components/services/tabs/TabItem.js new file mode 100644 index 000000000..9e03d2e21 --- /dev/null +++ b/src/components/services/tabs/TabItem.js @@ -0,0 +1,136 @@ +import { remote } from 'electron'; +import React, { Component } from 'react'; +import { defineMessages, intlShape } from 'react-intl'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; +import { SortableElement } from 'react-sortable-hoc'; + +import ServiceModel from '../../../models/Service'; +import { ctrlKey } from '../../../environment'; + +const { Menu } = remote; + +const messages = defineMessages({ + reload: { + id: 'tabs.item.reload', + defaultMessage: '!!!Reload', + }, + edit: { + id: 'tabs.item.edit', + defaultMessage: '!!!Edit', + }, + disableNotifications: { + id: 'tabs.item.disableNotifications', + defaultMessage: '!!!Disable notifications', + }, + enableNotifications: { + id: 'tabs.item.enableNotification', + defaultMessage: '!!!Enable notifications', + }, + disableService: { + id: 'tabs.item.disableService', + defaultMessage: '!!!Disable Service', + }, + deleteService: { + id: 'tabs.item.deleteService', + defaultMessage: '!!!Delete Service', + }, +}); + +@observer +class TabItem extends Component { + static propTypes = { + service: PropTypes.instanceOf(ServiceModel).isRequired, + clickHandler: PropTypes.func.isRequired, + shortcutIndex: PropTypes.number.isRequired, + reload: PropTypes.func.isRequired, + toggleNotifications: PropTypes.func.isRequired, + openSettings: PropTypes.func.isRequired, + deleteService: PropTypes.func.isRequired, + disableService: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + service, + clickHandler, + shortcutIndex, + reload, + toggleNotifications, + deleteService, + disableService, + openSettings, + } = this.props; + const { intl } = this.context; + + + const menuTemplate = [{ + label: service.name || service.recipe.name, + enabled: false, + }, { + type: 'separator', + }, { + label: intl.formatMessage(messages.reload), + click: reload, + }, { + label: intl.formatMessage(messages.edit), + click: () => openSettings({ + path: `services/edit/${service.id}`, + }), + }, { + type: 'separator', + }, { + label: service.isNotificationEnabled + ? intl.formatMessage(messages.disableNotifications) + : intl.formatMessage(messages.enableNotifications), + click: () => toggleNotifications(), + }, { + label: intl.formatMessage(messages.disableService), + click: () => disableService(), + }, { + type: 'separator', + }, { + label: intl.formatMessage(messages.deleteService), + click: () => deleteService(), + }]; + const menu = Menu.buildFromTemplate(menuTemplate); + + return ( +
  • menu.popup(remote.getCurrentWindow())} + data-tip={`${service.name} ${shortcutIndex <= 9 ? `(${ctrlKey}+${shortcutIndex})` : ''}`} + > + + {service.unreadDirectMessageCount > 0 && ( + + {service.unreadDirectMessageCount} + + )} + {service.unreadIndirectMessageCount > 0 + && service.unreadDirectMessageCount === 0 + && service.isIndirectMessageBadgeEnabled && ( + + • + + )} +
  • + ); + } +} + +export default SortableElement(TabItem); diff --git a/src/components/services/tabs/Tabbar.js b/src/components/services/tabs/Tabbar.js new file mode 100644 index 000000000..fdb2c0a59 --- /dev/null +++ b/src/components/services/tabs/Tabbar.js @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; + +import TabBarSortableList from './TabBarSortableList'; + +@observer +export default class TabBar extends Component { + static propTypes = { + services: MobxPropTypes.arrayOrObservableArray.isRequired, + setActive: PropTypes.func.isRequired, + openSettings: PropTypes.func.isRequired, + enableToolTip: PropTypes.func.isRequired, + disableToolTip: PropTypes.func.isRequired, + reorder: PropTypes.func.isRequired, + reload: PropTypes.func.isRequired, + toggleNotifications: PropTypes.func.isRequired, + deleteService: PropTypes.func.isRequired, + updateService: PropTypes.func.isRequired, + } + + onSortEnd = ({ oldIndex, newIndex }) => { + const { + enableToolTip, + reorder, + } = this.props; + + enableToolTip(); + reorder({ oldIndex, newIndex }); + }; + + disableService = ({ serviceId }) => { + const { updateService } = this.props; + + if (serviceId) { + updateService({ + serviceId, + serviceData: { + isEnabled: false, + }, + redirect: false, + }); + } + } + + render() { + const { + services, + setActive, + openSettings, + disableToolTip, + reload, + toggleNotifications, + deleteService, + } = this.props; + + return ( +
    + +
    + ); + } +} diff --git a/src/components/settings/SettingsLayout.js b/src/components/settings/SettingsLayout.js new file mode 100644 index 000000000..d5392ddba --- /dev/null +++ b/src/components/settings/SettingsLayout.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; + +import { oneOrManyChildElements } from '../../prop-types'; +import Appear from '../ui/effects/Appear'; + +@observer +export default class SettingsLayout extends Component { + static propTypes = { + navigation: PropTypes.element.isRequired, + children: oneOrManyChildElements.isRequired, + closeSettings: PropTypes.func.isRequired, + }; + + componentWillMount() { + document.addEventListener('keydown', this.handleKeyDown.bind(this), false); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyDown.bind(this), false); + } + + handleKeyDown(e) { + if (e.keyCode === 27) { // escape key + this.props.closeSettings(); + } + } + + render() { + const { + navigation, + children, + closeSettings, + } = this.props; + + return ( + +
    +
    + +
    + ); + } +} diff --git a/src/components/settings/account/AccountDashboard.js b/src/components/settings/account/AccountDashboard.js new file mode 100644 index 000000000..75dbdef49 --- /dev/null +++ b/src/components/settings/account/AccountDashboard.js @@ -0,0 +1,286 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape, FormattedMessage } from 'react-intl'; +import ReactTooltip from 'react-tooltip'; +import moment from 'moment'; + +import Loader from '../../ui/Loader'; +import Button from '../../ui/Button'; +import Infobox from '../../ui/Infobox'; +import Link from '../../ui/Link'; +import SubscriptionForm from '../../../containers/ui/SubscriptionFormScreen'; + +const messages = defineMessages({ + headline: { + id: 'settings.account.headline', + defaultMessage: '!!!Account', + }, + headlineSubscription: { + id: 'settings.account.headlineSubscription', + defaultMessage: '!!!Your Subscription', + }, + headlineUpgrade: { + id: 'settings.account.headlineUpgrade', + defaultMessage: '!!!Upgrade your Account', + }, + headlineInvoices: { + id: 'settings.account.headlineInvoices', + defaultMessage: '!!Invoices', + }, + manageSubscriptionButtonLabel: { + id: 'settings.account.manageSubscription.label', + defaultMessage: '!!!Manage your subscription', + }, + accountTypeBasic: { + id: 'settings.account.accountType.basic', + defaultMessage: '!!!Basic Account', + }, + accountTypePremium: { + id: 'settings.account.accountType.premium', + defaultMessage: '!!!Premium Supporter Account', + }, + accountEditButton: { + id: 'settings.account.account.editButton', + defaultMessage: '!!!Edit Account', + }, + invoiceDownload: { + id: 'settings.account.invoiceDownload', + defaultMessage: '!!!Download', + }, + userInfoRequestFailed: { + id: 'settings.account.userInfoRequestFailed', + defaultMessage: '!!!Could not load user information', + }, + tryReloadUserInfoRequest: { + id: 'settings.account.tryReloadUserInfoRequest', + defaultMessage: '!!!Try again', + }, + miningActive: { + id: 'settings.account.mining.active', + defaultMessage: '!!!You are right now performing {hashes} calculations per second.', + }, + miningThankYou: { + id: 'settings.account.mining.thankyou', + defaultMessage: '!!!Thank you for supporting Franz with your processing power.', + }, + miningMoreInfo: { + id: 'settings.account.mining.moreInformation', + defaultMessage: '!!!Get more information', + }, + cancelMining: { + id: 'settings.account.mining.cancel', + defaultMessage: '!!!Cancel mining', + }, +}); + +@observer +export default class AccountDashboard extends Component { + static propTypes = { + user: MobxPropTypes.observableObject.isRequired, + orders: MobxPropTypes.arrayOrObservableArray.isRequired, + hashrate: PropTypes.number.isRequired, + isLoading: PropTypes.bool.isRequired, + isLoadingOrdersInfo: PropTypes.bool.isRequired, + isLoadingPlans: PropTypes.bool.isRequired, + isCreatingPaymentDashboardUrl: PropTypes.bool.isRequired, + userInfoRequestFailed: PropTypes.bool.isRequired, + retryUserInfoRequest: PropTypes.func.isRequired, + openDashboard: PropTypes.func.isRequired, + openExternalUrl: PropTypes.func.isRequired, + onCloseSubscriptionWindow: PropTypes.func.isRequired, + stopMiner: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + user, + orders, + hashrate, + isLoading, + isCreatingPaymentDashboardUrl, + openDashboard, + openExternalUrl, + isLoadingOrdersInfo, + isLoadingPlans, + userInfoRequestFailed, + retryUserInfoRequest, + onCloseSubscriptionWindow, + stopMiner, + } = this.props; + const { intl } = this.context; + + return ( +
    +
    + + {intl.formatMessage(messages.headline)} + +
    +
    + {isLoading && ( + + )} + + {!isLoading && userInfoRequestFailed && ( +
    + + {intl.formatMessage(messages.userInfoRequestFailed)} + +
    + )} + + {!userInfoRequestFailed && ( +
    + {!isLoading && ( +
    +
    +
    + + {user.isPremium && ( + + + + )} +
    +
    +

    + {`${user.firstname} ${user.lastname}`} +

    + {user.organization && `${user.organization}, `} + {user.email}
    + {!user.isPremium && ( + {intl.formatMessage(messages.accountTypeBasic)} + )} + {user.isPremium && ( + {intl.formatMessage(messages.accountTypePremium)} + )} +
    + + {intl.formatMessage(messages.accountEditButton)} + + + {user.emailValidated} +
    +
    + )} + + {user.isSubscriptionOwner && ( + isLoadingOrdersInfo ? ( + + ) : ( +
    + {orders.length > 0 && ( +
    +
    +

    {intl.formatMessage(messages.headlineSubscription)}

    +
    + {orders[0].name} + {orders[0].price} +
    +
    +
    +

    {intl.formatMessage(messages.headlineInvoices)}

    + + + {orders.map(order => ( + + + + + ))} + +
    + {moment(order.date).format('DD.MM.YYYY')} + + +
    +
    +
    + )} +
    + ) + )} + + {user.isMiner && ( +
    +
    +

    {intl.formatMessage(messages.headlineSubscription)}

    +
    +
    +

    {intl.formatMessage(messages.miningThankYou)}

    + {hashrate.toFixed(2)}, + }} + tagName="p" + /> +

    + + {intl.formatMessage(messages.miningMoreInfo)} + +

    +
    +
    +
    +
    + )} + + {!user.isPremium && !user.isMiner && ( + isLoadingPlans ? ( + + ) : ( +
    +
    +

    {intl.formatMessage(messages.headlineUpgrade)}

    + +
    +
    + ) + )} +
    + )} +
    + +
    + ); + } +} diff --git a/src/components/settings/navigation/SettingsNavigation.js b/src/components/settings/navigation/SettingsNavigation.js new file mode 100644 index 000000000..3b21a7765 --- /dev/null +++ b/src/components/settings/navigation/SettingsNavigation.js @@ -0,0 +1,84 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, intlShape } from 'react-intl'; + +import Link from '../../ui/Link'; + +const messages = defineMessages({ + availableServices: { + id: 'settings.navigation.availableServices', + defaultMessage: '!!!Available services', + }, + yourServices: { + id: 'settings.navigation.yourServices', + defaultMessage: '!!!Your services', + }, + account: { + id: 'settings.navigation.account', + defaultMessage: '!!!Account', + }, + settings: { + id: 'settings.navigation.settings', + defaultMessage: '!!!Settings', + }, + logout: { + id: 'settings.navigation.logout', + defaultMessage: '!!!Logout', + }, +}); + +export default class SettingsNavigation extends Component { + static propTypes = { + serviceCount: PropTypes.number.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { serviceCount } = this.props; + const { intl } = this.context; + + return ( +
    + + {intl.formatMessage(messages.availableServices)} + + + {intl.formatMessage(messages.yourServices)} {serviceCount} + + + {intl.formatMessage(messages.account)} + + + {intl.formatMessage(messages.settings)} + + + + {intl.formatMessage(messages.logout)} + +
    + ); + } +} diff --git a/src/components/settings/recipes/RecipeItem.js b/src/components/settings/recipes/RecipeItem.js new file mode 100644 index 000000000..7b2f64d26 --- /dev/null +++ b/src/components/settings/recipes/RecipeItem.js @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; + +import RecipePreviewModel from '../../../models/RecipePreview'; + +@observer +export default class RecipeItem extends Component { + static propTypes = { + recipe: PropTypes.instanceOf(RecipePreviewModel).isRequired, + onClick: PropTypes.func.isRequired, + }; + + render() { + const { recipe, onClick } = this.props; + + return ( + + ); + } +} diff --git a/src/components/settings/recipes/RecipesDashboard.js b/src/components/settings/recipes/RecipesDashboard.js new file mode 100644 index 000000000..02ea04e35 --- /dev/null +++ b/src/components/settings/recipes/RecipesDashboard.js @@ -0,0 +1,151 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Link } from 'react-router'; + +import SearchInput from '../../ui/SearchInput'; +import Infobox from '../../ui/Infobox'; +import RecipeItem from './RecipeItem'; +import Loader from '../../ui/Loader'; +import Appear from '../../ui/effects/Appear'; + +const messages = defineMessages({ + headline: { + id: 'settings.recipes.headline', + defaultMessage: '!!!Available Services', + }, + mostPopularRecipes: { + id: 'settings.recipes.mostPopular', + defaultMessage: '!!!Most popular', + }, + allRecipes: { + id: 'settings.recipes.all', + defaultMessage: '!!!All services', + }, + devRecipes: { + id: 'settings.recipes.dev', + defaultMessage: '!!!Development', + }, + nothingFound: { + id: 'settings.recipes.nothingFound', + defaultMessage: '!!!Sorry, but no service matched your search term.', + }, + servicesSuccessfulAddedInfo: { + id: 'settings.recipes.servicesSuccessfulAddedInfo', + defaultMessage: '!!!Service successfully added', + }, +}); + +@observer +export default class RecipesDashboard extends Component { + static propTypes = { + recipes: MobxPropTypes.arrayOrObservableArray.isRequired, + isLoading: PropTypes.bool.isRequired, + hasLoadedRecipes: PropTypes.bool.isRequired, + showAddServiceInterface: PropTypes.func.isRequired, + searchRecipes: PropTypes.func.isRequired, + resetSearch: PropTypes.func.isRequired, + serviceStatus: MobxPropTypes.arrayOrObservableArray.isRequired, + devRecipesCount: PropTypes.number.isRequired, + searchNeedle: PropTypes.string, + }; + + static defaultProps = { + searchNeedle: '', + } + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + recipes, + isLoading, + hasLoadedRecipes, + showAddServiceInterface, + searchRecipes, + resetSearch, + serviceStatus, + devRecipesCount, + searchNeedle, + } = this.props; + const { intl } = this.context; + + return ( +
    +
    + searchRecipes(e)} + onReset={() => resetSearch()} + throttle + /> +
    +
    + {serviceStatus.length > 0 && serviceStatus.includes('created') && ( + + + {intl.formatMessage(messages.servicesSuccessfulAddedInfo)} + + + )} + {!searchNeedle && ( +
    + + {intl.formatMessage(messages.mostPopularRecipes)} + + + {intl.formatMessage(messages.allRecipes)} + + {devRecipesCount > 0 && ( + + {intl.formatMessage(messages.devRecipes)} ({devRecipesCount}) + + )} +
    + )} + {isLoading ? ( + + ) : ( +
    + {hasLoadedRecipes && recipes.length === 0 && ( +

    + + + + {intl.formatMessage(messages.nothingFound)} +

    + )} + {recipes.map(recipe => ( + showAddServiceInterface({ recipeId: recipe.id })} + /> + ))} +
    + )} +
    +
    + ); + } +} diff --git a/src/components/settings/services/EditServiceForm.js b/src/components/settings/services/EditServiceForm.js new file mode 100644 index 000000000..fac0f6b9a --- /dev/null +++ b/src/components/settings/services/EditServiceForm.js @@ -0,0 +1,277 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { Link } from 'react-router'; +import { defineMessages, intlShape } from 'react-intl'; +import normalizeUrl from 'normalize-url'; + +import Form from '../../../lib/Form'; +import User from '../../../models/User'; +import Recipe from '../../../models/Recipe'; +import Service from '../../../models/Service'; +import Tabs, { TabItem } from '../../ui/Tabs'; +import Input from '../../ui/Input'; +import Toggle from '../../ui/Toggle'; +import Button from '../../ui/Button'; + +const messages = defineMessages({ + saveService: { + id: 'settings.service.form.saveButton', + defaultMessage: '!!!Save service', + }, + deleteService: { + id: 'settings.service.form.deleteButton', + defaultMessage: '!!!Delete Service', + }, + availableServices: { + id: 'settings.service.form.availableServices', + defaultMessage: '!!!Available services', + }, + yourServices: { + id: 'settings.service.form.yourServices', + defaultMessage: '!!!Your services', + }, + addServiceHeadline: { + id: 'settings.service.form.addServiceHeadline', + defaultMessage: '!!!Add {name}', + }, + editServiceHeadline: { + id: 'settings.service.form.editServiceHeadline', + defaultMessage: '!!!Edit {name}', + }, + tabHosted: { + id: 'settings.service.form.tabHosted', + defaultMessage: '!!!Hosted', + }, + tabOnPremise: { + id: 'settings.service.form.tabOnPremise', + defaultMessage: '!!!Self hosted ⭐️', + }, + customUrlValidationError: { + id: 'settings.service.form.customUrlValidationError', + defaultMessage: '!!!Could not validate custom {name} server.', + }, + customUrlPremiumInfo: { + id: 'settings.service.form.customUrlPremiumInfo', + defaultMessage: '!!!To add self hosted services, you need a Franz Premium Supporter Account.', + }, + customUrlUpgradeAccount: { + id: 'settings.service.form.customUrlUpgradeAccount', + defaultMessage: '!!!Upgrade your account', + }, + indirectMessageInfo: { + id: 'settings.service.form.indirectMessageInfo', + defaultMessage: '!!!You will be notified about all new messages in a channel, not just @username, @channel, @here, ...', // eslint-disable-line + }, +}); + +@observer +export default class EditServiceForm extends Component { + static propTypes = { + recipe: PropTypes.instanceOf(Recipe).isRequired, + // service: PropTypes.oneOfType([ + // PropTypes.object, + // PropTypes.instanceOf(Service), + // ]), + service(props, propName) { + if (props.action === 'edit' && !(props[propName] instanceof Service)) { + return new Error(`'${propName}'' is expected to be of type 'Service' + when editing a Service`); + } + + return null; + }, + user: PropTypes.instanceOf(User).isRequired, + action: PropTypes.string.isRequired, + form: PropTypes.instanceOf(Form).isRequired, + onSubmit: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + isSaving: PropTypes.bool.isRequired, + isDeleting: PropTypes.bool.isRequired, + }; + + static defaultProps = { + service: {}, + }; + static contextTypes = { + intl: intlShape, + }; + + state = { + isValidatingCustomUrl: false, + } + + submit(e) { + const { recipe } = this.props; + + e.preventDefault(); + this.props.form.submit({ + onSuccess: async (form) => { + const values = form.values(); + + let isValid = true; + + if (recipe.validateUrl && values.customUrl) { + this.setState({ isValidatingCustomUrl: true }); + try { + values.customUrl = normalizeUrl(values.customUrl); + isValid = await recipe.validateUrl(values.customUrl); + } catch (err) { + console.warn('ValidateURL', err); + isValid = false; + } + } + + if (isValid) { + this.props.onSubmit(values); + } else { + form.invalidate('url-validation-error'); + } + + this.setState({ isValidatingCustomUrl: false }); + }, + onError: () => {}, + }); + } + + render() { + const { + recipe, + service, + action, + user, + form, + isSaving, + isDeleting, + onDelete, + } = this.props; + const { intl } = this.context; + + const { isValidatingCustomUrl } = this.state; + + const deleteButton = isDeleting ? ( + + ); + } +} diff --git a/src/components/ui/InfoBar.js b/src/components/ui/InfoBar.js new file mode 100644 index 000000000..aea2bd888 --- /dev/null +++ b/src/components/ui/InfoBar.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; +import Loader from 'react-loader'; + +// import { oneOrManyChildElements } from '../../prop-types'; +import Appear from '../ui/effects/Appear'; + +@observer +export default class InfoBar extends Component { + static propTypes = { + // eslint-disable-next-line + children: PropTypes.any.isRequired, + onClick: PropTypes.func, + type: PropTypes.string, + className: PropTypes.string, + ctaLabel: PropTypes.string, + ctaLoading: PropTypes.bool, + position: PropTypes.string, + sticky: PropTypes.bool, + onHide: PropTypes.func, + }; + + static defaultProps = { + onClick: () => null, + type: 'primary', + className: '', + ctaLabel: '', + ctaLoading: false, + position: 'bottom', + sticky: false, + onHide: () => null, + }; + + render() { + const { + children, + type, + className, + ctaLabel, + ctaLoading, + onClick, + position, + sticky, + onHide, + } = this.props; + + let transitionName = 'slideUp'; + if (position === 'top') { + transitionName = 'slideDown'; + } + + return ( + +
    + {children} + {ctaLabel && ( + + )} +
    + {!sticky && ( + + )} + {dismissable && ( + +

    + + )} + + )} +
    + {error.code === 'no-payment-session' && ( +

    {intl.formatMessage(messages.paymentSessionError)}

    + )} +
    + {showSkipOption && this.form.$('paymentTier').value === 'skip' ? ( + + ))} + +
    + {React.Children.map(children, (child, i) => ( +
    + {child} +
    + ))} +
    + + ); + } +} diff --git a/src/components/ui/Tabs/index.js b/src/components/ui/Tabs/index.js new file mode 100644 index 000000000..e4adb62c7 --- /dev/null +++ b/src/components/ui/Tabs/index.js @@ -0,0 +1,6 @@ +import Tabs from './Tabs'; +import TabItem from './TabItem'; + +export default Tabs; + +export { TabItem }; diff --git a/src/components/ui/Toggle.js b/src/components/ui/Toggle.js new file mode 100644 index 000000000..62d46393e --- /dev/null +++ b/src/components/ui/Toggle.js @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; +import { Field } from 'mobx-react-form'; + +@observer +export default class Toggle extends Component { + static propTypes = { + field: PropTypes.instanceOf(Field).isRequired, + className: PropTypes.string, + showLabel: PropTypes.bool, + }; + + static defaultProps = { + className: '', + showLabel: true, + }; + + onChange(e) { + const { field } = this.props; + + field.onChange(e); + } + + render() { + const { + field, + className, + showLabel, + } = this.props; + + if (field.value === '' && field.default !== '') { + field.value = field.default; + } + + return ( +
    +