Start of a new UI for Dimension

Integrations need styling and the breadcrumbs don't work. Further, you can't actually add/edit anything.
This commit is contained in:
Travis Ralston 2017-12-14 21:25:15 -07:00
parent 618d6f44ee
commit 6657d5dbf5
19 changed files with 322 additions and 160 deletions

12
package-lock.json generated
View File

@ -4550,6 +4550,12 @@
"resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
"integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU="
},
"ng2-breadcrumbs": {
"version": "0.1.281",
"resolved": "https://registry.npmjs.org/ng2-breadcrumbs/-/ng2-breadcrumbs-0.1.281.tgz",
"integrity": "sha1-OKZsYoD7BgwacyQ7ezP3zZ88waY=",
"dev": true
},
"ngx-modialog": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/ngx-modialog/-/ngx-modialog-3.0.4.tgz",
@ -8052,6 +8058,12 @@
"wbuf": "1.7.2"
}
},
"spinkit": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/spinkit/-/spinkit-1.2.5.tgz",
"integrity": "sha1-kPn0ZqIOjjnvJNqVnB5hHCow3VQ=",
"dev": true
},
"split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",

View File

@ -72,6 +72,7 @@
"html-webpack-plugin": "^2.28.0",
"jquery": "^3.2.1",
"json-loader": "^0.5.4",
"ng2-breadcrumbs": "^0.1.281",
"ngx-modialog": "^3.0.4",
"node-sass": "^4.7.2",
"postcss-cssnext": "^3.0.0",
@ -84,6 +85,7 @@
"rxjs": "^5.5.5",
"sass-loader": "^6.0.3",
"shelljs": "^0.7.8",
"spinkit": "^1.2.5",
"style-loader": "^0.18.2",
"ts-helpers": "^1.1.2",
"tslint": "^5.8.0",

View File

@ -33,6 +33,25 @@ class DimensionApi {
app.get("/api/v1/dimension/integrations/:roomId/:type/:integrationType/state", this._getIntegrationState.bind(this));
app.get("/api/v1/dimension/widgets/embeddable", this._checkEmbeddable.bind(this));
app.get("/api/v1/dimension/integration/:type/:integrationType", this._getIntegration.bind(this));
app.get("/api/v1/dimension/whoami", this._getTokenOwner.bind(this));
}
_getTokenOwner(req, res) {
res.setHeader("Content-Type", "application/json");
var scalarToken = req.query.scalar_token;
if (!scalarToken) {
res.status(400).send({error: 'Missing scalar token'});
return;
}
this._db.getTokenOwner(scalarToken).then(userId => {
res.status(200).send({userId: userId});
}).catch(err => {
log.error("DimensionApi", err);
console.error(err);
res.status(401).send({error: 'Invalid token or other error'});
});
}
_checkEmbeddable(req, res) {
@ -113,7 +132,8 @@ class DimensionApi {
}
}
_getIntegration(req, res) {res.setHeader("Content-Type", "application/json");
_getIntegration(req, res) {
res.setHeader("Content-Type", "application/json");
// Unauthed endpoint.
var type = req.params.type;

View File

@ -92,6 +92,18 @@ class DimensionStore {
});
}
/**
* Gets the user ID that owns a given token, returning a falsey value if no one owns the token.
* @param {string} scalarToken the scalar token to check
* @returns {Promise<String>} resolves to the user ID, or a falsey value if no user ID was found
*/
getTokenOwner(scalarToken) {
return this.__Tokens.find({where:{scalarToken: scalarToken}}).then(token => {
if (!token) return Promise.reject(new Error("Token not found"));
return Promise.resolve(token.matrixUserId);
})
}
/**
* Gets the upstream token for a given scalar token
* @param {string} scalarToken the scalar token to lookup

View File

@ -2,6 +2,8 @@
</header>
<main>
<toaster-container></toaster-container>
<!-- The breadcrumb needs to be defined here otherwise it doesn't work -->
<breadcrumb [allowBootstrap]="false" [hidden]="true"></breadcrumb>
<router-outlet></router-outlet>
</main>
<footer>

View File

@ -26,6 +26,9 @@ import { FullscreenButtonComponent } from "./fullscreen-button/fullscreen-button
import { VideoWidgetWrapperComponent } from "./widget_wrappers/video/video.component";
import { JitsiWidgetWrapperComponent } from "./widget_wrappers/jitsi/jitsi.component";
import { GCalWidgetWrapperComponent } from "./widget_wrappers/gcal/gcal.component";
import { PageHeaderComponent } from "./page-header/page-header.component";
import { SpinnerComponent } from "./spinner/spinner.component";
import { BreadcrumbsModule } from "ng2-breadcrumbs";
const WIDGET_CONFIGURATION_COMPONENTS: any[] = IntegrationService.getAllConfigComponents();
@ -41,6 +44,7 @@ const WIDGET_CONFIGURATION_COMPONENTS: any[] = IntegrationService.getAllConfigCo
BrowserAnimationsModule,
ModalModule.forRoot(),
BootstrapModalModule,
BreadcrumbsModule,
],
declarations: [
...WIDGET_CONFIGURATION_COMPONENTS,
@ -48,6 +52,8 @@ const WIDGET_CONFIGURATION_COMPONENTS: any[] = IntegrationService.getAllConfigCo
HomeComponent,
RiotComponent,
IntegrationComponent,
PageHeaderComponent,
SpinnerComponent,
ScalarCloseComponent,
MyFilterPipe,
GenericWidgetWrapperComponent,

View File

@ -8,7 +8,10 @@ import { GCalWidgetWrapperComponent } from "./widget_wrappers/gcal/gcal.componen
const routes: Routes = [
{path: "", component: HomeComponent},
{path: "riot", component: RiotComponent},
{
path: "riot", component: RiotComponent, data: {breadcrumb: "Home"},
children: [{path: "test", component: RiotComponent, data: {breadcrumb: "Testing"}}]
},
{path: "widgets/generic", component: GenericWidgetWrapperComponent},
{path: "widgets/video", component: VideoWidgetWrapperComponent},
{path: "widgets/jitsi", component: JitsiWidgetWrapperComponent},

View File

@ -0,0 +1,9 @@
<div class="header">
<div class="title">
<h2 class="pageName">{{ pageName }}</h2>
<breadcrumb [allowBootstrap]="false"></breadcrumb>
</div>
<div class="quickAction">
<ng-content></ng-content>
</div>
</div>

View File

@ -0,0 +1,28 @@
// component styles are encapsulated and only applied to their components
.header {
width: 100%;
margin-top: 30px;
border-top: 1px solid #e7eaec;
border-bottom: 1px solid #e7eaec;
background-color: #fff;
}
.header .title {
margin: 0;
width: 83.333333%; // col-sm-10
padding: 15px;
}
.header .title .pageName {
font-weight: 100;
padding: 0;
margin: 0;
}
.header .quickAction {
padding: 0;
margin: 0;
float: right;
text-align: right;
width: 16.666667%; // col-sm-2
}

View File

@ -0,0 +1,10 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "my-page-header",
templateUrl: "./page-header.component.html",
styleUrls: ["./page-header.component.scss"],
})
export class PageHeaderComponent {
@Input() pageName: string;
}

View File

@ -1,76 +1,57 @@
<div id="wrapper">
<my-scalar-close></my-scalar-close>
<div *ngIf="error">
<p class="text-danger">{{ error }}</p>
</div>
<div *ngIf="loading && !error">
<p><i class="fa fa-circle-o-notch fa-spin"></i> Loading...</p>
</div>
<div *ngIf="!error && !loading">
<!-- ------------------------ -->
<!-- EMPTY/ENCRYPTED STATES -->
<!-- ------------------------ -->
<div class="alert alert-warning" *ngIf="integrations.length > 0 && isEncryptedRoom">
<h4>This room is encrypted</h4>
<strong>Integrations are not encrypted!</strong> This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Riot, and other similar details. Add integrations with caution.
<my-page-header pageName="Dimension"></my-page-header>
<div class="page-content">
<div *ngIf="isError">
<div class="alert alert-danger">{{ errorMessage }}</div>
</div>
<div class="alert alert-warning" *ngIf="integrations.length === 0 && isEncryptedRoom">
<h4>This room is encrypted</h4>
There are currently no integrations which support encrypted rooms. Sorry about that!
</div>
<div class="alert alert-warning" *ngIf="integrations.length === 0 && !isEncryptedRoom">
<h4>No integrations available</h4>
This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<!-- ------------------------ -->
<!-- WIDGETS -->
<!-- ------------------------ -->
<h4 *ngIf="hasAnyOf('widget')">
Widgets <i class="fa fa-question-circle text-info" style="font-size: 15px;" placement="bottom"
ngbTooltip="Widgets add small apps to Riot, like Google Docs, Jitsi conferences, and YouTube videos"></i>
</h4>
<div class="integration-container">
<my-integration *ngFor="let integration of integrations | myFilter:'type':'widget'"
[integration]="integration"
[roomId]="roomId"
[scalarToken]="scalarToken"
(updated)="updateIntegration(integration)"></my-integration>
</div>
<div *ngIf="!isLoading && !isError">
<!-- ------------------------ -->
<!-- EMPTY/ENCRYPTED STATES -->
<!-- ------------------------ -->
<div class="alert alert-warning" *ngIf="hasIntegrations() && isRoomEncrypted">
<h4>This room is encrypted</h4>
<strong>Integrations are not encrypted!</strong>
This means that some information about yourself and the
room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display
name,
your username, your avatar, information about Riot, and other similar details. Add integrations with
caution.
</div>
<div class="alert alert-warning" *ngIf="!hasIntegrations() && isRoomEncrypted">
<h4>This room is encrypted</h4>
There are currently no integrations which support encrypted rooms. Sorry about that!
</div>
<div class="alert alert-warning" *ngIf="!hasIntegrations() && !isRoomEncrypted">
<h4>No integrations available</h4>
This room does not have any compatible integrations. Please contact the server owner if you're seeing
this
message.
</div>
<!-- ------------------------ -->
<!-- BOTS -->
<!-- ------------------------ -->
<h4 *ngIf="hasAnyOf('bot', 'complex-bot')">
Bots <i class="fa fa-question-circle text-info" style="font-size: 15px;" placement="bottom"
ngbTooltip="Bots can provide entertainment or some utility to your room"></i>
</h4>
<div class="integration-container">
<my-integration *ngFor="let integration of integrations | myFilter:'type':'bot'"
[integration]="integration"
[roomId]="roomId"
[scalarToken]="scalarToken"
(updated)="updateIntegration(integration)"></my-integration>
<my-integration *ngFor="let integration of integrations | myFilter:'type':'complex-bot'"
[integration]="integration"
[roomId]="roomId"
[scalarToken]="scalarToken"
(updated)="updateIntegration(integration)"></my-integration>
</div>
<!-- ------------------------ -->
<!-- BRIDGES -->
<!-- ------------------------ -->
<h4 *ngIf="hasAnyOf('bridge')">
Bridges <i class="fa fa-question-circle text-info" style="font-size: 15px;" placement="bottom"
ngbTooltip="Bridges allow people on other platforms to talk in the room"></i>
</h4>
<div class="integration-container">
<my-integration *ngFor="let integration of integrations | myFilter:'type':'bridge'"
[integration]="integration"
[roomId]="roomId"
[scalarToken]="scalarToken"
(updated)="updateIntegration(integration)"></my-integration>
<!-- ------------------------ -->
<!-- CATEGORIES -->
<!-- ------------------------ -->
<div *ngFor="let category of getCategories()">
<div class="ibox" *ngIf="getIntegrationsIn(category).length > 0">
<div class="ibox-title">
<h4>{{ category }}</h4>
</div>
<div class="ibox-content">
<div class="integration" *ngFor="let integration of getIntegrationsIn(category)">
<img class="integration-avatar" [src]="getSafeUrl(integration.avatar)"/>
<div class="integration-name">{{ integration.name }}</div>
<div class="integration-description">{{ integration.about }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,20 +1,10 @@
// component styles are encapsulated and only applied to their components
.integration-container {
display: flex;
flex-wrap: wrap;
margin-bottom: 20px;
}
#wrapper {
padding: 30px;
}
my-scalar-close {
position: fixed;
top: 15px;
top: 10px;
right: 15px;
}
h4 {
margin-left: 5px;
.page-content {
padding: 30px;
}

View File

@ -8,6 +8,12 @@ import { IntegrationService } from "../shared/integration.service";
import * as _ from "lodash";
import { IntegrationComponent } from "../integration/integration.component";
const CATEGORY_MAP = {
"Widgets": ["widget"],
"Bots": ["complex-bot", "bot"],
"Bridges": ["bridge"],
};
@Component({
selector: "my-riot",
templateUrl: "./riot.component.html",
@ -16,15 +22,18 @@ import { IntegrationComponent } from "../integration/integration.component";
export class RiotComponent {
@ViewChildren(IntegrationComponent) integrationComponents: Array<IntegrationComponent>;
public error: string;
public integrations: Integration[] = [];
public loading = true;
public roomId: string;
public scalarToken: string;
public isEncryptedRoom = false;
public isLoading = true;
public isError = false;
public errorMessage: string;
public isRoomEncrypted: boolean;
private scalarToken: string;
private roomId: string;
private userId: string;
private requestedScreen: string = null;
private requestedIntegration: string = null;
private integrationsForCategory: { [category: string]: Integration[] } = {};
private categoryMap: { [categoryName: string]: string[] } = CATEGORY_MAP;
constructor(private activatedRoute: ActivatedRoute,
private api: ApiService,
@ -35,27 +44,94 @@ export class RiotComponent {
this.requestedScreen = params.screen;
this.requestedIntegration = params.integ_id;
if (!params.scalar_token || !params.room_id) this.error = "Missing scalar token or room ID";
else {
if (!params.scalar_token || !params.room_id) {
console.error("Unable to load Dimension. Missing room ID or scalar token.");
this.isError = true;
this.isLoading = false;
this.errorMessage = "Unable to load Dimension - missing room ID or token.";
} else {
this.roomId = params.room_id;
this.scalarToken = params.scalar_token;
this.api.checkScalarToken(params.scalar_token).then(isValid => {
if (isValid) this.init();
else this.error = "Invalid scalar token";
this.api.getTokenOwner(params.scalar_token).then(userId => {
if (!userId) {
console.error("No user returned for token. Is the token registered in Dimension?");
this.isError = true;
this.isLoading = false;
this.errorMessage = "Could not verify your token. Please try logging out of Riot and back in. Be sure to back up your encryption keys!";
} else {
this.userId = userId;
console.log("Scalar token belongs to " + userId);
this.prepareIntegrations();
}
}).catch(err => {
this.error = "Unable to communicate with Dimension";
console.error(err);
this.isError = true;
this.isLoading = false;
this.errorMessage = "Unable to communicate with Dimension due to an unknown error.";
});
}
}
private init() {
public hasIntegrations(): boolean {
for (const category of this.getCategories()) {
if (this.getIntegrationsIn(category).length > 0) return true;
}
return false;
}
public getCategories(): string[] {
return Object.keys(this.categoryMap);
}
public getIntegrationsIn(category: string): Integration[] {
return this.integrationsForCategory[category];
}
public modifyIntegration(integration: Integration) {
console.log(this.userId + " is trying to modify " + integration.name);
if (integration.hasAdditionalConfig) {
// TODO: Navigate to edit screen
console.log("EDIT SCREEN FOR " + integration.name);
} else {
// It's a flip-a-bit (simple bot)
// TODO: "Are you sure?" dialog
let promise = null;
if (!integration.isEnabled) {
promise = this.scalar.inviteUser(this.roomId, integration.userId);
} else promise = this.api.removeIntegration(this.roomId, integration.type, integration.integrationType, this.scalarToken);
// We set this ahead of the promise for debouncing
integration.isEnabled = !integration.isEnabled;
integration.isUpdating = true;
promise.then(() => {
integration.isUpdating = false;
if (integration.isEnabled) this.toaster.pop("success", integration.name + " was invited to the room");
else this.toaster.pop("success", integration.name + " was removed from the room");
}).catch(err => {
integration.isEnabled = !integration.isEnabled; // revert the status change
integration.isUpdating = false;
console.error(err);
let errorMessage = null;
if (err.json) errorMessage = err.json().error;
if (err.response && err.response.error) errorMessage = err.response.error.message;
if (!errorMessage) errorMessage = "Could not update integration status";
this.toaster.pop("error", errorMessage);
})
}
}
private prepareIntegrations() {
this.scalar.isRoomEncrypted(this.roomId).then(payload => {
this.isEncryptedRoom = payload.response;
this.isRoomEncrypted = payload.response;
return this.api.getIntegrations(this.roomId, this.scalarToken);
}).then(integrations => {
const supportedIntegrations = _.filter(integrations, i => IntegrationService.isSupported(i));
const supportedIntegrations: Integration[] = _.filter(integrations, i => IntegrationService.isSupported(i));
for (const integration of supportedIntegrations) {
// Widgets technically support encrypted rooms, so unless they explicitly declare that
@ -65,20 +141,34 @@ export class RiotComponent {
integration.supportsEncryptedRooms = true;
}
if (this.isEncryptedRoom)
this.integrations = _.filter(supportedIntegrations, i => i.supportsEncryptedRooms);
else this.integrations = supportedIntegrations;
// Flag integrations that aren't supported in encrypted rooms
if (this.isRoomEncrypted) {
for (const integration of supportedIntegrations) {
if (!integration.supportsEncryptedRooms) {
integration.isSupported = false;
integration.notSupportedReason = "This integration is not supported in encrypted rooms";
}
}
}
let promises = this.integrations.map(b => this.updateIntegrationState(b));
// Set up the categories
for (const category of Object.keys(this.categoryMap)) {
const supportedTypes = this.categoryMap[category];
this.integrationsForCategory[category] = _.filter(supportedIntegrations, i => supportedTypes.indexOf(i.type) !== -1);
}
let promises = supportedIntegrations.map(i => this.updateIntegrationState(i));
return Promise.all(promises);
}).then(() => {
this.loading = false;
this.isLoading = false;
// HACK: We wait for the digest cycle so we actually have components to look at
setTimeout(() => this.tryOpenConfigScreen(), 20);
}).catch(err => {
this.error = "Unable to communicate with Dimension";
console.error(err);
this.isError = true;
this.isLoading = false;
this.errorMessage = "Unable to set up Dimension. This version of Riot may not supported or there may be a problem with the server.";
});
}
@ -109,13 +199,14 @@ export class RiotComponent {
}
private updateIntegrationState(integration: Integration) {
integration.hasConfig = IntegrationService.hasConfig(integration);
integration.hasAdditionalConfig = IntegrationService.hasConfig(integration);
if (integration.type === "widget") {
if (!integration.requirements) integration.requirements = {};
integration.requirements["canSetWidget"] = true;
}
// If the integration has requirements, then we'll check those instead of anything else
if (integration.requirements) {
let keys = _.keys(integration.requirements);
let promises = [];
@ -126,29 +217,30 @@ export class RiotComponent {
}
return Promise.all(promises).then(() => {
integration.isEnabled = true;
integration.isBroken = false;
integration.isSupported = true;
integration.notSupportedReason = null;
}, error => {
console.error(error);
integration.bridgeError = error.message || error;
integration.isEnabled = false;
integration.isBroken = false;
integration.isSupported = false;
integration.notSupportedReason = error;
});
}
// The integration doesn't have requirements, so we'll just make sure the bot user can be retrieved.
return this.scalar.getMembershipState(this.roomId, integration.userId).then(payload => {
integration.isBroken = false;
if (!payload.response) {
integration.isEnabled = false;
return;
if (payload.response) {
integration.isSupported = true;
integration.notSupportedReason = null;
integration.isEnabled = (payload.response.membership === "join" || payload.response.membership === "invite");
} else {
console.error("No response received to membership query of " + integration.userId);
integration.isSupported = false;
integration.notSupportedReason = "Unable to query membership state for this bot";
}
integration.isEnabled = (payload.response.membership === "join" || payload.response.membership === "invite");
}, (error) => {
console.error(error);
integration.isEnabled = false;
integration.isBroken = true;
integration.isSupported = false;
integration.notSupportedReason = "Unable to query membership state for this bot";
});
}
@ -163,50 +255,19 @@ export class RiotComponent {
}
return payload.response.join_rule === requirement
? Promise.resolve()
: Promise.reject(new Error("The room must be " + requirement + " to use this integration."));
: Promise.reject("The room must be " + requirement + " to use this integration.");
});
case "canSetWidget":
const processPayload = payload => {
const response = <any>payload.response;
if (response === true) return Promise.resolve();
if (response.error || response.error.message)
return Promise.reject(new Error("You cannot modify widgets in this room"));
return Promise.reject("You cannot modify widgets in this room");
return Promise.reject("Error communicating with Riot");
};
return this.scalar.canSendEvent(this.roomId, "im.vector.modular.widgets", true).then(processPayload).catch(processPayload);
default:
return Promise.reject(new Error("Requirement '" + key + "' not found"));
return Promise.reject("Requirement '" + key + "' not found");
}
}
public updateIntegration(integration: Integration) {
let promise = null;
if (!integration.isEnabled) {
promise = this.api.removeIntegration(this.roomId, integration.type, integration.integrationType, this.scalarToken);
} else promise = this.scalar.inviteUser(this.roomId, integration.userId);
promise.then(() => {
if (integration.isEnabled)
this.toaster.pop("success", integration.name + " was invited to the room");
else this.toaster.pop("success", integration.name + " was removed from the room");
}).catch(err => {
let errorMessage = "Could not update integration status";
if (err.json) {
errorMessage = err.json().error;
} else errorMessage = err.response.error.message;
integration.isEnabled = !integration.isEnabled;
this.toaster.pop("error", errorMessage);
});
}
public hasAnyOf(...types: string[]): boolean {
for (const integration of this.integrations) {
if (types.indexOf(integration.type) !== -1) return true;
}
return false;
}
}

View File

@ -12,6 +12,11 @@ export class ApiService {
.map(res => res.status === 200).toPromise();
}
getTokenOwner(scalarToken: String): Promise<string> {
return this.http.get("/api/v1/dimension/whoami", {params:{scalar_token:scalarToken}})
.map(res => res.status === 200 ? res.json()["userId"] : null).toPromise();
}
getIntegrations(roomId: string, scalarToken: string): Promise<Integration[]> {
return this.http.get("/api/v1/dimension/integrations/" + roomId, {params: {scalar_token: scalarToken}})
.map(res => res.json()).toPromise();

View File

@ -1,4 +1,5 @@
export interface Integration {
// These are from the server
type: string;
integrationType: string;
userId: string;
@ -6,13 +7,14 @@ export interface Integration {
avatar: string;
about: string; // nullable
supportsEncryptedRooms: boolean;
requirements: any; // nullable
// Set by us
isEnabled: boolean;
isBroken: boolean;
hasConfig: boolean;
requirements?: any; // nullable
bridgeError: string; // nullable
// These are set in the UI
isSupported: boolean;
notSupportedReason: string;
hasAdditionalConfig: boolean;
isEnabled: boolean; // for the flip-a-bit integrations
isUpdating: boolean;
}
export interface RSSIntegration extends Integration {

View File

@ -0,0 +1,6 @@
<div class="sk-folding-cube">
<div class="sk-cube1 sk-cube"></div>
<div class="sk-cube2 sk-cube"></div>
<div class="sk-cube4 sk-cube"></div>
<div class="sk-cube3 sk-cube"></div>
</div>

View File

@ -0,0 +1,2 @@
// component styles are encapsulated and only applied to their components
@import "../../../node_modules/spinkit/scss/spinners/11-folding-cube.scss";

View File

@ -0,0 +1,9 @@
import { Component } from "@angular/core";
@Component({
selector: "my-spinner",
templateUrl: "./spinner.component.html",
styleUrls: ["./spinner.component.scss"],
})
export class SpinnerComponent {
}

View File

@ -1,11 +1,13 @@
// styles in src/style directory are applied to the whole page
@import url('https://fonts.googleapis.com/css?family=Open+Sans:100|Roboto:300');
@import '../../node_modules/angular2-toaster/toaster';
body {
background: #ddd !important;
background: rgb(238, 238, 238) !important;
margin: 0;
padding: 0;
color: #222;
color: #333;
font-family: 'Open Sans', sans-serif;
}
// HACK: Work around dialog not showing up