From 7533c7ae81bb24cf9efd2a2efa5d39e0158e24c5 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Tue, 13 Sep 2016 02:23:39 -0400 Subject: [PATCH] feat(tutorial): Overlay bubbles that guide you through initial features Summary: Add header to show how many trial days remain More rendering out of Store, consolidate registry and store and expose via new serviceRegistry WIP Merge branch 'master' into hallamoore/feature-walkthrough-tutorial Switch to using observable instead of AbortablePromise Update submodule WIP WIP Remove annotations Remove changes WIP Test Plan: No tests Reviewers: evan, juan Reviewed By: juan Differential Revision: https://phab.nylas.com/D3260 --- internal_packages/mode-switch/lib/main.coffee | 19 -- internal_packages/mode-switch/lib/main.es6 | 34 +++ .../mode-switch/lib/mode-toggle.cjsx | 3 +- .../lib/headers/trial-remaining-header.jsx | 89 ++++++++ internal_packages/notifications/lib/main.es6 | 3 + .../stylesheets/trial-remaining-header.less | 59 +++++ internal_packages/thread-snooze/lib/main.es6 | 9 +- .../decorators/has-tutorial-tip.es6 | 214 ++++++++++++++++++ .../metadata-composer-toggle-button.jsx | 7 +- src/global/nylas-component-kit.coffee | 1 + src/global/nylas-exports.coffee | 2 + src/pro | 2 +- src/service-registry.es6 | 31 +++ static/components/tutorial-overlay.less | 78 +++++++ .../nylas-identity-seafoam@1x.png | Bin 0 -> 749 bytes .../nylas-identity-seafoam@2x.png | Bin 0 -> 1947 bytes static/index.less | 1 + 17 files changed, 529 insertions(+), 23 deletions(-) delete mode 100644 internal_packages/mode-switch/lib/main.coffee create mode 100644 internal_packages/mode-switch/lib/main.es6 create mode 100644 internal_packages/notifications/lib/headers/trial-remaining-header.jsx create mode 100644 internal_packages/notifications/stylesheets/trial-remaining-header.less create mode 100644 src/components/decorators/has-tutorial-tip.es6 create mode 100644 src/service-registry.es6 create mode 100644 static/components/tutorial-overlay.less create mode 100644 static/images/notification/nylas-identity-seafoam@1x.png create mode 100644 static/images/notification/nylas-identity-seafoam@2x.png diff --git a/internal_packages/mode-switch/lib/main.coffee b/internal_packages/mode-switch/lib/main.coffee deleted file mode 100644 index c9d59bfbc..000000000 --- a/internal_packages/mode-switch/lib/main.coffee +++ /dev/null @@ -1,19 +0,0 @@ -{ComponentRegistry, WorkspaceStore} = require 'nylas-exports' -ModeToggle = require './mode-toggle' - -# NOTE: this is a hack to allow ComponentRegistry -# to register the same component multiple times in -# different areas. if we do this more than once, let's -# dry this out. -class ModeToggleList extends ModeToggle - @displayName: 'ModeToggleList' - -module.exports = - activate: (state) -> - ComponentRegistry.register ModeToggleList, - location: WorkspaceStore.Sheet.Thread.Toolbar.Right - modes: ['list'] - - ComponentRegistry.register ModeToggle, - location: WorkspaceStore.Sheet.Threads.Toolbar.Right - modes: ['split'] diff --git a/internal_packages/mode-switch/lib/main.es6 b/internal_packages/mode-switch/lib/main.es6 new file mode 100644 index 000000000..54aa418de --- /dev/null +++ b/internal_packages/mode-switch/lib/main.es6 @@ -0,0 +1,34 @@ +import {ComponentRegistry, WorkspaceStore} from 'nylas-exports'; +import {HasTutorialTip} from 'nylas-component-kit'; + +import ModeToggle from './mode-toggle'; + +const ToggleWithTutorialTip = HasTutorialTip(ModeToggle, { + title: 'Compose with Context', + instructions: "N1 shows you everything about your contacts right inside your inbox. See LinkedIn profiles, Twitter bios, message history, and more.", +}); + +// NOTE: this is a hack to allow ComponentRegistry +// to register the same component multiple times in +// different areas. if we do this more than once, let's +// dry this out. +class ToggleWithTutorialTipList extends ToggleWithTutorialTip { + static displayName = 'ModeToggleList' +} + +export function activate() { + ComponentRegistry.register(ToggleWithTutorialTipList, { + location: WorkspaceStore.Sheet.Thread.Toolbar.Right, + modes: ['list'], + }); + + ComponentRegistry.register(ToggleWithTutorialTip, { + location: WorkspaceStore.Sheet.Threads.Toolbar.Right, + modes: ['split'], + }); +} + +export function deactivate() { + ComponentRegistry.unregister(ToggleWithTutorialTip); + ComponentRegistry.unregister(ToggleWithTutorialTipList); +} diff --git a/internal_packages/mode-switch/lib/mode-toggle.cjsx b/internal_packages/mode-switch/lib/mode-toggle.cjsx index 59cf9868a..a99a3ff77 100644 --- a/internal_packages/mode-switch/lib/mode-toggle.cjsx +++ b/internal_packages/mode-switch/lib/mode-toggle.cjsx @@ -21,7 +21,8 @@ class ModeToggle extends React.Component @_unsubscriber?() render: => - + + + ) + } + return ; + } +} diff --git a/internal_packages/notifications/lib/main.es6 b/internal_packages/notifications/lib/main.es6 index 4c1e59b9e..bf3dd8f8c 100644 --- a/internal_packages/notifications/lib/main.es6 +++ b/internal_packages/notifications/lib/main.es6 @@ -6,12 +6,14 @@ import NotificationStore from './notifications-store'; import ConnectionStatusHeader from './headers/connection-status-header'; import AccountErrorHeader from './headers/account-error-header'; import NotificationsHeader from "./headers/notifications-header"; +import TrialRemainingHeader from "./headers/trial-remaining-header"; export function activate() { ComponentRegistry.register(ActivitySidebar, {location: WorkspaceStore.Location.RootSidebar}); ComponentRegistry.register(NotificationsHeader, {location: WorkspaceStore.Sheet.Global.Header}); ComponentRegistry.register(ConnectionStatusHeader, {location: WorkspaceStore.Sheet.Global.Header}); ComponentRegistry.register(AccountErrorHeader, {location: WorkspaceStore.Sheet.Threads.Header}); + ComponentRegistry.register(TrialRemainingHeader, {location: WorkspaceStore.Sheet.Global.Header}); } export function serialize() {} @@ -21,4 +23,5 @@ export function deactivate() { ComponentRegistry.unregister(NotificationsHeader); ComponentRegistry.unregister(ConnectionStatusHeader); ComponentRegistry.unregister(AccountErrorHeader); + ComponentRegistry.unregister(TrialRemainingHeader); } diff --git a/internal_packages/notifications/stylesheets/trial-remaining-header.less b/internal_packages/notifications/stylesheets/trial-remaining-header.less new file mode 100644 index 000000000..adc904e00 --- /dev/null +++ b/internal_packages/notifications/stylesheets/trial-remaining-header.less @@ -0,0 +1,59 @@ +.trial-remaining-header { + background-color: #f6f7f8; + box-shadow: 0px 0px 3px #999; + + .notifications-sticky-item { + color: black; + align-items: center; + padding: 5px; + font-size: 12px; + } + + .icon { + border: solid #4b8e79 1px; + border-radius: 3px; + } + + .upgrade-to-pro { + margin: 0 10px; + border-radius: 5px; + color: white; + background-color: #30A797; + border: solid #30A797 1px; + padding: 0 12px; + cursor: pointer; + } +} + +@handleWidth: 100px; +@handleHeight: 30px; + +.trial-timer-wrapper { + position: relative; + flex-grow: 1; + height: 2px; + background-color: #ccc; + border-radius: 2px; + margin: 0 ~"calc(5px + @{handleWidth}/2)"; +} + +.trial-timer-progress { + position: relative; + background-color: #30A797; + width: 50%; + height: 100%; +} + +.trial-timer-handle { + background-color: #f6f7f8; + box-shadow: 0px 1px 3px #ccc; + border: solid #ccc 1px; + border-radius: 15px; + text-align: center; + padding: 0 15px; + position: absolute; + top: ~"calc(50% - @{handleHeight}/2)"; + color: #30A797; + width: @handleWidth; + height: @handleHeight; +} diff --git a/internal_packages/thread-snooze/lib/main.es6 b/internal_packages/thread-snooze/lib/main.es6 index 74055d8b6..cc85981b5 100644 --- a/internal_packages/thread-snooze/lib/main.es6 +++ b/internal_packages/thread-snooze/lib/main.es6 @@ -1,4 +1,6 @@ import {ComponentRegistry} from 'nylas-exports'; +import {HasTutorialTip} from 'nylas-component-kit'; + import {ToolbarSnooze, QuickActionSnooze} from './snooze-buttons'; import SnoozeMailLabel from './snooze-mail-label' import SnoozeStore from './snooze-store' @@ -7,8 +9,13 @@ import SnoozeStore from './snooze-store' export function activate() { this.snoozeStore = new SnoozeStore() + const ToolbarSnoozeWithTutorialTip = HasTutorialTip(ToolbarSnooze, { + title: "Handle it later!", + instructions: "Snooze this email and it'll return to your inbox later. Click here or swipe across the thread in your inbox to snooze.", + }); + this.snoozeStore.activate() - ComponentRegistry.register(ToolbarSnooze, {role: 'ThreadActionsToolbarButton'}); + ComponentRegistry.register(ToolbarSnoozeWithTutorialTip, {role: 'ThreadActionsToolbarButton'}); ComponentRegistry.register(QuickActionSnooze, {role: 'ThreadListQuickAction'}); ComponentRegistry.register(SnoozeMailLabel, {role: 'Thread:MailLabel'}); } diff --git a/src/components/decorators/has-tutorial-tip.es6 b/src/components/decorators/has-tutorial-tip.es6 new file mode 100644 index 000000000..8fa963493 --- /dev/null +++ b/src/components/decorators/has-tutorial-tip.es6 @@ -0,0 +1,214 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import _ from 'underscore'; + +import {Actions} from 'nylas-exports'; +import NylasStore from 'nylas-store'; + +const TipsBackgroundEl = document.createElement('tutorial-tip-background'); +document.body.appendChild(TipsBackgroundEl); + + +class TipsStore extends NylasStore { + constructor() { + super(); + + this._tipKeys = []; + } + + isTipVisible(key) { + const seen = NylasEnv.config.get('core.tutorial.seen') || []; + return this._tipKeys.find(t => !seen.includes(t)) === key; + } + + hasSeenTip(key) { + return (NylasEnv.config.get('core.tutorial.seen') || []).includes(key); + } + + // Actions: Since this is a private store just inside this file, we call + // these methods directly for now. + + mountedTip = (key) => { + if (!this._tipKeys.includes(key)) { + this._tipKeys.push(key); + } + this.trigger(); + } + + seenTip = (key) => { + this._tipKeys = this._tipKeys.filter(t => t !== key); + NylasEnv.config.pushAtKeyPath('core.tutorial.seen', key); + this.trigger(); + } + + unmountedTip = (key) => { + this._tipKeys = this._tipKeys.filter(t => t !== key); + this.trigger(); + } +} + +TipsStore = new TipsStore(); + +class TipPopoverContents extends React.Component { + static propTypes = { + title: React.PropTypes.string, + tipKey: React.PropTypes.string, + instructions: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element]), + onDismissed: React.PropTypes.func, + } + + componentDidMount() { + TipsBackgroundEl.classList.add('visible'); + } + + componentWillUnmount() { + TipsBackgroundEl.classList.remove('visible'); + if (this.props.onDismissed) { + this.props.onDismissed(); + } + } + + onDone = () => { + TipsStore.seenTip(this.props.tipKey); + Actions.closePopover(); + } + + render() { + let content = null; + + if (typeof(this.props.instructions) === 'string') { + content =

; + } else { + content =

{this.props.instructions}

+ } + + return ( +
+

{this.props.title}

+ {content} + +
+ ); + } +} + +export default function HasTutorialTip(ComposedComponent, TipConfig) { + const TipKey = ComposedComponent.displayName; + + if (TipsStore.hasSeenTip(TipKey)) { + return ComposedComponent; + } + + return class extends ComposedComponent { + static displayName = ComposedComponent.displayName; + + constructor(props) { + super(props); + this.state = {visible: false}; + } + + componentDidMount() { + TipsStore.mountedTip(TipKey); + + this._unlisten = TipsStore.listen(this._onTooltipStateChanged); + window.addEventListener('resize', this._onRecomputeTooltipPosition); + + // unfortunately, we can't render() a container around ComposedComponent + // without modifying the DOM tree and messing with things like flexbox. + // Instead, we leave render() unchanged and attach the bubble and hover + // listeners to the DOM manually. + + this.tipNode = document.createElement('div'); + this.tipNode.classList.add('tutorial-tip'); + document.body.appendChild(this.tipNode); + + const el = ReactDOM.findDOMNode(this); + el.addEventListener('mouseover', this._onMouseOver); + this._onTooltipStateChanged(); + } + + componentDidUpdate() { + if (this.state.visible) { + this._onRecomputeTooltipPosition(); + } + } + + componentWillUnmount() { + this._unlisten(); + + window.removeEventListener('resize', this._onRecomputeTooltipPosition); + document.body.removeChild(this.tipNode); + + TipsStore.unmountedTip(TipKey); + } + + _onTooltipStateChanged = () => { + const visible = TipsStore.isTipVisible(TipKey); + + if (this.state.visible !== visible) { + this.setState({visible}); + if (visible) { + this.tipNode.classList.add('visible'); + this._onRecomputeTooltipPosition(); + } else { + this.tipNode.classList.remove('visible'); + } + } + } + + _onMouseOver = () => { + if (!this.state.visible) { + return; + } + + const el = ReactDOM.findDOMNode(this); + el.removeEventListener('mouseover', this._onMouseOver); + + const tipRect = this.tipNode.getBoundingClientRect(); + const rect = ReactDOM.findDOMNode(this).getBoundingClientRect(); + const rectCX = rect.left + rect.width / 2; + const rectCY = rect.top + rect.height / 2; + TipsBackgroundEl.style.background = ` + -webkit-radial-gradient( + ${Math.round(rectCX / window.innerWidth * 100)}% + ${Math.round(rectCY / window.innerHeight * 100)}%, + circle, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 3%, rgba(0, 0, 0, 0.2) 5%) + `; + Actions.openPopover(( + { + el.addEventListener('mouseover', this._onMouseOver); + }} + /> + ), { + originRect: tipRect, + direction: 'down', + fallbackDirection: 'up', + }) + } + + _onRecomputeTooltipPosition = () => { + const el = ReactDOM.findDOMNode(this); + let settled = 0; + let last = {}; + const attempt = () => { + const {left, top} = el.getBoundingClientRect(); + this.tipNode.style.left = `${left + 5}px`; + this.tipNode.style.top = `${top + 5}px`; + + if (!_.isEqual(last, {left, top})) { + settled = 0; + last = {left, top}; + } + settled += 1; + if (settled < 5) { + window.requestAnimationFrame(this._onRecomputeTooltipPosition); + } + } + attempt(); + } + } +} diff --git a/src/components/metadata-composer-toggle-button.jsx b/src/components/metadata-composer-toggle-button.jsx index 9788fd166..59e128df9 100644 --- a/src/components/metadata-composer-toggle-button.jsx +++ b/src/components/metadata-composer-toggle-button.jsx @@ -116,7 +116,12 @@ export default class MetadataComposerToggleButton extends React.Component { } return ( - ); diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index 0a2eb3cd8..ccfd5a18c 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -93,5 +93,6 @@ class NylasComponentKit @load "ListensToObservable", 'decorators/listens-to-observable' @load "ListensToFluxStore", 'decorators/listens-to-flux-store' @load "ListensToMovementKeys", 'decorators/listens-to-movement-keys' + @load "HasTutorialTip", 'decorators/has-tutorial-tip' module.exports = new NylasComponentKit() diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index b56cf501d..749cc6888 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -143,6 +143,8 @@ class NylasExports @lazyLoadAndRegisterStore "SearchableComponentStore", 'searchable-component-store' @lazyLoad "CustomContenteditableComponents", 'components/overlaid-components/custom-contenteditable-components' + @lazyLoad "ServiceRegistry", "service-registry" + # Decorators @lazyLoad "InflatesDraftClientId", 'decorators/inflates-draft-client-id' diff --git a/src/pro b/src/pro index 736c1ad80..b47405f99 160000 --- a/src/pro +++ b/src/pro @@ -1 +1 @@ -Subproject commit 736c1ad8017e934ad01736888e9892f05ddb2268 +Subproject commit b47405f99f77fe3149ddb55453013ab1058031a8 diff --git a/src/service-registry.es6 b/src/service-registry.es6 new file mode 100644 index 000000000..6275aaec1 --- /dev/null +++ b/src/service-registry.es6 @@ -0,0 +1,31 @@ +class ServiceRegistry { + constructor() { + this._waitingForServices = {}; + this._services = {}; + } + + withService(name, callback) { + if (this._services[name]) { + process.nextTick(() => callback(this._services[name])); + } else { + this._waitingForServices[name] = this._waitingForServices[name] || []; + this._waitingForServices[name].push(callback); + } + } + + registerService(name, obj) { + this._services[name] = obj; + if (this._waitingForServices[name]) { + for (const callback of this._waitingForServices[name]) { + callback(obj); + } + delete this._waitingForServices[name]; + } + } + + unregisterService(name) { + delete this._services[name]; + } +} + +export default new ServiceRegistry() diff --git a/static/components/tutorial-overlay.less b/static/components/tutorial-overlay.less new file mode 100644 index 000000000..a3c68b6e3 --- /dev/null +++ b/static/components/tutorial-overlay.less @@ -0,0 +1,78 @@ +tutorial-tip-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: -webkit-radial-gradient(50% 50%, circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 4%, rgba(0,0,0,0.2) 7%); + z-index: 20; + pointer-events: none; + opacity: 0; + transition: opacity ease-in-out 250ms; +} + +tutorial-tip-background.visible { + pointer-events: inherit; + opacity: 1; +} + +.tutorial-tip { + position: absolute; + width: 13px; + height: 13px; + border-radius: 50%; + display: inline-block; + border: 2px solid white; + cursor: pointer; + pointer-events: none; + z-index: 100; + transform: translate3d(-50%,-50%,0); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgb(241, 170, 211)), to(rgb(185, 59, 255))); + opacity: 0; + transition: opacity ease-out 100ms; +} + +.tutorial-tip.visible { + opacity: 1; +} + +body { + background: #000; +} + +.tutorial-tip.visible:after { + pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + content: ''; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + + box-shadow: 0 0 0 2px rgb(241, 170, 211); + + animation: sonarEffect 2s ease-out 75ms; + animation-iteration-count: infinite; +} + +@-webkit-keyframes sonarEffect { + 0% { + opacity: 0.3; + } + 40% { + opacity: 0.7; + box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5); + } + 80% { + box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5); + -webkit-transform: scale(1.4); + opacity: 0.5; + } + 100% { + box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5); + -webkit-transform: scale(1.5); + opacity: 0; + } +} diff --git a/static/images/notification/nylas-identity-seafoam@1x.png b/static/images/notification/nylas-identity-seafoam@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..226e7dca1801ac0260ea2d82346180778176d667 GIT binary patch literal 749 zcmVnxOyy0(wbA zK~y-)O_NV-jX@N}e|P3-r4R4Dwp1#uNUJSv6o~~vB1kt@B8?OgLM&`VZ0u@fC4{|L z3Xya{VS9bJpefN0^l?4(L zx|{ZKd;eJql{ppy1oH&-BpSc#+fVK7P5=CRVU9{)L3{Nkj<@V*Vy4K{;w-!CS~$_N zhwpPUeEB;?VuIn)2~wjx9o0=V=Qi-*^B~X0-*U2bAF6Qg!%OB6Me4J)oZEVcKMV8pzUreTU(cn^gUlxhcZLU8AuFPhrKvNa%!6QX|I;9A ztMVLe+Qp_d>$v-Vh(C)7-ObziQv5~#*yq>{93Z%ur=SRRRn?r?a)7Oc1}+aiq`0_1 zS7SRJ)pcBa-bYPk6&qLAkf>nlFcn;ESj>Fj+b154yrw>z3MH4W(8pf^Y3P(5s~)EtxPhG0MQiDC?_o9Bydi^57F*OpH^X&2hSI2aiTR zGC4mFI1Gmzd3?=uxgcr2p4p|qw%P`UCx3u=5*0RN^Nbe%;v@qI0GYJ^X-$pjNrkWA z&95Jb#AOm3W79>PWUvxg&EZmj61yHWx@#sliSb%#`$QZ-X%nMwE?p!mrBP03CLmEQ f1*YU61atfYyjFf_(4QYh00000NkvXXu0mjf^juE- literal 0 HcmV?d00001 diff --git a/static/images/notification/nylas-identity-seafoam@2x.png b/static/images/notification/nylas-identity-seafoam@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..82fefc46b58e77eb4664ef353cd3a4710c431fd0 GIT binary patch literal 1947 zcmV;M2W0q(P)1Lu`!NqjBWgTPZ#&+`v%q0 z$TRcJz2}_wyyv~|l^^!MpsHXos0xa>_m<>+_+AA7i=b8jJxQ^`M@fuh$~FQuYs%;lVlXK z6CWfZBB?-9`cklJA~NYJRRuy)9yJBjX0U=sAO;VW8c-M1l4M#MRIt9n5X6Rcs&0({ zA`wk3i2>F+l(LF(Sy~s|2KY5q4T24cXbR5s|9~I{6#>N+CgKZ9A*jT*WUVH!JK~u~ zvNdk8Us(D~V*5a_e&g5*0n}gv$^xQ(A8{zT76f96>yss&l$5aArV@+rhN{9|IJB$^ zNWvkPOsW_J;~486*a)e?ijXroP8FZy>+L%clkxoUjQ6M}mNjf{+ozj`f?^+tS62*-7p_ob#$Gs7VQuj{%U`c4BW|kW>w$Cyp17)fTvX zZ;=0tU8gJGO1`27fSR%#hYCFm&Q5S)>{G5zjk2}AKy7&rfP6(2&$Mr&uDps%lcTJv z`k=-q5!zzN^qkn&7nv*)kpQ8mzLTGR^*E#RQ@nKbuPm*t(p_I5Ycc@5e{YCQ>o!nR zR>|2b{U}1Qz6B!&5n*e6GuNi?GW_6v3G?xyr34OGNzQ^Wg4ShQm95|h#iRUk_etKn z`x!sF@Ovn?i6Ezj|jC!Xc!yS~lehOcqv{Ht7>8jsvr^BQ1NN@~GPiU^r)C!X%p zR0|TR6*|^!`VK&rn@f$>zEiL0I?&lBm z1^GoCJMlu%QQO3x<}Cm;hCmif&`{fx~&U}kxVnzBlEZ|tB@UB@>&i%dLR z;MbSVGqb$Jj`hv-kKW|g>@0{W<$;hSkXWq5npuQ9^E0d~%h8o@PAOeG`&+trbL2V~ zCq^h#=Q+^&xF=zTT}^H5ZRy~(!H+pN@F9!VGPU^72ll~zu>hGZ#}D?|c*;?tRx7H! zJ3hzdkoJ#h{_k)=aVqO*@AeZ=(Dp)mZ-42W_kIO_c;Cbt6ZKOjr#JL*LgH`}^Z5Vmc({P52yfgPfYin+ z!LT~+^p6aAK_eR10GqSu3L<3VjU}benu0#4A|Vlv5+v?fQ=Tyx6hva*o!0ok5;zcS zc~Cx0`r~dil-z!T(+&GOj`(<1;)ig3^MdG*l~fB-cL@cu!3l@{Vbd3plxjr?1LXdh zm3Sp16)d!t=m*scT#D!#5irq|x?Y+`E|;lZM=e)>v^u+ucReMu7L+euMEqk(M9JV; zZcA=2=|F)O8c9}i-_uYXsop2SU1G`jqf&A^%9hyYN3YZx*sX~s_-j`ZGxXmCY~A~G h(3I9_;D@-e{~t>$oc~#(?F|3`002ovPDHLkV1fb&yt)7Y literal 0 HcmV?d00001 diff --git a/static/index.less b/static/index.less index f30dbf2a4..79be91825 100644 --- a/static/index.less +++ b/static/index.less @@ -40,3 +40,4 @@ @import "components/table"; @import "components/editable-table"; @import "components/multiselect-dropdown"; +@import "components/tutorial-overlay";