...
 
Commits (90)
image: markharding/minds-front-base
services:
- docker:dind
stages:
- test
- build
......@@ -154,6 +151,8 @@ build:production:i18n:
prepare:review:
stage: prepare
image: minds/ci:latest
services:
- docker:dind
script:
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- docker build -t $CI_REGISTRY_IMAGE/front-init:$CI_PIPELINE_ID -f containers/front-init/Dockerfile dist/.
......@@ -179,6 +178,8 @@ prepare:review:sentry:
prepare:production:
stage: prepare
image: minds/ci:latest
services:
- docker:dind
script:
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- docker build -t $CI_REGISTRY_IMAGE/front-init:$CI_PIPELINE_ID -f containers/front-init/Dockerfile dist/.
......@@ -261,6 +262,8 @@ review:stop:
.deploy: &deploy
image: minds/ci:latest
services:
- docker:dind
script:
## Sync assets with CDN
- aws s3 sync dist $S3_REPOSITORY_URL
......
### Summary
(What is the Merge request intending to do, in plain language)
(Be sure to associate any related issues or merge requests)
### Steps to test
(Steps to demonstrate merge achieves goal)
(Include any platform specific directions)
### Estimated Regression Scope
(What features do these changes effect in your estimation?)
......@@ -6,7 +6,7 @@ Minds Front
Front-end web application for Minds. Please run inside of [the Minds repo](https://github.com/minds/minds).
## Documentation
Documentation for Minds can be found at [minds.org/docs](https://www.minds.org/docs)
Please see the documentation on [developers.minds.com](https://developers.minds.com) for instructions on how to [build the Minds Front-end](https://developers.minds.com/docs/guides/frontend).
### Building
Please see the documentation on Minds.org for instructions on how to [build the Minds Front-end](https://www.minds.org/docs/install/preparation.html#front-end).
......
import generateRandomId from '../support/utilities';
context('Newsfeed', () => {
before(() => {
cy.getCookie('minds_sess').then(sessionCookie => {
......@@ -14,7 +16,6 @@ context('Newsfeed', () => {
cy.route('POST', '**/api/v1/media').as('mediaPOST');
cy.route('POST', '**/api/v1/newsfeed/**').as('newsfeedEDIT');
cy.route('POST', '**/api/v1/media/**').as('mediaEDIT');
cy.visit('/newsfeed/subscriptions')
.location('pathname')
.should('eq', '/newsfeed/subscriptions');
......@@ -37,6 +38,19 @@ context('Newsfeed', () => {
cy.get('minds-newsfeed-poster textarea').type(content);
};
const attachRichEmbed = (embedUrl) => {
cy.get('minds-newsfeed-poster').should('be.visible');
cy.get('minds-newsfeed-poster textarea')
.type(embedUrl);
cy.route('GET', `**/api/v1/newsfeed/preview?url=${embedUrl}**`)
.as('previewGET')
.wait('@previewGET')
.then(xhr => {
expect(xhr.status).to.equal(200);
});
}
const attachImageToActivity = () => {
cy.uploadFile(
'#attachment-input-poster',
......@@ -511,4 +525,140 @@ context('Newsfeed', () => {
deleteActivityFromNewsfeed();
});
it('should show a rich embed post from youtube in a modal', () => {
const content = generateRandomId() + " ",
url = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
// set up post.
newActivityContent(content);
attachRichEmbed(url);
// post and await.
cy.get('.m-posterActionBar__PostButton')
.click()
.wait('@newsfeedPOST').then(xhr => {
expect(xhr.status).to.equal(200);
//get activity, click it.
cy.get(`[minds-data-activity-guid='${xhr.response.body.guid}']`)
.click();
//check modal is open.
cy.get('[data-cy=data-minds-media-modal]')
.contains(content);
// close modal and tidy.
cy.get('.m-overlay-modal--backdrop')
.click({force: true});
deleteActivityFromNewsfeed();
});
});
it('should not open vimeo in a modal', () => {
const content = generateRandomId() + " ",
url = 'https://vimeo.com/8733915';
// set up post.
newActivityContent(content);
attachRichEmbed(url);
// post and await.
cy.get('.m-posterActionBar__PostButton')
.click()
.wait('@newsfeedPOST').then(xhr => {
expect(xhr.status).to.equal(200);
//get activity, make assertions tht would not be true for modals.
cy.get(`[minds-data-activity-guid='${xhr.response.body.guid}']`)
.should('be.visible')
.get('iframe')
.should('be.visible')
.get('.minds-more')
.should('be.visible');
// tidy.
deleteActivityFromNewsfeed();
});
});
it('should not open soundcloud in a modal', () => {
const content = generateRandomId() + " ",
url = 'https://soundcloud.com/richarddjames/piano-un10-it-happened';
// set up post.
newActivityContent(content);
attachRichEmbed(url);
// post and await.
cy.get('.m-posterActionBar__PostButton')
.click()
.wait('@newsfeedPOST').then(xhr => {
expect(xhr.status).to.equal(200);
//get activity, make assertions tht would not be true for modals.
cy.get(`[minds-data-activity-guid='${xhr.response.body.guid}']`)
.should('be.visible')
.get('.m-rich-embed-action-overlay')
.should('be.visible')
.get('.minds-more')
.should('be.visible');
deleteActivityFromNewsfeed();
});
});
it('should not open spotify in a modal', () => {
const content = generateRandomId() + " ",
url = 'https://open.spotify.com/track/2MZSXhq4XDJWu6coGoXX1V?si=nvja0EfwR3q6GMQmYg6gPQ';
// set up post.
newActivityContent(content);
attachRichEmbed(url);
// post and await.
cy.get('.m-posterActionBar__PostButton')
.click()
.wait('@newsfeedPOST').then(xhr => {
expect(xhr.status).to.equal(200);
//get activity, make assertions tht would not be true for modals.
cy.get(`[minds-data-activity-guid='${xhr.response.body.guid}']`)
.should('be.visible')
.get('.m-rich-embed-action-overlay')
.should('be.visible')
.get('.minds-more')
.should('be.visible');
deleteActivityFromNewsfeed();
});
});
it('should not open spotify in a modal', () => {
const content = generateRandomId() + " ",
url = 'http://giphygifs.s3.amazonaws.com/media/IzVquL965ib4s/giphy.gif';
// set up post.
newActivityContent(content);
attachRichEmbed(url);
// post and await.
cy.get('.m-posterActionBar__PostButton')
.click()
.wait('@newsfeedPOST').then(xhr => {
expect(xhr.status).to.equal(200);
//get activity, make assertions tht would not be true for modals.
cy.get(`[minds-data-activity-guid='${xhr.response.body.guid}']`)
.should('be.visible')
.get('.m-rich-embed-action-overlay')
.should('be.visible')
.get('.minds-more')
.should('be.visible');
deleteActivityFromNewsfeed();
});
});
});
......@@ -3,32 +3,38 @@
* @desc E2E testing for Minds Pro's pages.
*/
context('Pro Page', () => {
if (Cypress.env().pro_password) { // required to run tests against pro user only.
if (Cypress.env().pro_password) {
// required to run tests against pro user only.
const topBar = '.m-proChannel__topbar';
let categories = [
{ label: 'Technology', tag: '#technology' },
{ label: 'Food', tag: '#food' },
{ label: 'News', tag: '#news' }
{ label: 'News', tag: '#news' },
];
let footerLinks = [
{ label: 'Minds', link: 'https://www.minds.com/' },
{ label: 'Careers', link: 'https://www.minds.com/careers' },
];
const proButton = 'data-minds-sidebar-admin-pro-button';
function resetSettings() {
cy.visit(`/pro/settings`);
cy.route("POST", "**/api/v2/pro/settings").as("settings");
cy.route('POST', '**/api/v2/pro/settings').as('settings');
cy.get('#title').focus().clear().type('Title');
cy.get('#headline').focus().clear().type('This is a headline');
cy.get('#title')
.focus()
.clear()
.type('Title');
cy.get('#headline')
.focus()
.clear()
.type('This is a headline');
cy.contains('Hashtags')
.click();
cy.contains('Hashtags').click();
// remove all hashtags
removeInputs();
......@@ -37,8 +43,7 @@ context('Pro Page', () => {
let cat = categories[i];
addTag(cat.label, cat.tag, i);
}
cy.contains('Footer')
.click();
cy.contains('Footer').click();
cy.get('#footer_text')
.clear()
......@@ -54,30 +59,36 @@ context('Pro Page', () => {
cy.contains('Save')
.click()
.wait('@settings').then((xhr) => {
.wait('@settings')
.then(xhr => {
expect(xhr.status).to.equal(200);
expect(xhr.response.body).to.deep.equal({ status: 'success' });
}
);
});
}
function removeInputs() {
cy.get('.m-draggableList__list .m-proSettings__field .m-proSettings__flexInputs').should('be.visible').within($el => {
for (let i = $el.length - 1; i >= 0; i--) { // flexInput. Start from the last one
let c = $el[i];
for (let j = 0; j < c.children.length; j++) { // inputs and the X button
let cc = c.children[j];
if (cc.nodeName === 'I') { // if it's the X button, click on it
cy.wrap(cc).click();
cy.get(
'.m-draggableList__list .m-proSettings__field .m-proSettings__dragDropRow--input'
)
.should('be.visible')
.within($el => {
for (let i = $el.length - 1; i >= 0; i--) {
// flexInput. Start from the last one
let c = $el[i];
for (let j = 0; j < c.children.length; j++) {
// inputs and the X button
let cc = c.children[j];
if (cc.nodeName === 'I') {
// if it's the X button, click on it
cy.wrap(cc).click();
}
}
}
}
});
});
}
function addTag(label, tag, index) {
cy.contains('+ Add Tag')
.click();
cy.contains('+ Add Tag').click();
cy.get(`#tag-label-${index}`)
.clear()
......@@ -89,8 +100,7 @@ context('Pro Page', () => {
}
function addFooterLink(label, link, index) {
cy.contains('Add Link')
.click();
cy.contains('Add Link').click();
cy.get(`#footer_link-title-${index}`)
.clear()
......@@ -116,42 +126,51 @@ context('Pro Page', () => {
});
it('should load the feed tab', () => {
cy.route("GET", "**/api/v2/pro/content/*/activities/top**").as("activities");
cy.route('GET', '**/api/v2/pro/content/*/activities/top**').as(
'activities'
);
cy.contains('Feed')
.click()
.wait('@activities').then((xhr) => {
expect(xhr.status).to.equal(200);
});
})
.wait('@activities')
.then(xhr => {
expect(xhr.status).to.equal(200);
});
});
it('should load the videos tab', () => {
cy.route("GET", "**/api/v2/pro/content/*/videos/top**").as("videos");
cy.route('GET', '**/api/v2/pro/content/*/videos/top**').as('videos');
cy.contains('Videos')
.click()
.wait('@videos').then((xhr) => {
expect(xhr.status).to.equal(200);
});
})
.wait('@videos')
.then(xhr => {
expect(xhr.status).to.equal(200);
});
});
it('should load the images tab', () => {
cy.route("GET", "**/api/v2/pro/content/*/images/top**").as("images");
cy.route('GET', '**/api/v2/pro/content/*/images/top**').as('images');
cy.contains('Images')
.click()
.wait('@images').then((xhr) => {
expect(xhr.status).to.equal(200);
});
.wait('@images')
.then(xhr => {
expect(xhr.status).to.equal(200);
});
// should have sub-categories
cy.get('m-pro--channel--categories > .m-proChannel__category').each(($el, $index) => {
let c = categories.slice(0);
c.unshift({ label: 'All', tag: '#all' });
expect($el.text()).to.contain(c[$index].label);
});
cy.get('m-pro--channel--categories > .m-proChannel__category').each(
($el, $index) => {
let c = categories.slice(0);
c.unshift({ label: 'All', tag: '#all' });
expect($el.text()).to.contain(c[$index].label);
}
);
cy.get('m-pro--channel .m-overlay-modal').should('not.be.visible');
// click on tile
cy.get('.m-proChannelListContent__list li:first-child m-pro--channel-tile').click();
cy.get(
'.m-proChannelListContent__list li:first-child m-pro--channel-tile'
).click();
cy.wait(200);
// media modal should appear
......@@ -159,35 +178,41 @@ context('Pro Page', () => {
// close media modal
cy.get('m-pro--channel .m-overlay-modal--close').click();
})
});
it('should load the articles tab', () => {
cy.route("GET", "**/api/v2/pro/content/*/blogs/top**").as("blogs");
cy.route('GET', '**/api/v2/pro/content/*/blogs/top**').as('blogs');
cy.contains('Articles')
.click()
.wait('@blogs').then((xhr) => {
expect(xhr.status).to.equal(200);
});
})
.wait('@blogs')
.then(xhr => {
expect(xhr.status).to.equal(200);
});
});
it('should load the groups tab', () => {
cy.route("GET", "**/api/v2/pro/content/*/groups/top**").as("groups");
cy.route('GET', '**/api/v2/pro/content/*/groups/top**').as('groups');
cy.contains('Groups')
.click()
.wait('@groups').then((xhr) => {
expect(xhr.status).to.equal(200);
});
})
.wait('@groups')
.then(xhr => {
expect(xhr.status).to.equal(200);
});
});
it('should have a footer', () => {
// should have a footer text
cy.get('.m-proChannelFooter__text').contains('This is the footer text');
// should have footer links
cy.get('.m-proChannel__footer .m-proChannelFooter .m-proChannelFooter__link').should('be.visible').each(($el, $index) => {
expect($el.text()).to.contain(footerLinks[$index].label);
expect($el.attr('href')).to.contain(footerLinks[$index].link);
});
})
cy.get(
'.m-proChannel__footer .m-proChannelFooter .m-proChannelFooter__link'
)
.should('be.visible')
.each(($el, $index) => {
expect($el.text()).to.contain(footerLinks[$index].label);
expect($el.attr('href')).to.contain(footerLinks[$index].link);
});
});
}
})
});
......@@ -3,30 +3,31 @@
* @desc E2E testing for Minds Pro's settings.
*/
context('Pro Settings', () => {
if (Cypress.env().pro_password) { // required to run tests against pro user only.
if (Cypress.env().pro_password) {
// required to run tests against pro user only.
const title = '#title';
const headline = '#headline';
const previewButton = '.m-proSettings__previewBtn';
const activityContainer = 'minds-activity';
const footerText = '#footer_text';
const theme = {
primaryColor: '#primary_color',
plainBackgroundColor: '#plain_background_color',
textColor: '#textColor',
primaryColor: '#primaryColor',
plainBackgroundColor: '#plainBgColor',
schemeLight: '#scheme_light',
schemeDark: '#scheme_dark',
aspectRatio: {
169: '#tile_ratio_16\:9', // 16:9
1610: '#tile_ratio_16\:10', // 16:10
43: '#tile_ratio_4\:3', // 4:3
11: '#tile_ratio_1\:1' , // 1:1
169: '#tile_ratio_16:9', // 16:9
1610: '#tile_ratio_16:10', // 16:10
43: '#tile_ratio_4:3', // 4:3
11: '#tile_ratio_1:1', // 1:1
},
}
};
const hashtags = {
labelInput0: '#tag-label-0',
labelInput0: '#tag-label-0',
hashtagInput0: '#tag-tag-0',
labelInput1: '#tag-label-1',
labelInput1: '#tag-label-1',
hashtagInput1: '#tag-tag-1',
label1: 'label1',
label2: 'label2',
......@@ -34,40 +35,47 @@ context('Pro Settings', () => {
hashtag1: '#hashtag1',
hashtag2: '#hashtag2',
hashtag3: '#hashtag3',
}
};
const footer = {
hrefInput: `#footer_link-href-0`,
titleInput: `#footer_link-title-0`,
}
};
const strings = {
title: "Minds Pro E2E",
headline: "This headline is a test",
footer: "This is a footer",
footerTitle: "Minds",
title: 'Minds Pro E2E',
headline: 'This headline is a test',
footer: 'This is a footer',
footerTitle: 'Minds',
footerHref: 'https://www.minds.com/',
}
};
before(() => {
cy.login(true, Cypress.env().pro_username, Cypress.env().pro_password);
});
after(() => {
cy.visit("/pro/settings")
// cy.visit(`/${Cypress.env().username}`);
cy.visit('/pro/' + Cypress.env().pro_username + '/settings/hashtags')
.location('pathname')
.should('eq', '/pro/settings');
.should(
'eq',
'/pro/' + Cypress.env().pro_username + '/settings/hashtags'
);
clearHashtags();
});
beforeEach(()=> {
beforeEach(() => {
cy.preserveCookies();
cy.server();
cy.route("POST", "**/api/v2/pro/settings").as("settings");
cy.route('POST', '**/api/v2/pro/settings').as('settings');
cy.visit("/pro/settings")
cy.visit('/pro/' + Cypress.env().pro_username + '/settings/general')
.location('pathname')
.should('eq', '/pro/settings');
.should(
'eq',
'/pro/' + Cypress.env().pro_username + '/settings/general'
);
});
it('should update the title and headline', () => {
......@@ -84,40 +92,36 @@ context('Pro Settings', () => {
saveAndPreview();
//check tab title.
cy.title()
.should('eq', strings.title+' - '+strings.headline+" | Minds");
cy.title().should(
'eq',
strings.title + ' - ' + strings.headline + ' | Minds'
);
});
// Need to find a way around the color input in Cypress.
it('should allow the user to set a dark theme for posts', () => {
cy.contains('Theme')
.click();
cy.contains('Theme').click();
cy.get(theme.schemeDark)
.click();
cy.get(theme.schemeDark).click();
saveAndPreview();
cy.contains('Feed')
.click();
cy.contains('Feed').click();
cy.get(activityContainer)
.should('have.css', 'background-color')
.and('eq', 'rgb(35, 35, 35)');
.should('have.css', 'background-color')
.and('eq', 'rgb(35, 35, 35)');
});
it('should allow the user to set a light theme for posts', () => {
cy.contains('Theme')
.click();
cy.contains('Theme').click();
cy.get(theme.schemeLight)
.click();
cy.get(theme.schemeLight).click();
saveAndPreview();
cy.contains('Feed')
.click();
cy.contains('Feed').click();
cy.get(activityContainer)
.should('have.css', 'background-color')
......@@ -125,12 +129,10 @@ context('Pro Settings', () => {
});
it('should allow the user to set category hashtags', () => {
cy.contains('Hashtags')
.click();
cy.contains('Hashtags').click();
cy.contains('Add').click();
cy.contains('+ Add Tag')
.click();
cy.get(hashtags.labelInput0)
.clear()
.type(hashtags.label1);
......@@ -138,10 +140,9 @@ context('Pro Settings', () => {
cy.get(hashtags.hashtagInput0)
.clear()
.type(hashtags.hashtag1);
cy.contains('+ Add Tag')
.click();
cy.contains('Add').click();
cy.get(hashtags.labelInput1)
.first()
.clear()
......@@ -151,7 +152,7 @@ context('Pro Settings', () => {
.first()
.clear()
.type(hashtags.hashtag2);
saveAndPreview();
//check the labels are present and clickable.
......@@ -160,20 +161,18 @@ context('Pro Settings', () => {
});
it('should allow the user to set footer', () => {
cy.contains('Footer')
.click();
cy.contains('Footer').click();
cy.get(footerText)
.clear()
.type(strings.footer);
cy.contains('Add Link')
.click();
cy.contains('Add Link').click();
cy.get(footer.hrefInput)
.clear()
.type(strings.footerHref);
cy.get(footer.titleInput)
.clear()
.type(strings.footerTitle);
......@@ -189,50 +188,45 @@ context('Pro Settings', () => {
function saveAndPreview() {
//save and await response
cy.contains('Save')
.click()
.wait('@settings').then((xhr) => {
.click()
.wait('@settings')
.then(xhr => {
expect(xhr.status).to.equal(200);
expect(xhr.response.body).to.deep.equal({ status: 'success' });
});
//go to pro page
cy.get(previewButton)
.click();
cy.contains('View Pro Channel').click();
}
function clearHashtags() {
cy.contains('Hashtags')
.click();
cy.contains('+ Add Tag')
.click();
cy.contains('clear')
.click({multiple: true});
saveAndPreview();
cy.contains('Hashtags').click();
cy.contains('Add').click();
cy.contains('clear').click({ multiple: true });
saveAndPreview();
}
//
//
// it.only('should update the theme', () => {
// // nav to theme tab
// cy.contains('Theme')
// .click();
// cy.get(theme.plainBackgroundColor).then(elem => {
// elem.val('#00dd00');
// //save and await response
// cy.contains('Save')
// .click()
// .click()
// .wait('@settings').then((xhr) => {
// expect(xhr.status).to.equal(200);
// expect(xhr.response.body).to.deep.equal({ status: 'success' });
// });
// //go to pro page
// cy.get(previewButton)
// .click();
// });
// cy.contains('View Pro Channel').click();
// })
}
})
});
This diff is collapsed.
<ng-container *ngIf="!isProDomain">
<m-v2-topbar *mIfFeature="'top-feeds'; else legacyTopbar">
<ng-container search>
<m-search--bar [defaultSizes]="false"></m-search--bar>
</ng-container>
<ng-container icons>
<m-notifications--topbar-toggle
*ngIf="session.isLoggedIn()"
></m-notifications--topbar-toggle>
</ng-container>
</m-v2-topbar>
<ng-template #legacyTopbar>
<m-topbar class="m-noshadow">
<ng-container *ngIf="ready">
<ng-container *ngIf="!isProDomain">
<m-v2-topbar *mIfFeature="'top-feeds'; else legacyTopbar">
<ng-container search>
<m-search--bar></m-search--bar>
<m-search--bar [defaultSizes]="false"></m-search--bar>
</ng-container>
<ng-container icons>
<m-notifications--topbar-toggle></m-notifications--topbar-toggle>
<m-wallet--topbar-toggle></m-wallet--topbar-toggle>
<m-notifications--topbar-toggle
*ngIf="session.isLoggedIn()"
></m-notifications--topbar-toggle>
</ng-container>
</m-topbar>
</ng-template>
</m-v2-topbar>
<ng-template #legacyTopbar>
<m-topbar class="m-noshadow">
<ng-container search>
<m-search--bar></m-search--bar>
</ng-container>
<m-sidebar--markers
<ng-container icons>
<m-notifications--topbar-toggle></m-notifications--topbar-toggle>
<m-wallet--topbar-toggle></m-wallet--topbar-toggle>
</ng-container>
</m-topbar>
</ng-template>
<m-sidebar--markers
[class.has-v2-navbar]="featuresService.has('top-feeds')"
></m-sidebar--markers>
</ng-container>
<m-body
[class.has-v2-navbar]="featuresService.has('top-feeds')"
></m-sidebar--markers>
</ng-container>
[class.is-pro-domain]="isProDomain"
>
<m-announcement [id]="'blockchain:sale'" *ngIf="false">
<span
class="m-blockchain--wallet-address-notice--action"
routerLink="/tokens"
i18n="@@BLOCKCHAIN__SALE__NOTICE"
>
The MINDS token is now live. Learn more here.
</span>
</m-announcement>
<m-blockchain--wallet-address-notice></m-blockchain--wallet-address-notice>
<router-outlet></router-outlet>
</m-body>
<m-messenger *ngIf="minds.LoggedIn && !isProDomain"></m-messenger>
<m-hovercard-popup></m-hovercard-popup>
<m-body
[class.has-v2-navbar]="featuresService.has('top-feeds')"
[class.is-pro-domain]="isProDomain"
>
<m-announcement [id]="'blockchain:sale'" *ngIf="false">
<span
class="m-blockchain--wallet-address-notice--action"
routerLink="/tokens"
i18n="@@BLOCKCHAIN__SALE__NOTICE"
>
The MINDS token is now live. Learn more here.
</span>
</m-announcement>
<m-blockchain--wallet-address-notice></m-blockchain--wallet-address-notice>
<router-outlet></router-outlet>
</m-body>
<m-messenger *ngIf="minds.LoggedIn && !isProDomain"></m-messenger>
<m-hovercard-popup></m-hovercard-popup>
<m-overlay-modal></m-overlay-modal>
<m--blockchain--transaction-overlay></m--blockchain--transaction-overlay>
<m-modal--tos-updated *ngIf="session.isLoggedIn()"></m-modal--tos-updated>
<m-juryDutySession__summons
*ngIf="session.isLoggedIn() && !isProDomain"
></m-juryDutySession__summons>
<m-modal-signup-on-scroll *ngIf="!isProDomain"></m-modal-signup-on-scroll>
<m-channel--onboarding *ngIf="showOnboarding"></m-channel--onboarding>
<m-cookies-notice *ngIf="!session.isLoggedIn()"> </m-cookies-notice>
<m-overlay-modal></m-overlay-modal>
<m--blockchain--transaction-overlay></m--blockchain--transaction-overlay>
<m-modal--tos-updated *ngIf="session.isLoggedIn()"></m-modal--tos-updated>
<m-juryDutySession__summons
*ngIf="session.isLoggedIn() && !isProDomain"
></m-juryDutySession__summons>
<m-modal-signup-on-scroll *ngIf="!isProDomain"></m-modal-signup-on-scroll>
<m-channel--onboarding *ngIf="showOnboarding"></m-channel--onboarding>
<m-cookies-notice *ngIf="!session.isLoggedIn()"> </m-cookies-notice>
</ng-container>
import { Component, HostBinding } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding } from '@angular/core';
import { NotificationService } from './modules/notifications/notification.service';
import { AnalyticsService } from './services/analytics';
......@@ -18,6 +18,7 @@ import { ThemeService } from './common/services/theme.service';
import { BannedService } from './modules/report/banned/banned.service';
import { DiagnosticsService } from './services/diagnostics.service';
import { SiteService } from './common/services/site.service';
import { SsoService } from './common/services/sso.service';
import { Subscription } from 'rxjs';
import { RouterHistoryService } from './common/services/router-history.service';
import { PRO_DOMAIN_ROUTES } from './modules/pro/pro.module';
......@@ -29,8 +30,11 @@ import { PRO_DOMAIN_ROUTES } from './modules/pro/pro.module';
})
export class Minds {
name: string;
minds = window.Minds;
ready: boolean = false;
showOnboarding: boolean = false;
showTOSModal: boolean = false;
......@@ -57,7 +61,9 @@ export class Minds {
private bannedService: BannedService,
private diagnostics: DiagnosticsService,
private routerHistoryService: RouterHistoryService,
private site: SiteService
private site: SiteService,
private sso: SsoService,
private cd: ChangeDetectorRef
) {
this.name = 'Minds';
......@@ -67,8 +73,29 @@ export class Minds {
}
async ngOnInit() {
this.diagnostics.setUser(this.minds.user);
this.diagnostics.listen(); // Listen for user changes
try {
this.diagnostics.setUser(this.minds.user);
this.diagnostics.listen(); // Listen for user changes
if (this.sso.isRequired()) {
await this.sso.connect();
}
} catch (e) {
console.error('ngOnInit()', e);
}
this.ready = true;
this.detectChanges();
try {
await this.initialize();
} catch (e) {
console.error('initialize()', e);
}
}
async initialize() {
this.blockListService.fetch();
if (!this.site.isProDomain) {
this.notificationService.getNotifications();
......@@ -136,4 +163,9 @@ export class Minds {
get isProDomain() {
return this.site.isProDomain;
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
}
import { Cookie } from '../../services/cookie';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Location } from '@angular/common';
import { SiteService } from '../services/site.service';
/**
* API Class
*/
export class MindsHttpClient {
base: string = '/';
origin: string = '';
cookie: Cookie = new Cookie();
static _(http: HttpClient, site: SiteService) {
return new MindsHttpClient(http, site);
static _(http: HttpClient) {
return new MindsHttpClient(http);
}
constructor(public http: HttpClient, protected site: SiteService) {
if (this.site.isProDomain) {
this.base = window.Minds.site_url;
this.origin = document.location.host;
}
}
constructor(public http: HttpClient) {}
/**
* Return a GET request
......@@ -81,22 +73,11 @@ export class MindsHttpClient {
'X-VERSION': environment.version,
};
if (this.origin) {
const PRO_XSRF_JWT = this.cookie.get('PRO-XSRF-JWT') || '';
headers['X-MINDS-ORIGIN'] = this.origin;
headers['X-PRO-XSRF-JWT'] = PRO_XSRF_JWT;
}
const builtOptions = {
headers: new HttpHeaders(headers),
cache: true,
};
if (this.origin) {
builtOptions['withCredentials'] = true;
}
return Object.assign(options, builtOptions);
}
}
......
......@@ -115,7 +115,19 @@ import { ToggleComponent } from './components/toggle/toggle.component';
import { MarketingAsFeaturedInComponent } from './components/marketing/as-featured-in.component';
import { SidebarMenuComponent } from './components/sidebar-menu/sidebar-menu.component';
import { ChartV2Component } from './components/chart-v2/chart-v2.component';
import { MiniChartComponent } from './components/mini-chart/mini-chart.component';
import * as PlotlyJS from 'plotly.js/dist/plotly.js';
import { PlotlyModule } from 'angular-plotly.js';
import { PageLayoutComponent } from './components/page-layout/page-layout.component';
import { DashboardLayoutComponent } from './components/dashboard-layout/dashboard-layout.component';
import { ShadowboxLayoutComponent } from './components/shadowbox-layout/shadowbox-layout.component';
import { ShadowboxHeaderComponent } from './components/shadowbox-header/shadowbox-header.component';
import { DropdownSelectorComponent } from './components/dropdown-selector/dropdown-selector.component';
import { ShadowboxSubmitButtonComponent } from './components/shadowbox-submit-button/shadowbox-submit-button.component';
import { FormDescriptorComponent } from './components/form-descriptor/form-descriptor.component';
import { FormToastComponent } from './components/form-toast/form-toast.component';
import { SsoService } from './services/sso.service';
PlotlyModule.plotlyjs = PlotlyJS;
@NgModule({
imports: [
......@@ -124,6 +136,7 @@ import { MiniChartComponent } from './components/mini-chart/mini-chart.component
RouterModule,
FormsModule,
ReactiveFormsModule,
PlotlyModule,
],
declarations: [
MINDS_PIPES,
......@@ -220,7 +233,14 @@ import { MiniChartComponent } from './components/mini-chart/mini-chart.component
MarketingAsFeaturedInComponent,
SidebarMenuComponent,
ChartV2Component,
MiniChartComponent,
PageLayoutComponent,
DashboardLayoutComponent,
ShadowboxLayoutComponent,
ShadowboxHeaderComponent,
DropdownSelectorComponent,
FormDescriptorComponent,
FormToastComponent,
ShadowboxSubmitButtonComponent,
],
exports: [
MINDS_PIPES,
......@@ -312,9 +332,18 @@ import { MiniChartComponent } from './components/mini-chart/mini-chart.component
MarketingComponent,
MarketingAsFeaturedInComponent,
SidebarMenuComponent,
ChartV2Component,
PageLayoutComponent,
DashboardLayoutComponent,
ShadowboxLayoutComponent,
DropdownSelectorComponent,
FormDescriptorComponent,
FormToastComponent,
ShadowboxSubmitButtonComponent,
],
providers: [
SiteService,
SsoService,
{
provide: AttachmentService,
useFactory: AttachmentService._,
......@@ -330,7 +359,7 @@ import { MiniChartComponent } from './components/mini-chart/mini-chart.component
{
provide: MindsHttpClient,
useFactory: MindsHttpClient._,
deps: [HttpClient, SiteService],
deps: [HttpClient],
},
{
provide: NSFWSelectorCreatorService,
......
export * from './text-input-autocomplete.module';
export {
TextInputAutocompleteMenuComponent,
} from './text-input-autocomplete-menu.component';
export { TextInputAutocompleteMenuComponent } from './text-input-autocomplete-menu.component';
import { Component, EventEmitter } from '@angular/core';
import { Client } from '../../../services/api';
import { UserAvatarService } from '../../services/user-avatar.service';
import { of, Observable } from 'rxjs';
@Component({
selector: 'minds-avatar',
......@@ -14,17 +14,28 @@ import { Client } from '../../../services/api';
],
outputs: ['added'],
template: `
<div class="minds-avatar" [style.background-image]="'url(' + src + ')'">
<div
class="minds-avatar"
[ngStyle]="{ 'background-image': 'url(' + (getSrc() | async) + ')' }"
>
<img
*ngIf="!src"
*ngIf="!(userAvatarService.src$ | async)"
src="{{ minds.cdn_assets_url }}assets/avatars/blue/default-large.png"
class="mdl-shadow--4dp"
/>
<div *ngIf="editing" class="overlay">
<i class="material-icons">{{ icon }}</i>
<ng-container *ngIf="showPrompt">
<span *ngIf="src" i18n="@@COMMON__AVATAR__CHANGE">Change avatar</span>
<span *ngIf="!src" i18n="@@COMMON__AVATAR__ADD">Add an avatar</span>
<span
*ngIf="userAvatarService.src$ | async"
i18n="@@COMMON__AVATAR__CHANGE"
>Change avatar</span
>
<span
*ngIf="!(userAvatarService.src$ | async)"
i18n="@@COMMON__AVATAR__ADD"
>Add an avatar</span
>
</ng-container>
</div>
<input *ngIf="editing" type="file" #file (change)="add($event)" />
......@@ -40,18 +51,21 @@ export class MindsAvatar {
index: number = 0;
icon: string = 'camera';
showPrompt: boolean = true;
file: any;
added: EventEmitter<any> = new EventEmitter();
constructor(public userAvatarService: UserAvatarService) {}
set _object(value: any) {
if (!value) return;
value.icontime = value.icontime ? value.icontime : '';
this.object = value;
this.src = `${this.minds.cdn_url}fs/v1/avatars/${this.object.guid}/large/${this.object.icontime}`;
if (this.object.type === 'user')
if (this.object.type !== 'user') {
this.src = `${this.minds.cdn_url}fs/v1/avatars/${this.object.guid}/large/${this.object.icontime}`;
} else if (!this.minds.user || this.object.guid !== this.minds.user.guid) {
this.src = `${this.minds.cdn_url}icon/${this.object.guid}/large/${this.object.icontime}`;
}
}
set _src(value: any) {
......@@ -63,6 +77,10 @@ export class MindsAvatar {
if (!this.editing && this.file) this.done();
}
/**
* New avatar added.
* @param e - the element.
*/
add(e) {
if (!this.editing) return;
......@@ -78,6 +96,9 @@ export class MindsAvatar {
typeof reader.result === 'string'
? reader.result
: reader.result.toString();
if (this.object.type === 'user' && this.isOwnerAvatar()) {
this.userAvatarService.src$.next(this.src);
}
};
reader.readAsDataURL(this.file);
......@@ -87,9 +108,28 @@ export class MindsAvatar {
if (this.waitForDoneSignal !== true) this.done();
}
/**
* Called upon being done.
*/
done() {
console.log('sending done');
this.added.next(this.file);
this.file = null;
}
/**
* Gets the src of the image
* @returns { Observables<string> } the src for the image.
*/
getSrc(): Observable<string> {
return this.isOwnerAvatar() ? this.userAvatarService.src$ : of(this.src);
}
/**
* Determined whether this is a users avatar.
* @returns true if the object guid matches the currently logged in user guid
*/
isOwnerAvatar(): boolean {
return this.minds.user && this.object.guid === this.minds.user.guid;
}
}
......@@ -43,8 +43,8 @@ export class ChannelBadgesComponent {
return true;
} else if (
!this.user.is_admin &&
(this.session.isAdmin() &&
this.user.guid !== this.session.getLoggedInUser().guid)
this.session.isAdmin() &&
this.user.guid !== this.session.getLoggedInUser().guid
) {
return true;
}
......
<!-- <div
<div
#chartContainer
class="m-chartV2__chartContainer"
[ngClass]="{ isTouchDevice: isTouchDevice }"
[ngClass]="{ isTouchDevice: isTouchDevice, isMini: isMini }"
>
<plotly-plot
*ngIf="init"
#graphDiv
id="graphDiv"
[data]="data"
[layout]="layout"
[config]="config"
......@@ -14,48 +12,70 @@
[style]="{ position: 'relative' }"
(hover)="onHover($event)"
(unhover)="onUnhover($event)"
(plotly_click)="onClick($event)"
id="graphDiv"
>
</plotly-plot>
</div>
<div #hoverInfoDiv id="hoverInfoDiv" class="m-chartV2__hoverInfoDiv">
<i *ngIf="isTouchDevice" class="material-icons" (click)="onUnhover($event)"
<div #hoverInfoDiv class="m-chartV2__hoverInfoDiv">
<i
*ngIf="isTouchDevice"
class="material-icons m-chartV2__hoverInfo__closeBtn"
(click)="onUnhover($event)"
>close</i
>
<div class="m-chartV2__hoverInfo__row">
{{ hoverInfo.date | utcDate | date: datePipe }}
</div>
<div
[ngSwitch]="selectedMetric?.unit"
class="m-chartV2__hoverInfo__row--primary"
>
<ng-template ngSwitchCase="number">
{{ hoverInfo.value | number }} {{ selectedMetric.label | lowercase }}
</ng-template>
<ng-template ngSwitchCase="usd">
{{ hoverInfo.value | currency }} USD
</ng-template>
<ng-template ngSwitchDefault>
{{ hoverInfo.value | number: '1.1-3' }} {{ selectedMetric?.unit }}
</ng-template>
</div>
<div class="m-chartV2__hoverInfo__row" *ngIf="isComparison">
vs
<ng-container
[ngSwitch]="selectedMetric?.unit"
class="m-chartV2__hoverInfo__row"
>
<ng-template ngSwitchCase="number">
{{ hoverInfo.comparisonValue | number }}
</ng-template>
<ng-template ngSwitchCase="usd">
{{ hoverInfo.comparisonValue | currency }}
</ng-template>
<ng-template ngSwitchDefault>
{{ hoverInfo.comparisonValue | number: '1.1-3' }}
</ng-template>
</ng-container>
on {{ hoverInfo.comparisonDate | utcDate | date: datePipe }}
<div class="m-chartV2__hoverInfo__wrapper">
<div class="m-chartV2__hoverInfo__arrowContainer" *ngIf="isMini">
<i class="material-icons">arrow_upward</i>
</div>
<div class="m-chartV2__hoverInfo__rowsContainer">
<div class="m-chartV2__hoverInfo__row">
{{ hoverInfo.date | utcDate | date: datePipe }}
</div>
<div
[ngSwitch]="rawData?.unit"
class="m-chartV2__hoverInfo__row--primary"
>
<ng-template ngSwitchCase="number">
{{ hoverInfo.value | number: '1.0-0' | abbr }}
{{ rawData.label | lowercase }}
</ng-template>
<ng-template ngSwitchCase="usd">
{{ hoverInfo.value | currency }} USD
</ng-template>
<ng-template ngSwitchCase="eth">
{{ hoverInfo.value | number: '1.3-3' }} ETH
</ng-template>
<ng-template ngSwitchCase="tokens">
{{ hoverInfo.value | number: '1.1-3' }} Tokens
</ng-template>
<ng-template ngSwitchDefault>
{{ hoverInfo.value | number: '1.0-3' }} {{ rawData?.unit }}
</ng-template>
</div>
<div class="m-chartV2__hoverInfo__row" *ngIf="isComparison">
vs
<ng-container
[ngSwitch]="rawData?.unit"
class="m-chartV2__hoverInfo__row"
>
<ng-template ngSwitchCase="number">
{{ hoverInfo.comparisonValue | number: '1.0-0' | abbr }}
</ng-template>
<ng-template ngSwitchCase="usd">
{{ hoverInfo.comparisonValue | currency }}
</ng-template>
<ng-template ngSwitchCase="eth">
{{ hoverInfo.value | number: '1.3-3' }} ETH
</ng-template>
<ng-template ngSwitchCase="tokens">
{{ hoverInfo.value | number: '1.1-3' }} Tokens
</ng-template>
<ng-template ngSwitchDefault>
{{ hoverInfo.comparisonValue | number: '1.1-3' }}
</ng-template>
</ng-container>
on {{ hoverInfo.comparisonDate | utcDate | date: datePipe }}
</div>
</div>
</div>
</div> -->
</div>
m-chartV2 {
display: block;
position: relative;
margin-left: 40px;
}
.js-plotly-plot,
.plot-container {
height: 44vh;
min-height: 44vh;
display: block;
}
#graphDiv {
display: block;
position: relative;
g,
g > * {
cursor: default;
}
> * {
transition: background-color 0.3s cubic-bezier(0.23, 1, 0.32, 1),
color 0.3s cubic-bezier(0.23, 1, 0.32, 1);
}
.main-svg {
max-width: 100%;
}
}
.m-chartV2__hoverInfoDiv {
width: 160px;
padding: 12px;
position: absolute;
pointer-events: none;
border-radius: 3px;
font-size: 12px;
z-index: 9999999999;
opacity: 0;
transition: opacity 0.2s ease-in;
@include m-theme() {
background-color: themed($m-white);
box-shadow: 0 0 4px rgba(themed($m-black), 0.3);
color: themed($m-grey-300);
}
[class*='m-chartV2__hoverInfo__row'] {
padding-bottom: 4px;
font-weight: 300;
&:last-of-type {
padding-top: 2px;
}
}
.m-chartV2__hoverInfo__row--primary {
font-weight: 400;
font-size: 15px;
@include m-theme() {
color: themed($m-grey-600);
}
}
.m-chartV2__hoverInfo__closeBtn {
display: none;
font-size: 15px;
position: absolute;
cursor: pointer;
top: 10px;
right: 10px;
transition: color 0.3s cubic-bezier(0.23, 1, 0.32, 1);
@include m-theme() {
color: themed($m-grey-300);
}
&:active {
@include m-theme() {
color: themed($m-grey-500);
}
}
}
}
// ----------------------------------------------------
.isTouchDevice .m-chartV2__hoverInfoDiv .m-chartV2__hoverInfo__closeBtn {
display: block;
}
@media screen and (max-width: $min-tablet) {
m-chartV2 {
margin-left: 16px;
}
}
// ----------------------------------------------------
m-chartV2.isMini {
margin-left: 0;
margin-top: 24px;
.js-plotly-plot,
.plot-container {
height: 40px;
min-height: 40px;
}
.m-chartV2__chartContainer {
// margin-right: 24px;
}
.m-chartV2__hoverInfoDiv {
width: 150px;
padding: 0px;
.m-chartV2__hoverInfo__wrapper {
display: flex;
}
.m-chartV2__hoverInfo__rowsContainer {
display: flex;
flex-direction: column;
padding: 14px 14px 14px 0;
}
.m-chartV2__hoverInfo__arrowContainer {
width: 20px;
i {
margin-left: -4px;
transform: rotate(-45deg) scaleX(0.5);
@include m-theme() {
color: themed($m-grey-600);
}
}
}
[class*='m-chartV2__hoverInfo__row'] {
line-height: 1.1;
}
.m-chartV2__hoverInfo__row--primary {
font-size: 12px;
}
}
@media screen and (max-width: $min-tablet) {
margin-left: 0;
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { UtcDatePipe } from '../../pipes/utcdate';
import { AbbrPipe } from '../../pipes/abbr';
import { MockService } from '../../../utils/mock';
import { ThemeService } from '../../services/theme.service';
import { ChartV2Component } from './chart-v2.component';
describe('ChartV2Component', () => {
......@@ -8,14 +12,46 @@ describe('ChartV2Component', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ChartV2Component],
declarations: [ChartV2Component, UtcDatePipe, AbbrPipe],
providers: [
{
provide: ThemeService,
useValue: MockService(ThemeService),
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ChartV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
component.rawData = {
id: 'views',
label: 'Pageviews',
permissions: ['admin', 'user'],
unit: 'usd',
description: '',
visualisation: {
type: 'chart',
segments: [
{
buckets: [
{
key: 1567296000000,
date: '2019-09-01T00:00:00+00:00',
value: 11,
},
{
key: 1567382400000,
date: '2019-09-02T00:00:00+00:00',
value: 12,
},
],
},
],
},
};
});
it('should create', () => {
......
<div class="m-dashboardLayout__header">
<ng-content select="[m-dashboardLayout__header]"></ng-content>
</div>
<div class="m-dashboardLayout__body">
<ng-content select="[m-dashboardLayout__body]"></ng-content>
</div>
m-dashboardLayout {
display: block;
width: 100%;
max-width: 100%;
}
.m-dashboardLayout__header {
h3 {
font-size: 26px;
font-weight: 500;
margin-top: 0;
margin-right: 24px;
}
}
.m-dashboardLayout__body {
position: relative;
display: block;
width: 100%;
a {
font-weight: 400;
text-decoration: none;
@include m-theme() {
color: themed($m-blue);
}
}
}
@media screen and (max-width: $min-tablet) {
m-dashboardLayout {
display: block;
padding: 0;
max-width: none;
width: 100%;
}
.m-dashboardLayout__header {
padding-left: 24px;
}
}
@media screen and (max-width: $max-mobile) {
}
import { Component } from '@angular/core';
@Component({
selector: 'm-dashboardLayout',
templateUrl: './dashboard-layout.component.html',
})
export class DashboardLayoutComponent {
constructor() {}
}
<div
class="m-draggableList__listItem m-draggableList__listHeader"
*ngIf="headers"
(click)="clickedHeaderRow($event)"
>
<ng-container *ngFor="let header of headers">
<div class="m-draggableList__cell">{{ header | titlecase }}</div>
</ng-container>
<div class="m-draggableList__cell"></div>
</div>
<ul
dndDropzone
[dndHorizontal]="false"
[dndEffectAllowed]="dndEffectAllowed"
(dndStart)="dragging = true"
(dndDrop)="onDrop($event)"
class="m-draggableList__list"
[ngClass]="{ dragging: dragging }"
>
<div class="dndPlaceholder" dndPlaceholderRef></div>
<li
*ngFor="let item of data; let i = index; trackBy: trackByFunction"
[dndDraggable]="item"
[dndEffectAllowed]="'move'"
[dndDragImageOffsetFunction]="dragImageOffsetRight"
[dndDisableIf]="disabled"
class="m-draggableList__listItem"
>
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ item: item, i: i }"
></ng-container>
<div class="m-draggableList__cell">
<i class="handle material-icons" dndHandle>open_with</i>
<i class="material-icons" (click)="removeItem(i)">
clear
</i>
</div>
</li>
</ul>
@import 'themes';
m-draggable-list {
m-draggableList {
width: 100%;
@include m-theme() {
box-shadow: 0 1px 4px 0 rgba(themed($m-black), 0.1);
}
ul.m-draggableList__list {
width: 100%;
list-style: none;
padding: 0;
padding-inline-start: 0;
margin: 0;
display: flex;
flex-direction: column;
transition: all ease 300ms;
&.dndDragover {
padding-top: 16px;
padding-bottom: 16px;
// padding-top: 16px;
// padding-bottom: 16px;
@include m-theme() {
background-color: rgba(themed($m-black), 0.05);
box-shadow: 0 1px 4px 0 rgba(themed($m-black), 0.1);
}
}
&.dragging {
li.m-draggableList__listItem {
&:first-child {
@include m-theme() {
border-top: 1px solid themed($m-grey-50);
}
}
}
}
}
li.m-draggableList__listItem {
padding: 8px;
border: 1px solid #ddd;
.m-draggableList__listItem {
display: flex;
align-items: center;
list-style-type: none;
padding: 0;
margin: 0;
@include m-theme() {
border: 1px solid themed($m-grey-50);
color: themed($m-grey-800);
}
// &:first-child {
&:not(.m-draggableList__listHeader) {
@include m-theme() {
border-top: none;
}
}
// }
&.m-draggableList__listHeader {
@include m-theme() {
// border-bottom: none;
color: themed($m-grey-300);
}
}
}
input.m-draggableList__cell {
width: 0;
min-width: 0;
}
.m-draggableList__cell {
padding: 10px 20px;
flex: 1 1 0px;
box-sizing: border-box;
@include m-theme() {
border: none;
border-right: 1px solid themed($m-grey-50);
background-color: themed($m-white);
}
&input {
width: 0;
min-width: 0;
}
&:last-child {
//icon cell
padding: 10px 15px;
flex: 0 0 80px;
max-width: 80px;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
.handle {
@include m-theme() {
color: themed($grey-600);
}
@include m-theme() {
border-right: none;
}
}
}
i {
cursor: pointer;
width: auto;
height: auto;
transition: all 0.3s ease;
@include m-theme() {
color: themed($m-grey-300);
}
&.handle {
font-size: 20px;
padding-right: 8px;
@include m-theme() {
}
}
&:hover {
transform: scale(1.15);
@include m-theme() {
color: themed($m-grey-200);
}
}
}
.dndPlaceholder {
min-height: 100px;
@include m-theme() {
border: 1px dashed rgba(themed($m-grey-100), 0.8);
}
}
}
import { Component, ContentChild, Input, TemplateRef } from '@angular/core';
import { DndDropEvent, EffectAllowed } from 'ngx-drag-drop';
import {
Component,
ContentChild,
Input,
TemplateRef,
Output,
EventEmitter,
ChangeDetectorRef,
} from '@angular/core';
import {
DndDropEvent,
EffectAllowed,
DndDragImageOffsetFunction,
} from 'ngx-drag-drop';
@Component({
selector: 'm-draggable-list',
template: `
<ul
dndDropzone
[dndHorizontal]="false"
[dndEffectAllowed]="dndEffectAllowed"
(dndDrop)="onDrop($event)"
class="m-draggableList__list"
>
<div
class="dndPlaceholder"
dndPlaceholderRef
style="min-height:100px;border:1px dashed green;background-color:rgba(0, 0, 0, 0.1)"
></div>
<li
*ngFor="let item of data; let i = index; trackBy: trackByFunction"
[dndDraggable]="item"
[dndEffectAllowed]="'move'"
class="m-draggableList__listItem"
>
<i class="handle material-icons" dndHandle>reorder</i>
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ item: item, i: i }"
></ng-container>
</li>
</ul>
`,
selector: 'm-draggableList',
templateUrl: 'list.component.html',
})
export class DraggableListComponent {
@Input() data: Array<any>;
@Input() dndEffectAllowed: EffectAllowed = 'copyMove';
@Input() id: string;
@Input() headers: string[];
@Input() disabled: boolean;
@ContentChild(TemplateRef, { static: false }) template: TemplateRef<any>;
@Output() emptyListHeaderRowClicked: EventEmitter<any> = new EventEmitter();
@Output() arrayChanged: EventEmitter<any> = new EventEmitter();
dragging: boolean = false;
trackByFunction(index, item) {
return this.id ? item[this.id] + index : index;
}
constructor(private cd: ChangeDetectorRef) {}
onDrop(event: DndDropEvent) {
this.dragging = false;
if (
this.data &&
(event.dropEffect === 'copy' || event.dropEffect === 'move')
......@@ -50,7 +44,7 @@ export class DraggableListComponent {
let dragIndex = this.data.findIndex(
item => event.data[this.id] === item[this.id]
);
let dropIndex = event.index || this.data.length;
let dropIndex = event.index;
// remove element
this.data.splice(dragIndex, 1);
......@@ -60,6 +54,43 @@ export class DraggableListComponent {
}
this.data.splice(dropIndex, 0, event.data);
this.arrayChanged.emit(this.data);
}
}
removeItem(index) {
this.data.splice(index, 1);
this.arrayChanged.emit(this.data);
}
clickedHeaderRow($event) {
if (this.data.length === 0) {
this.emptyListHeaderRowClicked.emit($event);
}
}
dragImageOffsetRight: DndDragImageOffsetFunction = (
event: DragEvent,
dragImage: HTMLElement
) => {
return {
x: dragImage.offsetWidth - 57,
y: event.offsetY + 10,
};
};
/**
* If input is focused then disable dragging
*/
onFocusIn(e: FocusEvent | MouseEvent) {
this.disabled = true;
}
/**
* Re-enable when input not focused
* TODO: Make this smarter.. what if something else disabled the dragging?
*/
onFocusOut(e: FocusEvent | MouseEvent) {
this.disabled = false;
}
}
<div class="m-analyticsFilter__labelWrapper" *ngIf="showLabel">
<div class="m-dropdownSelector__labelWrapper" *ngIf="showLabel">
<span>{{ filter.label }}</span>
<m-tooltip icon="help">
<div>{{ filter?.description }}</div>
......@@ -13,7 +13,7 @@
</m-tooltip>
</div>
<div
class="m-analyticsFilter__wrapper"
class="m-dropdownSelector__wrapper"
[ngClass]="{
expanded: expanded,
dropUp: dropUp
......@@ -21,27 +21,23 @@
(blur)="expanded = false"
tabindex="0"
>
<div
class="m-analyticsFilter__header m-analyticsFilter__row"
(click)="expanded = !expanded"
>
<span class="m-analyticsFilter__option m-analyticsFilter__option--selected">
<div class="m-dropdownSelector__header" (click)="expanded = !expanded">
<span class="m-dropdownSelector__option">
{{ selectedOption.label }}
</span>
<i class="material-icons" *ngIf="!expanded">keyboard_arrow_down</i>
<i class="material-icons" *ngIf="expanded">keyboard_arrow_up</i>
</div>
<div class="m-analyticsFilter__optionsContainer">
<div class="m-dropdownSelector__optionsContainer">
<ng-container *ngFor="let option of filter.options">
<div
class="m-analyticsFilter__option m-analyticsFilter__row"
class="m-dropdownSelector__option"
(click)="updateFilter(option)"
[ngClass]="{
unavailable: option.available === false
}"
>
{{ option.label }}
<!-- <span>{{ option.label }}</span> -->
</div>
</ng-container>
</div>
......
$rounded-top: 3px 3px 0 0;
$rounded-bottom: 0 0 3px 3px;
m-analytics__filter {
m-dropdownSelector {
position: relative;
margin: 0 24px 36px 0;
z-index: 2;
display: block;
}
.m-analyticsFilter__labelWrapper {
.m-dropdownSelector__labelWrapper {
position: absolute;
bottom: 115%;
white-space: nowrap;
@include m-theme() {
color: themed($m-grey-300);
}
m-tooltip {
margin-left: 4px;
}
> * {
display: inline-block;
}
.m-tooltip {
i {
font-size: 12px;
@include m-theme() {
color: rgba(themed($m-grey-300), 0.7);
}
}
}
.m-tooltip--bubble {
letter-spacing: 1.2px;
line-height: 16px;
z-index: 9999;
font-size: 11px;
bottom: 110%;
left: 0;
width: 160px;
......@@ -56,168 +45,179 @@ m-analytics__filter {
}
}
.m-analyticsFilter__wrapper {
.m-dropdownSelector__wrapper {
cursor: pointer;
&:focus {
outline: 0;
}
> * {
width: 180px;
box-sizing: border-box;
}
.m-analyticsFilter__optionsContainer {
padding: 8px 0;
.m-analyticsFilter__option {
transform: translateY(25%);
}
}
&.expanded {
@include m-theme() {
box-shadow: 0px 1px 15px 0 rgba(themed($m-black), 0.15);
}
.m-analyticsFilter__header {
.m-dropdownSelector__header {
@include m-theme() {
border-color: themed($m-blue);
}
}
.m-analyticsFilter__optionsContainer {
.m-dropdownSelector__optionsContainer {
display: block;
}
&:not(.dropUp) {
.m-analyticsFilter__header {
.m-dropdownSelector__header {
@include m-theme() {
border-radius: $rounded-top;
}
}
.m-analyticsFilter__optionsContainer {
.m-dropdownSelector__optionsContainer {
border-top: none;
border-radius: $rounded-bottom;
}
}
&.dropUp {
.m-analyticsFilter__header {
.m-dropdownSelector__header {
border-radius: $rounded-bottom;
}
.m-analyticsFilter__optionsContainer {
.m-dropdownSelector__optionsContainer {
bottom: 100%;
border-radius: $rounded-top;
border-bottom: none;
@include m-theme() {
box-shadow: 0px -4px 16px -4px rgba(themed($m-black), 0.15);
}
}
}
}
.m-analyticsFilter__header {
position: relative;
border-radius: 3px;
transition: all 0.3s cubic-bezier(0.075, 0.82, 0.165, 1);
> * {
width: 180px;
box-sizing: border-box;
}
}
.m-dropdownSelector__header {
position: relative;
border-radius: 3px;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
@include m-theme() {
background-color: themed($m-white);
color: themed($m-grey-300);
}
@include m-theme() {
border: 1px solid themed($m-grey-100);
}
.m-dropdownSelector__label {
margin-right: 10px;
}
i {
flex-grow: 0;
width: 24px;
height: 24px;
padding-top: 2px;
}
.m-dropdownSelector__option {
@include m-theme() {
background-color: themed($m-white);
color: themed($m-grey-300);
}
@include m-theme() {
border: 1px solid themed($m-grey-100);
}
.m-analyticsFilter__label {
margin-right: 10px;
color: themed($m-grey-500);
}
i {
flex-grow: 0;
width: 24px;
height: 24px;
}
.m-analyticsFilter__option--selected {
}
}
.m-dropdownSelector__optionsContainer {
box-sizing: border-box;
position: absolute;
display: none;
border-radius: 3px;
left: 0px;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
@include m-theme() {
border: 1px solid themed($m-blue);
border-top: 1px solid themed($m-blue);
background-color: themed($m-white);
box-shadow: 0px 8px 16px 0px rgba(themed($m-black), 0.15);
}
.m-dropdownSelector__option {
&:hover:not(.unavailable) {
@include m-theme() {
color: themed($m-grey-500);
background-color: rgba(themed($m-grey-100), 0.2);
}
}
}
.m-analyticsFilter__optionsContainer {
position: absolute;
display: none;
border-radius: 3px;
left: 0px;
transition: box-shadow 0.3s cubic-bezier(0.075, 0.82, 0.165, 1);
@include m-theme() {
border: 1px solid themed($m-blue);
border-top: 1px solid themed($m-blue);
background-color: themed($m-white);
&:first-child {
padding-top: 14px;
}
&:last-child {
padding-bottom: 14px;
}
}
}
.m-analyticsFilter__row {
display: flex;
justify-content: space-between;
align-items: center;
height: 46px;
padding: 0 20px;
&.m-analyticsFilter__header {
padding-right: 10px;
}
.m-dropdownSelector__header {
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 10px;
}
.m-dropdownSelector__option {
display: inline-block;
padding: 10px 20px;
box-sizing: border-box;
width: inherit;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include m-theme() {
color: themed($m-grey-300);
}
.m-analyticsFilter__option {
display: inline-block;
box-sizing: border-box;
width: inherit;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.unavailable {
display: none;
text-decoration: line-through;
@include m-theme() {
color: themed($m-grey-300);
}
&.unavailable {
text-decoration: line-through;
@include m-theme() {
color: themed($m-grey-50);
}
}
&:hover:not(.unavailable) {
@include m-theme() {
color: themed($m-grey-600);
}
color: themed($m-grey-50);
}
}
}
@media screen and (max-width: $min-tablet) {
m-analytics__filter {
.m-analyticsFilter__labelWrapper {
m-dropdownSelector {
.m-dropdownSelector__labelWrapper {
.m-tooltip--bubble {
width: 120px;
}
}
.m-analyticsFilter__wrapper {
> * {
width: 140px;
}
}
}
}
@media screen and (max-width: $max-mobile) {
m-analytics__filter {
.m-analyticsFilter__wrapper {
.m-analyticsFilter__header {
m-dropdownSelector {
.m-dropdownSelector__wrapper {
> * {
width: 160px;
}
.m-dropdownSelector__header {
padding-right: 10px;
i {
display: none;
}
}
.m-analyticsFilter__row {
padding: 0 18px;
height: 40px;
&.m-analyticsFilter__header {
padding-right: 10px;
.m-dropdownSelector__optionsContainer {
.m-dropdownSelector__option {
&:first-child {
padding-top: 11px;
}
&:last-child {
padding-bottom: 11px;
}
}
}
.m-analyticsFilter__option--selected {
.m-dropdownSelector__option {
margin-right: 0;
padding: 8px 18px;
}
}
}
......
......@@ -3,34 +3,29 @@ import {
OnInit,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
EventEmitter,
} from '@angular/core';
import { Observable } from 'rxjs';
import {
AnalyticsDashboardService,
Filter,
Option,
} from '../../dashboard.service';
import { Session } from '../../../../../services/session';
import { Session } from '../../../services/session';
import { Filter, Option } from '../../../interfaces/dashboard';
@Component({
selector: 'm-analytics__filter',
templateUrl: 'filter.component.html',
selector: 'm-dropdownSelector',
templateUrl: './dropdown-selector.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnalyticsFilterComponent implements OnInit {
export class DropdownSelectorComponent implements OnInit {
@Input() filter: Filter;
@Input() dropUp: boolean = false;
@Input() showLabel: boolean = true;
@Output() selectionMade: EventEmitter<any> = new EventEmitter();
expanded = false;
options: Array<any> = [];
selectedOption: Option;
constructor(
private analyticsService: AnalyticsDashboardService,
public session: Session
) {}
constructor(public session: Session) {}
ngOnInit() {
this.selectedOption =
......@@ -45,25 +40,9 @@ export class AnalyticsFilterComponent implements OnInit {
}
this.selectedOption = option;
if (this.filter.id === 'timespan') {
this.analyticsService.updateTimespan(option.id);
console.log('upDateFilter ', option.id);
return;
}
const selectedFilterStr = `${this.filter.id}::${option.id}`;
this.analyticsService.updateFilter(selectedFilterStr);
this.selectionMade.emit({
option: this.selectedOption,
filterId: this.filter.id,
});
}
// clickHeader() {
// if (this.expanded) {
// console.log('its expanded');
// setTimeout(() => {
// this.expanded = false;
// });
// } else {
// console.log('itsnot expanded');
// }
// document.getElementById("myAnchor").blur();
// }
}
......@@ -79,9 +79,7 @@ export class FeaturedContentComponent implements OnInit {
component
);
const componentRef: ComponentRef<
any
> = this.dynamicHost.viewContainerRef.createComponent(
const componentRef: ComponentRef<any> = this.dynamicHost.viewContainerRef.createComponent(
componentFactory,
void 0,
this.injector
......
import { Injectable } from '@angular/core';
import {
filter,
first,
map,
switchMap,
mergeMap,
skip,
take,
} from 'rxjs/operators';
import { filter, first, switchMap, mergeMap, skip, take } from 'rxjs/operators';
import { FeedsService } from '../../services/feeds.service';
import { Subscription } from 'rxjs';
@Injectable()
export class FeaturedContentService {
offset: number = -1;
offset = 0;
maximumOffset = 0;
feedLength = 0;
protected feedSubscription: Subscription;
constructor(protected feedsService: FeedsService) {
this.onInit();
}
onInit() {
this.feedSubscription = this.feedsService.feed.subscribe(feed => {
this.feedLength = feed.length;
this.maximumOffset = this.feedLength - 1;
});
this.feedsService
.setLimit(12)
.setOffset(0)
......@@ -23,28 +28,36 @@ export class FeaturedContentService {
}
async fetch() {
if (this.offset >= this.feedsService.rawFeed.getValue().length) {
this.offset = -1;
}
// Refetch every 2 calls, if not loading
if (this.offset % 2 && !this.feedsService.inProgress.getValue()) {
this.feedsService.clear();
this.feedsService.fetch();
}
return await this.feedsService.feed
.pipe(
filter(feed => feed.length > 0),
first(),
mergeMap(feed => feed),
filter(entities => entities.length > 0),
mergeMap(feed => feed), // Convert feed array to stream
skip(this.offset++),
take(1),
switchMap(async entity => {
if (!entity) {
return false;
} else {
const resolvedEntity = await entity.pipe(first()).toPromise();
this.resetOffsetAtEndOfStream();
return resolvedEntity;
}
return await entity.pipe(first()).toPromise();
})
)
.toPromise();
}
protected resetOffsetAtEndOfStream() {
if (this.offset >= this.maximumOffset) {
this.offset = 0;
this.fetchNextFeed();
}
}
protected fetchNextFeed() {
if (!this.feedsService.inProgress.getValue()) {
this.feedsService.clear();
this.feedsService.fetch();
}
}
}
<div class="m-formDescriptor" i18n>
<ng-content></ng-content>
</div>
.m-formDescriptor {
font-size: 15px;
line-height: 20px;
@include m-theme() {
color: themed($m-blue);
border-left: 2px solid themed($m-blue);
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MiniChartComponent } from './mini-chart.component';
import { FormDescriptorComponent } from './form-descriptor.component';
describe('MiniChartComponent', () => {
let component: MiniChartComponent;
let fixture: ComponentFixture<MiniChartComponent>;
describe('FormDescriptorComponent', () => {
let component: FormDescriptorComponent;
let fixture: ComponentFixture<FormDescriptorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MiniChartComponent],
declarations: [FormDescriptorComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MiniChartComponent);
fixture = TestBed.createComponent(FormDescriptorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
......
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'm-miniChart',
templateUrl: './mini-chart.component.html',
selector: 'm-formDescriptor',
templateUrl: './form-descriptor.component.html',
})
export class MiniChartComponent implements OnInit {
export class FormDescriptorComponent implements OnInit {
constructor() {}
ngOnInit() {}
......
<div class="m-formToast__toastsContainer">
<ng-container *ngFor="let toast of toasts; let i = index">
<div
class="m-formToast__wrapper"
[ngClass]="{ dismissed: toast.dismissed }"
>
<i
class="material-icons m-formToast__icon--success"
*ngIf="toast.type === 'success'"
>check</i
>
<i
class="material-icons m-formToast__icon--error"
*ngIf="toast.type === 'error'"
>warning</i
>
<i
class="material-icons m-formToast__icon--warning"
*ngIf="toast.type === 'warning'"
>warning</i
>
<i
class="material-icons m-formToast__icon--info"
*ngIf="toast.type === 'info'"
></i>
<p i18n>{{ toast.message }}</p>
<div class="m-formToast__iconWrapper">
<i
class="material-icons m-formToast__icon--close"
(click)="toast.dismissed = true"
>clear</i
>
</div>
</div>
</ng-container>
</div>
m-formToast {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
max-width: 522px;
width: 60%;
transition: all 0.3s ease;
}
.m-formToast__toastsContainer {
display: flex;
flex-flow: column nowrap;
justify-content: flex-end;
}
.m-formToast__wrapper {
box-sizing: border-box;
width: 100%;
font-size: 15px;
line-height: 20px;
padding: 13px;
margin-bottom: 16px;
display: flex;
opacity: 1;
animation-name: fadeIn;
animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
animation-duration: 0.4s;
animation-iteration-count: 1;
animation-fill-mode: forwards;
@include m-theme() {
color: themed($m-grey-600);
background-color: themed($m-white);
box-shadow: 0 0 15px 0 rgba(themed($m-black), 0.2);
}
&.dismissed {
display: none;
}
p {
flex-grow: 1;
margin: 0;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
[class*='m-formToast__icon--'] {
margin-right: 10px;
}
.m-formToast__icon--success {
@include m-theme() {
color: themed($m-green-dark);
}
}
.m-formToast__icon--error {
@include m-theme() {
color: themed($m-red);
}
}
.m-formToast__icon--warning {
@include m-theme() {
color: themed($m-amber-dark);
}
}
.m-formToast__icon--info {
margin-right: 14px;
}
.m-formToast__icon--close {
cursor: pointer;
transition: all 0.2s ease-out;
@include m-theme() {
color: themed($m-grey-300);
}
&:hover {
transform: scale(1.2);
@include m-theme() {
color: themed($m-grey-100);
}
}
&:active {
@include m-theme() {
color: themed($m-grey-400);
}
}
}
@media screen and (max-width: $max-mobile) {
m-formToast {
bottom: 48px;
width: 75%;
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormToastComponent } from './form-toast.component';
import { FormToastService } from '../../services/form-toast.service';
describe('FormToastComponent', () => {
let component: FormToastComponent;
let fixture: ComponentFixture<FormToastComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [FormToastComponent],
providers: [FormToastService],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FormToastComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import {
Component,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { FormToast, FormToastService } from '../../services/form-toast.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'm-formToast',
templateUrl: './form-toast.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormToastComponent implements OnInit, OnDestroy {
toasts: FormToast[] = [];
timeoutIds: number[] = [];
subscription: Subscription;
constructor(
private service: FormToastService,
protected cd: ChangeDetectorRef
) {}
ngOnInit() {
this.subscription = this.service.onToast().subscribe(toast => {
// clear toasts when an empty toast is received
if (!toast.message) {
this.toasts = [];
return;
}
const toastIndex = this.toasts.push(toast) - 1;
console.log(toastIndex);
this.detectChanges();
const toastTimeout = setTimeout(() => {
this.toasts[toastIndex]['dismissed'] = true;
this.detectChanges();
}, 3400);
this.timeoutIds.push(setTimeout(() => toastTimeout));
});
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
ngOnDestroy() {
this.timeoutIds.forEach(id => clearTimeout(id));
this.subscription.unsubscribe();
}
}
......@@ -47,4 +47,8 @@ m-marketing {
margin: 0 auto;
box-sizing: border-box;
}
.m-marketing__subText {
font-size: 11px;
}
}
<p>
mini-chart works!
</p>
......@@ -22,7 +22,7 @@ export class NSFWSelectorComponent {
@Input('service') serviceRef: string = 'consumer';
@Input('consumer') consumer: false;
@Input('expanded') expanded: false;
@Output('selected') onSelected: EventEmitter<any> = new EventEmitter();
@Output('selectedChange') onSelected: EventEmitter<any> = new EventEmitter();
constructor(
public creatorService: NSFWSelectorCreatorService,
......@@ -31,6 +31,14 @@ export class NSFWSelectorComponent {
private storage: Storage
) {}
ngOnInit() {
if (this.service.reasons) {
for (const reason of this.service.reasons) {
this.toggle(reason.value, false);
}
}
}
get service() {
switch (this.serviceRef) {
case 'editing':
......@@ -58,14 +66,17 @@ export class NSFWSelectorComponent {
}
}
toggle(reason) {
toggle(reason, triggerChange = true) {
if (reason.locked) {
return;
}
this.service.toggle(reason);
const reasons = this.service.reasons.filter(r => r.selected);
this.onSelected.next(reasons);
if (triggerChange) {
const reasons = this.service.reasons.filter(r => r.selected);
this.onSelected.next(reasons);
}
}
hasSelections(): boolean {
......
<m-sidebarMenu [menu]="menu"></m-sidebarMenu>
<section class="m-pageLayout__main">
<ng-content select="[m-pageLayout__main]"></ng-content>
</section>
m-pageLayout {
display: block;
position: relative;
width: 100%;
max-width: 1200px;
margin: auto;
min-height: 100%;
padding: 56px 0 48px 0;
@include m-theme() {
background-color: themed($m-white);
color: themed($m-grey-800);
}
&.isForm {
min-height: 0px;
}
.m-tooltip {
margin-left: 4px;
i {
font-size: 12px;
@include m-theme() {
color: rgba(themed($m-grey-300), 0.7);
}
}
.m-tooltip--bubble {
z-index: 99;
font-size: 11px;
@include m-theme() {
color: themed($m-white);
background-color: themed($m-blue);
}
}
}
}
m-sidebarMenu {
display: block;
box-sizing: border-box;
width: 245px;
padding-left: 20px;
@include m-theme() {
background-color: themed($m-white);
}
}
.m-pageLayout__main {
margin-left: 25%;
margin-right: 24px;
@include m-theme() {
background-color: themed($m-white);
color: themed($m-grey-800);
}
}
@media screen and (max-width: $min-tablet) {
m-pageLayout {
&.isForm {
padding-bottom: 0px;
}
}
.m-pageLayout__main {
display: block;
margin: 0;
}
m-sidebarMenu {
margin-left: 0;
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, Input } from '@angular/core';
import { PageLayoutComponent } from './page-layout.component';
@Component({
selector: 'm-sidebarMenu',
template: '',
})
class SidebarMenuComponentMock {
@Input() catId;
@Input() menu;
}
describe('PageLayoutComponent', () => {
let component: PageLayoutComponent;
let fixture: ComponentFixture<PageLayoutComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [PageLayoutComponent, SidebarMenuComponentMock],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PageLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component, OnInit, Input, HostBinding } from '@angular/core';
import { Menu } from '../sidebar-menu/sidebar-menu.component';
@Component({
selector: 'm-pageLayout',
templateUrl: './page-layout.component.html',
})
export class PageLayoutComponent implements OnInit {
@Input() menu: Menu;
@HostBinding('class.isForm') @Input() isForm: boolean = false;
constructor() {}
ngOnInit() {}
}
......@@ -231,7 +231,7 @@
<m-nsfw-selector
service="editing"
[selected]="entity.nsfw"
(selected)="onNSFWSelected($event)"
(selectedChange)="onNSFWSelected($event)"
>
</m-nsfw-selector>
</li>
......
......@@ -55,7 +55,7 @@
[href]="src.perma_url"
target="_blank"
rel="noopener noreferrer"
class="meta mdl-color-text--blue-grey-900"
class="meta"
[ngClass]="{ 'm-rich-embed-has-thumbnail': src.thumbnail_src, 'm-rich-embed--title--no-padding': hasInlineContentLoaded() }"
>
<h2
......@@ -74,10 +74,7 @@
<a class="thumbnail" *ngIf="preview.thumbnail">
<img src="{{preview.thumbnail}}" />
</a>
<a
class="meta mdl-color-text--blue-grey-900"
[ngClass]="{ 'm-has-thumbnail': preview.thumbnail }"
>
<a class="meta" [ngClass]="{ 'm-has-thumbnail': preview.thumbnail }">
<h2 class="m-rich-embed--title mdl-card__title-text">
{{preview.title | excerpt}}
</h2>
......
......@@ -16,9 +16,6 @@ minds-rich-embed {
left: 0;
width: 100%;
height: 100%;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.2);
}
&:hover {
background: transparent;
......
......@@ -3,11 +3,14 @@ import {
ElementRef,
ChangeDetectorRef,
ChangeDetectionStrategy,
Output,
EventEmitter,
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { RichEmbedService } from '../../../services/rich-embed';
import mediaProxyUrl from '../../../helpers/media-proxy-url';
import { FeaturesService } from '../../../services/features.service';
@Component({
moduleId: module.id,
......@@ -17,18 +20,22 @@ import mediaProxyUrl from '../../../helpers/media-proxy-url';
})
export class MindsRichEmbed {
type: string = '';
mediaSource: string = '';
src: any = {};
preview: any = {};
maxheight: number = 320;
inlineEmbed: any = null;
embeddedInline: boolean = false;
cropImage: boolean = false;
modalRequestSubscribed: boolean = false;
@Output() mediaModalRequested: EventEmitter<any> = new EventEmitter();
private lastInlineEmbedParsed: string;
constructor(
private sanitizer: DomSanitizer,
private service: RichEmbedService,
private cd: ChangeDetectorRef
private cd: ChangeDetectorRef,
protected featureService: FeaturesService
) {}
set _src(value: any) {
......@@ -65,6 +72,14 @@ export class MindsRichEmbed {
// Inline Embedding
let inlineEmbed = this.parseInlineEmbed(this.inlineEmbed);
if (
this.featureService.has('media-modal') &&
this.mediaSource === 'youtube'
) {
this.modalRequestSubscribed =
this.mediaModalRequested.observers.length > 0;
}
if (
inlineEmbed &&
inlineEmbed.id &&
......@@ -80,9 +95,35 @@ export class MindsRichEmbed {
}
this.inlineEmbed = inlineEmbed;
if (
this.modalRequestSubscribed &&
this.featureService.has('media-modal') &&
this.mediaSource === 'youtube'
) {
if (this.inlineEmbed && this.inlineEmbed.htmlProvisioner) {
this.inlineEmbed.htmlProvisioner().then(html => {
this.inlineEmbed.html = html;
this.detectChanges();
});
// @todo: catch any error here and forcefully window.open to destination
}
}
}
action($event) {
if (
this.modalRequestSubscribed &&
this.featureService.has('media-modal') &&
this.mediaSource === 'youtube'
) {
$event.preventDefault();
$event.stopPropagation();
this.mediaModalRequested.emit();
return;
}
if (this.inlineEmbed && !this.embeddedInline) {
$event.preventDefault();
$event.stopPropagation();
......@@ -120,6 +161,7 @@ export class MindsRichEmbed {
if ((matches = youtube.exec(url)) !== null) {
if (matches[1]) {
this.mediaSource = 'youtube';
return {
id: `video-youtube-${matches[1]}`,
className:
......@@ -138,12 +180,13 @@ export class MindsRichEmbed {
if ((matches = vimeo.exec(url)) !== null) {
if (matches[1]) {
this.mediaSource = 'vimeo';
return {
id: `video-vimeo-${matches[1]}`,
className:
'm-rich-embed-video m-rich-embed-video-iframe m-rich-embed-video-vimeo',
html: this.sanitizer.bypassSecurityTrustHtml(`<iframe
src="https://player.vimeo.com/video/${matches[1]}?autoplay=1&title=0&byline=0&portrait=0"
src="https://player.vimeo.com/video/${matches[1]}?title=0&byline=0&portrait=0"
frameborder="0"
webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>`),
playable: true,
......@@ -156,6 +199,7 @@ export class MindsRichEmbed {
if ((matches = soundcloud.exec(url)) !== null) {
if (matches[1]) {
this.mediaSource = 'soundcloud';
return {
id: `audio-soundcloud-${matches[1]}`,
className:
......@@ -183,6 +227,7 @@ export class MindsRichEmbed {
if ((matches = spotify.exec(url)) !== null) {
if (matches[1]) {
this.mediaSource = 'spotify';
return {
id: `audio-spotify-${matches[1]}`,
className:
......@@ -207,7 +252,7 @@ export class MindsRichEmbed {
if (!id) {
return null;
}
this.mediaSource = 'giphy';
return {
id: `image-giphy-${matches[1]}`,
className:
......@@ -225,7 +270,11 @@ export class MindsRichEmbed {
}
hasInlineContentLoaded() {
return this.embeddedInline && this.inlineEmbed && this.inlineEmbed.html;
return this.featureService.has('media-modal')
? !this.modalRequestSubscribed &&
this.inlineEmbed &&
this.inlineEmbed.html
: this.embeddedInline && this.inlineEmbed && this.inlineEmbed.html;
}
detectChanges() {
......
<section class="m-shadowboxHeader__section">
<div class="m-shadowboxHeader__wrapper">
<ng-container *ngIf="isScrollable">
<div
*ngIf="isOverflown && !isAtScrollStart"
class="m-shadowboxHeader__overflowFade--left"
></div>
<div
[ngClass]="{ showButton: showButton.left }"
class="m-shadowboxHeader__overflowScrollButton--left"
(click)="slide('left')"
>
<i class="material-icons">chevron_left</i>
</div>
</ng-container>
<div
#shadowboxHeaderContainer
class="m-shadowboxHeader__container disable-scrollbars"
(scroll)="onScroll($event)"
>
<ng-content select=".m-shadowboxLayout__header"></ng-content>
</div>
<ng-container *ngIf="isScrollable">
<div
*ngIf="isOverflown && !isAtScrollEnd"
class="m-shadowboxHeader__overflowFade--right"
></div>
<div
[ngClass]="{ showButton: showButton.right }"
class="m-shadowboxHeader__overflowScrollButton--right"
(click)="slide('right')"
>
<i class="material-icons">chevron_right</i>
</div>
</ng-container>
</div>
</section>
m-shadowboxHeader {
min-height: 116px;
display: block;
a {
font-weight: 400;
text-decoration: none;
@include m-theme() {
color: themed($m-blue);
}
}
}
.m-shadowboxHeader__section {
position: relative;
}
.m-shadowboxHeader__wrapper {
position: relative;
z-index: 1;
height: 124px;
@include m-theme() {
box-shadow: 0 7px 15px -7px rgba(themed($m-black-always), 0.1);
}
}
.m-shadowboxHeader__container {
overflow-x: hidden;
overflow-y: hidden;
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
&.disable-scrollbars {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
&::-webkit-scrollbar {
width: 0px;
background: transparent; /* Chrome/Safari/Webkit */
}
}
.m-tooltip--bubble {
width: 160px;
}
}
[class*='m-shadowboxHeader__overflowFade--'] {
position: absolute;
top: 0;
bottom: 0;
width: 24px;
z-index: 2;
&.m-shadowboxHeader__overflowFade--right {
@include m-theme() {
right: 0;
background: linear-gradient(
to right,
rgba(themed($m-white), 0) 0,
themed($m-white) 50%
);
}
}
&.m-shadowboxHeader__overflowFade--left {
@include m-theme() {
left: 0;
background: linear-gradient(
to left,
rgba(themed($m-white), 0) 0,
themed($m-white) 50%
);
}
}
}
[class*='m-shadowboxHeader__overflowScrollButton--'] {
position: absolute;
top: 50%;
border-radius: 50%;
box-sizing: border-box;
z-index: 2;
transform: translateY(-50%);
opacity: 0;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
cursor: pointer;
&.showButton {
opacity: 1;
}
@include m-theme() {
background-color: themed($m-white);
box-shadow: 0px 0px 10px -3px rgba(themed($m-black-always), 0.3);
border: 1px solid themed($m-white);
}
&:hover {
@include m-theme() {
border: 1px solid themed($m-blue);
}
}
&.m-shadowboxHeader__overflowScrollButton--right {
right: -12;
}
&.m-shadowboxHeader__overflowScrollButton--left {
left: -12;
}
i {
@include m-theme() {
color: themed($m-grey-200);
}
}
}
.m-shadowboxLayout__header.hasTitle {
padding: 34px 28px 34px 70px;
}
.m-shadowboxHeader__title {
margin: 0 0 2px 0;
line-height: 32px;
font-weight: 500;
@include m-theme() {
color: themed($m-grey-800);
font-size: 24px;
}
}
.m-shadowboxHeader__subtitle {
line-height: 22px;
margin: 0;
@include m-theme() {
color: themed($m-grey-300);
font-size: 15px;
}
}
@media screen and (max-width: $min-tablet) {
.m-shadowboxLayout__header.hasTitle {
padding: 0 24px;
}
.m-shadowboxHeader__section {
[class*='m-shadowboxHeader__overflowScrollButton--'] {
display: none;
}
.m-shadowboxHeader__container {
overflow-x: scroll;
scroll-snap-type: x mandatory;
.m-analytics__metric {
scroll-snap-align: start;
&:first-child {
margin-left: 16px;
}
&:last-child {
margin-right: 16px;
}
}
}
}
.m-shadowboxHeader__wrapper {
@include m-theme() {
box-shadow: none;
}
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {
Component,
Input,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
ViewChild,
ElementRef,
HostListener,
} from '@angular/core';
import { ShadowboxHeaderComponent } from './shadowbox-header.component';
describe('ShadowboxHeaderComponent', () => {
let component: ShadowboxHeaderComponent;
let fixture: ComponentFixture<ShadowboxHeaderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ShadowboxHeaderComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ShadowboxHeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import {
Component,
Input,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
ViewChild,
ElementRef,
HostListener,
} from '@angular/core';
@Component({
selector: 'm-shadowboxHeader',
templateUrl: './shadowbox-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ShadowboxHeaderComponent implements AfterViewInit {
@Input() isScrollable: boolean = true;
@Input() itemActivated;
@ViewChild('shadowboxHeaderContainer', { static: false })
containerEl: ElementRef;
container;
childClientWidth: number;
faderWidth = 24;
isOverflown: boolean = false;
isAtScrollEnd = false;
isAtScrollStart = true;
showButton = { left: false, right: false };
constructor(private cd: ChangeDetectorRef) {}
ngAfterViewInit() {
this.checkOverflow();
// const activeMetric = ;//get the index of the metric with .active
// this.slideToActiveMetric();
}
// updateMetric(metric) {
// // TODO: if clicked metric is not fully visible, slide() until it is
// this.analyticsService.updateMetric(metric.id);
// }
// ----------------------------------------------------
@HostListener('click', ['$event.target'])
onClick(target) {
// console.log('***Clicked on: ', target);
// find index of target
// this.slideToActiveMetric(metricIndex);
}
slideToActiveMetric(metricIndex) {
// TODOOJM
}
// ----------------------------------------------------
@HostListener('window:resize')
onResize() {
this.checkOverflow();
}
onScroll($event) {
this.checkOverflow();
}
checkOverflow() {
if (!this.isScrollable) {
return;
}
const firstMetric = <HTMLElement>(
document.querySelector('.m-shadowboxLayout__headerItem')
);
// TODO: figure out how to avoid test failure "Cannot read property 'clientWidth' of null"
this.childClientWidth = firstMetric ? firstMetric.clientWidth : 160;
this.container = this.containerEl.nativeElement;
this.isOverflown =
this.container.scrollWidth - this.container.clientWidth > 0;
this.isAtScrollStart = this.container.scrollLeft < this.faderWidth;
this.showButton.left = this.isOverflown && !this.isAtScrollStart;
this.isAtScrollEnd =
!this.isOverflown ||
this.container.scrollWidth -
(this.container.scrollLeft + this.container.clientWidth) <
this.faderWidth;
this.showButton.right =
this.isOverflown && this.container.scrollLeft >= 0 && !this.isAtScrollEnd;
this.detectChanges();
}
slide(direction) {
let currentScrollLeft = this.container.scrollLeft;
let targetScrollLeft;
let scrollEndOffset = 0;
const partiallyVisibleMetricWidth =
this.container.clientWidth % this.childClientWidth;
const completelyVisibleMetricsWidth =
this.container.clientWidth - partiallyVisibleMetricWidth;
if (direction === 'right') {
if (currentScrollLeft < this.faderWidth) {
currentScrollLeft = this.faderWidth;
}
targetScrollLeft = Math.min(
currentScrollLeft + completelyVisibleMetricsWidth,
this.container.scrollWidth - completelyVisibleMetricsWidth
);
} else {
if (this.isAtScrollEnd) {
scrollEndOffset = partiallyVisibleMetricWidth - this.faderWidth;
}
targetScrollLeft = Math.max(
currentScrollLeft - completelyVisibleMetricsWidth + scrollEndOffset,
0
);
}
this.container.scrollTo({
top: 0,
left: targetScrollLeft,
behavior: 'smooth',
});
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
}
<m-shadowboxHeader
*ngIf="hasHeader"
[isScrollable]="scrollableHeader"
[ngClass]="{ isScrollable: scrollableHeader }"
>
<ng-content
select=".m-shadowboxLayout__header"
ngProjectAs=".m-shadowboxLayout__header"
></ng-content>
></m-shadowboxHeader
>
<div *ngIf="!isForm" class="m-shadowboxLayout__bottom">
<ng-content select=".m-shadowboxLayout__body"></ng-content>
<ng-content select=".m-shadowboxLayout__footer"></ng-content>
</div>
<ng-container *ngIf="isForm">
<ng-content
class="m-shadowboxLayout__bottom"
select=".m-shadowboxLayout__bottom"
>
</ng-content>
</ng-container>
m-shadowboxLayout {
display: block;
@include m-theme() {
box-shadow: 0px 0px 10px -3px rgba(themed($m-black-always), 0.3);
}
}
m-shadowboxHeader.isScrollable {
.m-shadowboxLayout__header {
display: flex;
flex-flow: row nowrap;
}
}
.m-shadowboxLayout__bottom {
position: relative;
transition: all 0.3s ease;
@include m-theme() {
border-top: 1px solid rgba(themed($m-grey-50), 0.5);
background-color: themed($m-white);
}
}
.m-shadowboxLayout__footer {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
@include m-theme() {
background-color: rgba(themed($m-grey-50), 0.25);
border-top: 1px solid rgba(themed($m-grey-50), 0.6);
}
}
.m-shadowboxLayout__button {
cursor: pointer;
padding: 10px 20px;
background-color: #4fc3a9;
min-height: 43px;
border: 0;
transition: all 0.2s ease;
border-radius: 2px;
outline: 0;
@include m-theme() {
color: themed($m-white);
}
&:hover {
transform: scale(1.02);
background-color: #55ccb2;
@include m-theme() {
box-shadow: 0 3px 3px -2px rgba(themed($m-black), 0.2),
0 2px 5px 0 rgba(themed($m-black), 0.14),
0 1px 7px 0 rgba(themed($m-black), 0.12);
}
}
&:active {
transform: scale(0.999);
@include m-theme() {
box-shadow: 0 3px 2px -2px rgba(themed($m-black), 0.2),
0 2px 3px 0 rgba(themed($m-black), 0.14),
0 1px 5px 0 rgba(themed($m-black), 0.12);
}
}
&:disabled,
&[disabled] {
cursor: default;
@include m-theme() {
background-color: themed($m-grey-100);
}
}
p {
margin: 0;
font-size: 17px;
font-weight: 300;
span {
font-size: 72px;
}
}
}
button {
outline: 0;
}
@keyframes blink {
0% {
opacity: 0.2;
transform: scale(2.1);
font-size: 77px;
}
20% {
opacity: 1;
transform: scale(1.2);
font-size: 90px;
}
100% {
opacity: 0.2;
transform: scale(1.3);
}
}
p.m-shadowboxLayout__buttonStatus--saving {
// line-height:70px
span {
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
// ---------------------------------------
m-shadowboxLayout.isForm {
margin-top: 69px;
.m-shadowboxLayout__body {
padding: 50px 70px;
}
.m-shadowboxLayout__footer {
padding: 30px 70px;
align-items: center;
}
.m-shadowboxHeader__wrapper {
height: auto;
}
}
// ---------------------------------------
@media screen and (max-width: $min-tablet) {
m-shadowboxLayout {
@include m-theme() {
box-shadow: none;
}
}
.m-shadowboxLayout__bottom {
@include m-theme() {
border-top: 1px solid themed($m-grey-100);
}
}
m-shadowboxLayout.isForm {
margin-top: 0px;
m-shadowboxHeader {
min-height: 80px;
}
.m-shadowboxLayout__bottom {
@include m-theme() {
border-top: none;
}
}
.m-shadowboxLayout__body {
padding: 24px 24px 36px 24px;
}
.m-shadowboxLayout__footer {
justify-content: center;
padding: 24px 24px 48px 24px;
}
}
}
@media screen and (max-width: $max-mobile) {
m-shadowboxLayout.isForm {
.m-shadowboxLayout__bottom {
@include m-theme() {
border-top-color: rgba(0, 0, 0, 0);
}
}
.m-shadowboxLayout__footer {
.m-shadowboxLayout__button {
min-width: 50%;
}
}
}
m-shadowboxLayout {
@include m-theme() {
box-shadow: none;
}
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, Input } from '@angular/core';
import { ShadowboxLayoutComponent } from './shadowbox-layout.component';
@Component({
selector: 'm-shadowboxHeader',
template: '',
})
class ShadowboxHeaderComponentMock {
@Input() isScrollable;
}
describe('ShadowboxLayoutComponent', () => {
let component: ShadowboxLayoutComponent;
let fixture: ComponentFixture<ShadowboxLayoutComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ShadowboxLayoutComponent, ShadowboxHeaderComponentMock],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ShadowboxLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component, Input, HostBinding } from '@angular/core';
@Component({
selector: 'm-shadowboxLayout',
templateUrl: './shadowbox-layout.component.html',
})
export class ShadowboxLayoutComponent {
@Input() scrollableHeader: boolean = true;
@Input() hasHeader: boolean = true;
@HostBinding('class.isForm') @Input() isForm: boolean = false;
constructor() {}
}
<button
class="m-shadowboxSubmitButton"
type="submit"
[disabled]="disabled"
[ngClass]="{ saving: saveStatus === 'saving' }"
>
<ng-content></ng-content>
</button>
.m-shadowboxSubmitButton {
min-width: 220px;
position: relative;
cursor: pointer;
padding: 10px 20px;
background-color: #4fc3a9;
min-height: 43px;
border: 0;
transition: all 0.2s ease;
border-radius: 2px;
outline: 0;
@include m-theme() {
color: themed($m-white);
}
&:hover {
&:not(:disabled) {
transform: scale(1.02);
background-color: #55ccb2;
@include m-theme() {
box-shadow: 0 3px 3px -2px rgba(themed($m-black), 0.2),
0 2px 5px 0 rgba(themed($m-black), 0.14),
0 1px 7px 0 rgba(themed($m-black), 0.12);
}
}
}
&:active {
&:not(:disabled) {
transform: scale(0.999);
background-color: #4fc3a9;
@include m-theme() {
box-shadow: 0 3px 2px -2px rgba(themed($m-black), 0.2),
0 2px 3px 0 rgba(themed($m-black), 0.14),
0 1px 5px 0 rgba(themed($m-black), 0.12);
}
}
}
&:disabled,
&[disabled] {
cursor: default;
&:not(.saving) {
@include m-theme() {
background-color: themed($m-grey-100);
}
}
}
}
button {
outline: 0;
}
[class*='m-shadowboxSubmitButton__status'] {
font-size: 17px;
font-weight: 300;
}
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0.2;
transform: scale(0.9);
}
}
.m-shadowboxSubmitButton__status--saving {
span {
display: inline-block;
height: 8px;
width: 8px;
margin: 0 6px;
border-radius: 50%;
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
@include m-theme() {
background-color: themed($m-white);
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@media screen and (max-width: $max-mobile) {
m-shadowboxSubmitButton {
min-width: 50%;
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
m-analytics__chart {
display: block;
position: relative;
margin-left: 40px;
.js-plotly-plot,
.plot-container {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.