...
 
Commits (17)
......@@ -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();
// })
}
})
});
......@@ -1968,6 +1968,12 @@
"integrity": "sha512-4GbNCDs98uHCT/OMv40qQC/OpoPbYn9XdXeTiFwHBBFO6eJhYEPUu2zDKirXSbHlvDV8oZ9l8EQ+HrEx/YS9DQ==",
"dev": true
},
"@types/sizzle": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz",
"integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==",
"dev": true
},
"@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
......@@ -5322,13 +5328,14 @@
"dev": true
},
"cypress": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-3.4.1.tgz",
"integrity": "sha512-1HBS7t9XXzkt6QHbwfirWYty8vzxNMawGj1yI+Fu6C3/VZJ8UtUngMW6layqwYZzLTZV8tiDpdCNBypn78V4Dg==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-3.6.1.tgz",
"integrity": "sha512-6n0oqENdz/oQ7EJ6IgESNb2M7Bo/70qX9jSJsAziJTC3kICfEMmJUlrAnP9bn+ut24MlXQST5nRXhUP5nRIx6A==",
"dev": true,
"requires": {
"@cypress/listr-verbose-renderer": "0.4.1",
"@cypress/xvfb": "1.2.4",
"@types/sizzle": "2.3.2",
"arch": "2.1.1",
"bluebird": "3.5.0",
"cachedir": "1.3.0",
......@@ -5355,6 +5362,7 @@
"request-progress": "3.0.0",
"supports-color": "5.5.0",
"tmp": "0.1.0",
"untildify": "3.0.3",
"url": "0.11.0",
"yauzl": "2.10.0"
},
......@@ -19115,6 +19123,12 @@
}
}
},
"untildify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
"dev": true
},
"upath": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz",
......
......@@ -63,7 +63,7 @@
"@types/jasminewd2": "~2.0.4",
"@types/node": "~10.12.18",
"codelyzer": "^4.5.0",
"cypress": "^3.4.1",
"cypress": "^3.6.1",
"cypress-file-upload": "^3.3.3",
"gulp": "~4.0.0",
"gulp-autoprefixer": "^6.0.0",
......
<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);
}
}
......
......@@ -121,6 +121,10 @@ import { PageLayoutComponent } from './components/page-layout/page-layout.compon
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 { 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;
......@@ -232,6 +236,9 @@ PlotlyModule.plotlyjs = PlotlyJS;
DashboardLayoutComponent,
ShadowboxLayoutComponent,
ShadowboxHeaderComponent,
FormDescriptorComponent,
FormToastComponent,
ShadowboxSubmitButtonComponent,
],
exports: [
MINDS_PIPES,
......@@ -327,9 +334,13 @@ PlotlyModule.plotlyjs = PlotlyJS;
PageLayoutComponent,
DashboardLayoutComponent,
ShadowboxLayoutComponent,
FormDescriptorComponent,
FormToastComponent,
ShadowboxSubmitButtonComponent,
],
providers: [
SiteService,
SsoService,
{
provide: AttachmentService,
useFactory: AttachmentService._,
......@@ -345,7 +356,7 @@ PlotlyModule.plotlyjs = PlotlyJS;
{
provide: MindsHttpClient,
useFactory: MindsHttpClient._,
deps: [HttpClient, SiteService],
deps: [HttpClient],
},
{
provide: NSFWSelectorCreatorService,
......
......@@ -15,6 +15,13 @@ m-dashboardLayout {
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) {
......
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';
@Component({
selector: 'm-dashboardLayout',
templateUrl: './dashboard-layout.component.html',
})
export class DashboardLayoutComponent implements OnInit {
export class DashboardLayoutComponent {
constructor() {}
ngOnInit() {}
}
<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"
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[];
@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 +43,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 +53,28 @@ 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,
};
};
}
<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 { DashboardLayoutComponent } from './dashboard-layout.component';
import { FormDescriptorComponent } from './form-descriptor.component';
describe('DashboardLayoutComponent', () => {
let component: DashboardLayoutComponent;
let fixture: ComponentFixture<DashboardLayoutComponent>;
describe('FormDescriptorComponent', () => {
let component: FormDescriptorComponent;
let fixture: ComponentFixture<FormDescriptorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [DashboardLayoutComponent],
declarations: [FormDescriptorComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardLayoutComponent);
fixture = TestBed.createComponent(FormDescriptorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
......
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'm-formDescriptor',
templateUrl: './form-descriptor.component.html',
})
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();
}
}
......@@ -3,7 +3,7 @@
m-marketing {
display: block;
font-family: Roboto, sans-serif;
overflow: hidden;
overflow-x: hidden;
@include m-theme() {
background: themed($m-white);
......
<m-sidebarMenu [catId]="navId"></m-sidebarMenu>
<m-sidebarMenu [menu]="menu"></m-sidebarMenu>
<section class="m-pageLayout__main">
<ng-content select="[m-pageLayout__main]"></ng-content>
</section>
......@@ -2,12 +2,17 @@ m-pageLayout {
display: block;
position: relative;
width: 100%;
padding-top: 56px;
margin-bottom: 48px;
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;
......@@ -18,7 +23,7 @@ m-pageLayout {
}
}
.m-tooltip--bubble {
z-index: 9999;
z-index: 99;
font-size: 11px;
@include m-theme() {
color: themed($m-white);
......@@ -30,14 +35,14 @@ m-pageLayout {
m-sidebarMenu {
display: block;
box-sizing: border-box;
padding-left: 105px;
width: 245px;
padding-left: 20px;
@include m-theme() {
background-color: themed($m-white);
}
}
.m-pageLayout__main {
margin-left: 350px;
margin-left: 25%;
margin-right: 24px;
@include m-theme() {
......@@ -47,6 +52,12 @@ m-sidebarMenu {
}
@media screen and (max-width: $min-tablet) {
m-pageLayout {
&.isForm {
padding-bottom: 0px;
}
}
.m-pageLayout__main {
display: block;
margin: 0;
......
......@@ -8,6 +8,7 @@ import { PageLayoutComponent } from './page-layout.component';
})
class SidebarMenuComponentMock {
@Input() catId;
@Input() menu;
}
describe('PageLayoutComponent', () => {
......
import { Component, OnInit, Input } from '@angular/core';
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() navId: string;
@Input() menu: Menu;
@HostBinding('class.isForm') @Input() isForm: boolean = false;
constructor() {}
ngOnInit() {}
......
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;
......@@ -16,8 +23,6 @@ m-shadowboxHeader {
.m-shadowboxHeader__container {
overflow-x: hidden;
overflow-y: hidden;
// display: flex;
// flex-wrap: nowrap;
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
&.disable-scrollbars {
......@@ -95,8 +100,31 @@ m-shadowboxHeader {
}
}
}
.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;
......
......@@ -16,10 +16,10 @@ import {
})
export class ShadowboxHeaderComponent implements AfterViewInit {
@Input() isScrollable: boolean = true;
@Input() metricActivated;
@Input() itemActivated;
@ViewChild('shadowboxHeaderContainer', { static: false })
shadowboxHeaderContainerEl: ElementRef;
shadowboxHeaderContainer;
containerEl: ElementRef;
container;
childClientWidth: number;
faderWidth = 24;
......@@ -44,7 +44,8 @@ export class ShadowboxHeaderComponent implements AfterViewInit {
// ----------------------------------------------------
@HostListener('click', ['$event.target'])
onClick(target) {
console.log('***Clicked on: ', target);
// console.log('***Clicked on: ', target);
// find index of target
// this.slideToActiveMetric(metricIndex);
}
......@@ -73,38 +74,32 @@ export class ShadowboxHeaderComponent implements AfterViewInit {
// TODO: figure out how to avoid test failure "Cannot read property 'clientWidth' of null"
this.childClientWidth = firstMetric ? firstMetric.clientWidth : 160;
this.shadowboxHeaderContainer = this.shadowboxHeaderContainerEl.nativeElement;
this.container = this.containerEl.nativeElement;
this.isOverflown =
this.shadowboxHeaderContainer.scrollWidth -
this.shadowboxHeaderContainer.clientWidth >
0;
this.container.scrollWidth - this.container.clientWidth > 0;
this.isAtScrollStart =
this.shadowboxHeaderContainer.scrollLeft < this.faderWidth;
this.isAtScrollStart = this.container.scrollLeft < this.faderWidth;
this.showButton.left = this.isOverflown && !this.isAtScrollStart;
this.isAtScrollEnd =
!this.isOverflown ||
this.shadowboxHeaderContainer.scrollWidth -
(this.shadowboxHeaderContainer.scrollLeft +
this.shadowboxHeaderContainer.clientWidth) <
this.container.scrollWidth -
(this.container.scrollLeft + this.container.clientWidth) <
this.faderWidth;
this.showButton.right =
this.isOverflown &&
this.shadowboxHeaderContainer.scrollLeft >= 0 &&
!this.isAtScrollEnd;
this.isOverflown && this.container.scrollLeft >= 0 && !this.isAtScrollEnd;
this.detectChanges();
}
slide(direction) {
let currentScrollLeft = this.shadowboxHeaderContainer.scrollLeft;
let currentScrollLeft = this.container.scrollLeft;
let targetScrollLeft;
let scrollEndOffset = 0;
const partiallyVisibleMetricWidth =
this.shadowboxHeaderContainer.clientWidth % this.childClientWidth;
this.container.clientWidth % this.childClientWidth;
const completelyVisibleMetricsWidth =
this.shadowboxHeaderContainer.clientWidth - partiallyVisibleMetricWidth;
this.container.clientWidth - partiallyVisibleMetricWidth;
if (direction === 'right') {
if (currentScrollLeft < this.faderWidth) {
......@@ -112,8 +107,7 @@ export class ShadowboxHeaderComponent implements AfterViewInit {
}
targetScrollLeft = Math.min(
currentScrollLeft + completelyVisibleMetricsWidth,
this.shadowboxHeaderContainer.scrollWidth -
completelyVisibleMetricsWidth
this.container.scrollWidth - completelyVisibleMetricsWidth
);
} else {
if (this.isAtScrollEnd) {
......@@ -125,7 +119,7 @@ export class ShadowboxHeaderComponent implements AfterViewInit {
);
}
this.shadowboxHeaderContainer.scrollTo({
this.container.scrollTo({
top: 0,
left: targetScrollLeft,
behavior: 'smooth',
......
......@@ -2,12 +2,23 @@
*ngIf="hasHeader"
[isScrollable]="scrollableHeader"
[ngClass]="{ isScrollable: scrollableHeader }"
><ng-content
>
<ng-content
select=".m-shadowboxLayout__header"
ngProjectAs=".m-shadowboxLayout__header"
></ng-content
></m-shadowboxHeader>
<div class="m-shadowboxLayout__bottom">
></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>
......@@ -12,27 +12,121 @@ m-shadowboxHeader.isScrollable {
}
.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 {
min-height: 104px;
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
width: 100%;
@include m-theme() {
background-color: rgba(themed($m-grey-50), 0.25);
border-top: 1px solid rgba(themed($m-grey-50), 0.6);
}
> * {
margin: 30px 68px 30px 0;
}
.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() {
......@@ -44,4 +138,42 @@ m-shadowboxHeader.isScrollable {
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 { Component, OnInit, Input } from '@angular/core';
import { Component, Input, HostBinding } from '@angular/core';
@Component({
selector: 'm-shadowboxLayout',
templateUrl: './shadowbox-layout.component.html',
})
export class ShadowboxLayoutComponent implements OnInit {
export class ShadowboxLayoutComponent {
@Input() scrollableHeader: boolean = true;
@Input() hasHeader: boolean = true;
constructor() {}
@HostBinding('class.isForm') @Input() isForm: boolean = false;
ngOnInit() {}
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%;
}
}
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ShadowboxSubmitButtonComponent } from './shadowbox-submit-button.component';
describe('ShadowboxSubmitButtonComponent', () => {
let component: ShadowboxSubmitButtonComponent;
let fixture: ComponentFixture<ShadowboxSubmitButtonComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ShadowboxSubmitButtonComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ShadowboxSubmitButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component, OnInit, Input } from '@angular/core';
@Component({
selector: 'm-shadowboxSubmitButton',
templateUrl: './shadowbox-submit-button.component.html',
})
export class ShadowboxSubmitButtonComponent implements OnInit {
@Input() saveStatus: string = 'unsaved';
@Input() disabled: boolean = false;
constructor() {}
ngOnInit() {}
}
const sidebarMenuCategories = [
{
category: {
id: 'analytics',
label: 'Analytics',
path: '/analytics/dashboard/',
permissions: ['admin', 'user'],
},
subcategories: [
// {
// id: 'summary',
// label: 'Summary',
// permissions: ['admin', 'user'],
// },
{
id: 'traffic',
label: 'Traffic',
permissions: ['admin', 'user'],
},
{
id: 'earnings',
label: 'Earnings',
permissions: ['admin', 'user'],
},
{
id: 'engagement',
label: 'Engagement',
permissions: ['admin', 'user'],
},
{
id: 'trending',
label: 'Trending',
permissions: ['admin', 'user'],
},
// {
// id: 'referrers',
// label: 'Referrers',
// permissions: ['admin', 'user'],
// },
// {
// id: 'plus',
// label: 'Plus',
// permissions: ['admin'],
// },
// {
// id: 'pro',
// label: 'Pro',
// permissions: ['admin'],
// {
// id: 'boost',
// label: 'Boost',
// permissions: ['admin'],
// },
// {
// id: 'nodes',
// label: 'Nodes',
// permissions: ['admin'],
// },
],
},
// {
// category: {
// id: 'test1',
// label: 'Test1',
// permissions: ['admin', 'user'],
// path: '/somepath/bork',
// },
// subcategories: [
// {
// id: 'nodes',
// label: 'Nodes',
// permissions: ['admin'],
// },
// {
// id: 'nodes2',
// label: 'Nodes2',
// permissions: ['admin'],
// },
// ],
// },
// {
// category: {
// id: 'test2',
// label: 'Test2 no subcats',
// path: '/anotherpath/test2',
// },
// },
];
export default sidebarMenuCategories;
const sidebarMenuCategories = [
{
header: {
id: 'analytics',
label: 'Analytics',
path: '/analytics/dashboard/',
permissions: ['admin', 'user'],
},
links: [
{
id: 'summary',
label: 'Summary',
permissions: ['admin'],
},
{
id: 'traffic',
label: 'Traffic',
permissions: ['admin', 'user'],
},
{
id: 'earnings',
label: 'Earnings',
permissions: ['admin', 'user'],
},
{
id: 'engagement',
label: 'Engagement',
permissions: ['admin', 'user'],
// path: '/some/path/outside/header/path',
},
{
id: 'trending',
label: 'Trending',
permissions: ['admin', 'user'],
},
// {
// id: 'referrers',
// label: 'Referrers',
// permissions: ['admin', 'user'],
// },
// {
// id: 'plus',
// label: 'Plus',
// permissions: ['admin'],
// },
// {
// id: 'pro',
// label: 'Pro',
// permissions: ['admin'],
// {
// id: 'boost',
// label: 'Boost',
// permissions: ['admin'],
// },
// {
// id: 'nodes',
// label: 'Nodes',
// permissions: ['admin'],
// },
],
},
];
export default sidebarMenuCategories;
<div class="m-sidebarMenu__topbar">
<i class="material-icons" (click)="mobileMenuExpanded = true">menu</i>
<div class="m-sidebarMenu__topbarHeader">
{{ cat.header.label }}
{{ menu.header.label }}
</div>
</div>
<div
......@@ -29,38 +29,38 @@
</div> -->
</div>
</a>
<!-- <ng-container *ngFor="let cat of cats"> -->
<div
class="m-sidebarMenu__catContainer expanded"
*ngIf="cat.header.permissionGranted"
class="m-sidebarMenu__menuContainer expanded"
*ngIf="menu.header.permissionGranted"
>
<!-- [ngClass]="{ expanded: cat.header.expanded }" -->
<div class="m-sidebarMenu__header">
<h3>{{ cat.header.label }}</h3>
<!-- <i
class="material-icons"
*ngIf="cat.header.expanded && cat.links"
(click)="cat.header.expanded = false"
>keyboard_arrow_up</i
> -->
<!-- <i class="material-icons" *ngIf="!cat.header.expanded && cat.links"
>keyboard_arrow_down</i
> -->
<!-- (click)="cat.header.expanded = true" -->
<h3>{{ menu.header.label }}</h3>
</div>
<nav class="m-sidebarMenu__linksContainer" *ngIf="cat.links">
<div class="m-sidebarMenu__link" *ngFor="let link of cat.links">
<a
*ngIf="link.permissionGranted"
(click)="mobileMenuExpanded = false"
[routerLink]="link.path ? '/' + link.path : '../' + link.id"
routerLinkActive="selected"
>{{ link.label }}</a
<nav class="m-sidebarMenu__linksContainer" *ngIf="menu.links">
<div class="m-sidebarMenu__link" *ngFor="let link of menu.links">
<ng-container *ngIf="link.permissionGranted">
<ng-container *ngIf="!link.newWindow">
<a
(click)="mobileMenuExpanded = false"
[routerLink]="link.path ? '/' + link.path : '../' + link.id"
routerLinkActive="selected"
[routerLinkActiveOptions]="{ exact: true }"
>
<span>{{ link.label }}</span></a
>
</ng-container>
<ng-container *ngIf="link.newWindow"
><a
[routerLink]="link.path ? ['/' + link.path] : ['../' + link.id]"
target="_blank"
class="newWindow"
><i class="material-icons">launch</i>
<span>{{ link.label }}</span></a
></ng-container
></ng-container
>
</div>
</nav>
</div>
<!-- </ng-container> -->
</div>
......@@ -7,7 +7,7 @@ m-sidebarMenu {
.m-sidebarMenu__userWrapper {
display: none;
}
.m-sidebarMenu__catContainer {
.m-sidebarMenu__menuContainer {
.m-sidebarMenu__linksContainer {
display: block;
cursor: pointer;
......@@ -17,6 +17,15 @@ m-sidebarMenu {
.m-sidebarMenu__sidebar {
position: fixed;
top: 157px;
bottom: 0;
overflow-x: hidden;
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.m-sidebarMenu__header {
......@@ -35,7 +44,6 @@ m-sidebarMenu {
.m-sidebarMenu__linksContainer {
cursor: pointer;
display: none;
.m-sidebarMenu__link {
a {
display: block;
......@@ -45,6 +53,16 @@ m-sidebarMenu {
@include m-theme() {
color: themed($m-grey-300);
}
&.newWindow {
margin-top: 50px;
display: flex;
align-items: center;
i {
margin-right: 10px;
font-size: 13px;
font-weight: bold;
}
}
}
a.selected,
&:hover a {
......@@ -52,6 +70,9 @@ m-sidebarMenu {
color: themed($m-blue);
}
}
&:last-child {
padding-bottom: 36px;
}
}
}
}
......@@ -127,7 +148,7 @@ m-sidebarMenu {
&.mobileMenuExpanded {
left: 0;
}
.m-sidebarMenu__catContainer {
.m-sidebarMenu__menuContainer {
.m-sidebarMenu__linksContainer {
display: none;
}
......
......@@ -4,7 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { Session } from '../../../services/session';
import { sessionMock } from '../../../../tests/session-mock.spec';
import { SidebarMenuComponent } from './sidebar-menu.component';
import sidebarMenuCategories from './sidebar-menu-categories.default';
import sidebarMenu from '../../../modules/pro/settings/sidebar-menu.default';
describe('SidebarMenuComponent', () => {
let component: SidebarMenuComponent;
......@@ -21,7 +21,7 @@ describe('SidebarMenuComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(SidebarMenuComponent);
component = fixture.componentInstance;
component.catId = 'analytics';
component.menu = sidebarMenu;
// component.user = sessionMock.user;
fixture.detectChanges();
});
......
import { Component, OnInit, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Session } from '../../../services/session';
import sidebarMenuCategories from './sidebar-menu-categories.default';
interface MenuCategory {
interface Menu {
header: MenuLink;
links?: MenuLink[];
expanded?: boolean;
}
export { MenuCategory };
export { Menu };
interface MenuLink {
id: string;
......@@ -16,6 +15,7 @@ interface MenuLink {
permissions?: string[];
permissionGranted?: boolean;
path?: string;
newWindow?: boolean;
}
export { MenuLink };
......@@ -24,11 +24,9 @@ export { MenuLink };
templateUrl: './sidebar-menu.component.html',
})
export class SidebarMenuComponent implements OnInit {
@Input() catId: string;
@Input() menu: Menu;
cat: MenuCategory;
mobileMenuExpanded = false;
// activeCat;
minds: Minds;
user;
userRoles: string[] = ['user'];
......@@ -38,38 +36,38 @@ export class SidebarMenuComponent implements OnInit {
ngOnInit() {
this.minds = window.Minds;
this.user = this.session.getLoggedInUser();
this.cat = sidebarMenuCategories.find(cat => cat.header.id === this.catId);
this.getUserRoles();
this.grantPermissionsAndFindActiveCat();
this.grantPermissions();
}
getUserRoles() {
if (this.session.isAdmin()) {
this.userRoles.push('admin');
}
// TODO: define & handle other userRole options, e.g. pro, loggedIn
if (this.minds.user.pro) {
this.userRoles.push('pro');
}
}
grantPermissionsAndFindActiveCat() {
// this.cat.forEach(this.cat => {
this.cat.header['permissionGranted'] = this.cat.header.permissions
? this.checkForRoleMatch(this.cat.header.permissions)
grantPermissions() {
this.menu.header['permissionGranted'] = this.menu.header.permissions
? this.checkForRoleMatch(this.menu.header.permissions)
: true;
if (this.cat.links) {
this.cat.links.forEach(link => {
if (this.menu.links) {
this.menu.links.forEach(link => {
link['permissionGranted'] = link.permissions
? this.checkForRoleMatch(link.permissions)
: true;
if (link.id === ':username') {
link.id = this.user.username;
}
if (link.path) {
link.path = link.path.replace(':username', this.user.username);
}
});
}
// if (location.pathname.indexOf(this.cats.header.path) !== -1) {
// this.cats.header['expanded'] = true;
// this.activeCat = this.cat;
// } else {
// this.cat.header['expanded'] = false;
// }
// });
}
checkForRoleMatch(permissionsArray) {
......
......@@ -14,7 +14,6 @@ export class BlockListService {
protected storage: Storage
) {
this.blocked = new BehaviorSubject(JSON.parse(this.storage.get('blocked')));
this.fetch();
}
fetch() {
......
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
export interface FormToast {
type?: 'success' | 'error' | 'warning' | 'info' | null;
message?: string;
}
@Injectable()
export class FormToastService {
private subject = new Subject<FormToast>();
constructor() {}
onToast(): Observable<FormToast> {
return this.subject.asObservable();
}
success(message: string) {
const toast: FormToast = {
message: message,
type: 'success',
};
this.trigger(toast);
}
error(message: string) {
const toast: FormToast = {
message: message,
type: 'error',
};
this.trigger(toast);
}
warn(message: string) {
const toast: FormToast = {
message: message,
type: 'warning',
};
this.trigger(toast);
}
inform(message: string) {
const toast: FormToast = {
message: message,
type: 'info',
};
this.trigger(toast);
}
trigger(toast: FormToast) {
if (!toast.type) {
toast.type = 'info';
}
this.subject.next(toast);
}
}
import { Injectable } from '@angular/core';
import { SiteService } from './site.service';
import { Client } from '../../services/api/client';
import { Session } from '../../services/session';
@Injectable()
export class SsoService {
protected readonly minds = window.Minds;
constructor(
protected site: SiteService,
protected client: Client,
protected session: Session
) {
this.listen();
}
listen() {
this.session.isLoggedIn((is: boolean) => {
if (is) {
this.auth();
}
});
}
isRequired(): boolean {
return this.site.isProDomain;
}
async connect() {
try {
const connect: any = await this.client.postRaw(
`${this.minds.site_url}api/v2/sso/connect`
);
if (connect && connect.token && connect.status === 'success') {
const authorization: any = await this.client.post(
'api/v2/sso/authorize',
{
token: connect.token,
}
);
if (authorization && authorization.user) {
this.session.inject(authorization.user);
}
}
} catch (e) {
console.error(e);
}
}
async auth() {
try {
const connect: any = await this.client.post('api/v2/sso/connect');
if (connect && connect.token && connect.status === 'success') {
await this.client.postRaw(
`${this.minds.site_url}api/v2/sso/authorize`,
{
token: connect.token,
}
);
}
} catch (e) {
console.error(e);
}
}
}
......@@ -114,9 +114,7 @@
></minds-admin-monetization>
<minds-admin-programs *ngIf="filter == 'programs'"></minds-admin-programs>
<minds-admin-payouts *ngIf="filter == 'payouts'"></minds-admin-payouts>
<minds-admin-withdrawals
*ngIf="filter == 'withdrawals'"
></minds-admin-withdrawals>
<m-admin-withdrawals *ngIf="filter == 'withdrawals'"></m-admin-withdrawals>
<minds-admin-featured *ngIf="filter == 'featured'"></minds-admin-featured>
<minds-admin-tagcloud *ngIf="filter == 'tagcloud'"></minds-admin-tagcloud>
<m-admin--verify *ngIf="filter == 'verify'"></m-admin--verify>
......
<div class="mdl-grid m-admin-withdrawals" style="max-width: 900px">
<div class="m-admin-withdrawals">
<div class="m-admin-withdrawals__legend">
<ng-container *ngIf="user" i18n>
<b>@{{ user }}</b
>'s withdrawals
</ng-container>
<ng-container *ngIf="!user" i18n>
Pending Withdrawals
</ng-container>
</div>
<div
class="m-admin-withdrawals--user"
*ngIf="user"
i18n="@@M__ADMIN__WITHDRAWALS__USERNAME_TITLE"
class="m-admin-withdrawals__card"
*ngFor="let request of withdrawals; let i = index"
>
<b>@{{ user }}</b
>'s withdrawals
</div>
<ng-container *ngIf="request">
<div class="m-admin-withdrawalsCard__cell">
<div class="m-admin-withdrawalsCardCell__label" i18n>Requested</div>
<table class="mdl-data-table mdl-js-data-table m-border" [mdl]>
<thead>
<tr class="m-admin--withdrawals--ledger-header">
<th
class="mdl-data-table__cell--non-numeric m-data-title"
i18n="@@M__ADMIN__WITHDRAWALS__DATE_COL"
>
Date
</th>
<th
class="mdl-data-table__cell--non-numeric m-data-title"
i18n="Transaction@@M__ADMIN__WITHDRAWALS__TX_COL"
>
Tx
</th>
<th
class="mdl-data-table__cell--non-numeric m-data-title"
i18n="@@M__ADMIN__WITHDRAWALS__AMOUNT_COL"
>
Amount
</th>
<th
class="mdl-data-table__cell--non-numeric m-data-title"
i18n="@@M__ADMIN__WITHDRAWALS__COMPLETED_COL"
>
Completed?
</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let withdrawal of withdrawals; let i = index">
<tr class="m-admin--withdrawals--ledger-row">
<td class="mdl-data-table__cell--non-numeric">
{{ withdrawal.timestamp * 1000 | date: 'short' }}
</td>
<td
class="mdl-data-table__cell--non-numeric m-admin--withdrawals--ledger-tx"
<div class="m-admin-withdrawalsCardCell__body">
{{ request.timestamp * 1000 | date: 'short' }}
</div>
</div>
<div class="m-admin-withdrawalsCard__cell">
<div class="m-admin-withdrawalsCardCell__label" i18n>User</div>
<div class="m-admin-withdrawalsCardCell__body">
<a [routerLink]="['/', request.user?.username]"
>@{{ request.user?.username }}</a
>
{{ withdrawal.tx }}
</td>
<td>{{ withdrawal.amount | token: 18 | number: '1.0-4' }}</td>
<td class="mdl-data-table__cell--non-numeric">
{{ withdrawal.completed ? 'Yes' : 'No' }}
</td>
</tr>
</ng-container>
<tr
class="m-admin--withdrawals--ledger-row m-wire-console--ledger-row-placeholder"
*ngIf="!inProgress && (!withdrawals || !withdrawals.length)"
>
<td
colspan="4"
style="text-align:center"
i18n="@@M__ADMIN__WITHDRAWALS__NO_WITHDRAWALS_NOTE"
</div>
</div>
<div class="m-admin-withdrawalsCard__cell">
<div class="m-admin-withdrawalsCardCell__label" i18n>Signed up</div>
<div class="m-admin-withdrawalsCardCell__body">
{{ request.user?.time_created * 1000 | date: 'shortDate' }}
</div>
</div>
<div class="m-admin-withdrawalsCard__cell">
<div class="m-admin-withdrawalsCardCell__label" i18n>Referrer</div>
<div class="m-admin-withdrawalsCardCell__body">
<ng-container *ngIf="request.referrer; else organicText">
<a [routerLink]="['/', request.referrer.username]"
>@{{ request.referrer.username }}</a
>
</ng-container>
<ng-template #organicText>
<ng-container>(organic)</ng-container>
</ng-template>
</div>
</div>
<div class="m-admin-withdrawalsCard__cell">
<div class="m-admin-withdrawalsCardCell__label" i18n>Amount</div>
<div
class="m-admin-withdrawalsCardCell__body m-admin-withdrawalsCardCell__body--bolder"
>
No withdrawals to show. You can access a user's withdrawal ledger by
using the admin drop-down on their channel.
</td>
</tr>
</tbody>
</table>
{{ request.amount | token: 18 | number: '1.0-4' }}
</div>
</div>
<div class="m-admin-withdrawalsCard__cell">
<div class="m-admin-withdrawalsCardCell__label" i18n>Status</div>
<div class="m-admin-withdrawalsCardCell__body">
{{ request.status?.replace('_', ' ') | uppercase }}
</div>
</div>
<div
class="m-admin-withdrawalsCard__cell m-admin-withdrawalsCard__cell--actions"
*ngIf="request.status === 'pending_approval'"
>
<div class="m-admin-withdrawalsCardCell__body">
<a
[routerLink]="[
'/admin/withdrawals',
{ user: request.user?.username }
]"
>History</a
>
<button
class="mf-button mf-button--smaller"
[disabled]="inProgress"
(click)="approve(request)"
>
Approve
</button>
<button
class="mf-button mf-button--destructive mf-button--smaller"
[disabled]="inProgress"
(click)="reject(request)"
>
Reject
</button>
</div>
</div>
</ng-container>
</div>
<div
class="m-admin-withdrawals__notice"
*ngIf="!inProgress && !withdrawals?.length"
>
No withdrawals to show. You can access a user's ledger by using the admin
drop-down on their channel.
</div>
<infinite-scroll
distance="25%"
(load)="load()"
[moreData]="moreData"
[inProgress]="inProgress"
>
</infinite-scroll>
></infinite-scroll>
</div>
.m-admin-withdrawals {
.mdl-data-table {
width: 100%;
max-width: 900px;
margin: 16px auto 0;
.m-admin-withdrawals__legend {
font-size: 16px;
font-weight: 600;
text-align: center;
margin: 8px 0 16px;
letter-spacing: 1px;
}
.m-admin--withdrawals--ledger-tx {
font-size: 12px;
.m-admin-withdrawals__card {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 600px;
margin: 0 auto 32px;
padding: 8px;
border: 1px solid;
border-radius: 4px;
@include m-theme() {
background: themed($m-white);
color: themed($m-black);
border-color: themed($m-grey-50);
}
}
.m-admin-withdrawalsCard__cell {
flex-grow: 1;
flex-shrink: 0;
box-sizing: border-box;
min-width: 25%;
padding: 8px;
&--actions {
width: 100%;
text-align: right;
padding-top: 24px;
margin-top: 16px;
border-top: 1px dotted;
@include m-theme() {
border-color: themed($m-grey-50);
}
}
a {
color: inherit;
font: inherit;
text-decoration: none;
border-bottom: 1px dotted;
cursor: pointer;
@include m-theme() {
border-color: themed($m-grey-300);
}
}
.m-admin-withdrawalsCardCell__label {
font-weight: 600;
margin-bottom: 2px;
@include m-theme() {
color: themed($m-grey-300);
}
}
.m-admin-withdrawalsCardCell__body {
&--bolder {
font-weight: bold;
letter-spacing: 0.5px;
}
}
&--actions .m-admin-withdrawalsCardCell__body {
> * {
display: inline-block;
vertical-align: middle;
margin: 0 8px 8px;
}
}
}
.m-admin-withdrawals--user {
width: 100%;
font-size: 14px;
font-weight: 300;
.m-admin-withdrawals__notice {
max-width: 600px;
margin: 32px auto;
padding: 16px;
border: 1px solid;
border-radius: 4px;
text-align: center;
padding: 8px 8px 32px;
@include m-theme() {
background: themed($m-white);
color: themed($m-black);
border-color: themed($m-grey-50);
}
}
}
......@@ -4,7 +4,7 @@ import { ActivatedRoute } from '@angular/router';
@Component({
moduleId: module.id,
selector: 'minds-admin-withdrawals',
selector: 'm-admin-withdrawals',
templateUrl: 'withdrawals.component.html',
})
export class AdminWithdrawals {
......@@ -45,12 +45,19 @@ export class AdminWithdrawals {
this.inProgress = true;
const params = {
limit: 50,
offset: this.offset,
};
if (this.user) {
params['user'] = this.user;
} else {
params['status'] = 'pending_approval';
}
this.client
.get(`api/v2/admin/rewards/withdrawals`, {
limit: 50,
offset: this.offset,
user: this.user,
})
.get(`api/v2/admin/rewards/withdrawals`, params)
.then((response: any) => {
if (!response.withdrawals) {
this.inProgress = false;
......@@ -71,4 +78,58 @@ export class AdminWithdrawals {
this.inProgress = false;
});
}
async approve(withdrawal) {
if (!confirm("Do you want to approve this withdrawal? There's no UNDO.")) {
return;
}
this.inProgress = true;
try {
const endpoint = `api/v2/admin/rewards/withdrawals/${[
withdrawal.user_guid,
withdrawal.timestamp,
withdrawal.tx,
].join('/')}`;
await this.client.put(endpoint);
withdrawal.status = 'approved';
} catch (e) {
alert(
`There was an issue while approving withdrawal: ${(e && e.message) ||
'Unknown server error'}`
);
}
this.inProgress = false;
}
async reject(withdrawal) {
if (!confirm("Do you want to reject this withdrawal? There's no UNDO.")) {
return;
}
this.inProgress = true;
try {
const endpoint = `api/v2/admin/rewards/withdrawals/${[
withdrawal.user_guid,
withdrawal.timestamp,
withdrawal.tx,
].join('/')}`;
await this.client.delete(endpoint);
withdrawal.status = 'rejected';
} catch (e) {
alert(
`There was an issue while rejecting withdrawal: ${(e && e.message) ||
'Unknown server error'}`
);
}
this.inProgress = false;
}
}
......@@ -71,7 +71,7 @@ const routes: Routes = [
path: 'analytics',
component: AnalyticsComponent,
children: [
{ path: '', redirectTo: 'dashboard/', pathMatch: 'full' },
{ path: '', redirectTo: 'dashboard/traffic', pathMatch: 'full' },
{
path: 'admin',
component: AdminAnalyticsComponent,
......@@ -83,7 +83,7 @@ const routes: Routes = [
],
},
{
path: 'dashboard/',
path: 'dashboard',
redirectTo: 'dashboard/traffic',
pathMatch: 'full',
},
......
<m-pageLayout navId="analytics">
<m-pageLayout [menu]="menu">
<div class="m-analyticsDashboard" *ngIf="ready$ | async" m-pageLayout__main>
<m-dashboardLayout>
<ng-container m-dashboardLayout__header>
......@@ -34,7 +34,7 @@
{{ description }}
<ng-container *ngIf="(category$ | async) === 'earnings'">
<a *ngIf="!session.getLoggedInUser().pro" routerLink="/pro"
>Upgrade to PRO</a
>Upgrade to Pro</a
>
<a
*ngIf="
......
......@@ -53,13 +53,6 @@ m-analytics__dashboard {
@include m-theme() {
color: themed($m-grey-300);
}
a {
font-weight: 400;
text-decoration: none;
@include m-theme() {
color: themed($m-blue);
}
}
}
@media screen and (max-width: $min-tablet) {
......
......@@ -16,6 +16,8 @@ import { Session } from '../../../services/session';
import { AnalyticsDashboardService } from './dashboard.service';
import { Filter } from './../../../interfaces/dashboard';
import sidebarMenu from './sidebar-menu.default';
import { Menu } from '../../../common/components/sidebar-menu/sidebar-menu.component';
@Component({
selector: 'm-analytics__dashboard',
......@@ -24,6 +26,7 @@ import { Filter } from './../../../interfaces/dashboard';
providers: [AnalyticsDashboardService],
})
export class AnalyticsDashboardComponent implements OnInit, OnDestroy {
menu: Menu = sidebarMenu;
paramsSubscription: Subscription;
ready$ = this.analyticsService.ready$;
......@@ -63,8 +66,6 @@ export class AnalyticsDashboardComponent implements OnInit, OnDestroy {
this.updateCategory(cat);
if (cat === 'summary') {
this.layout = 'summary';
} else {
this.layout = 'chart';
}
});
......
const sidebarMenu = {
header: {
id: 'analytics',
label: 'Analytics',
permissions: ['admin', 'user'],
},
links: [
{
id: 'summary',
label: 'Summary',
permissions: ['admin'],
// path: '/some/path/outside/analytics/dashboard',
},
{
id: 'traffic',
label: 'Traffic',
permissions: ['admin', 'user'],
},
{
id: 'earnings',
label: 'Earnings',
permissions: ['admin', 'user'],
},
{
id: 'engagement',
label: 'Engagement',
permissions: ['admin', 'user'],
},
{
id: 'trending',
label: 'Trending',
permissions: ['admin', 'user'],
},
// {
// id: 'referrers',
// label: 'Referrers',
// permissions: ['admin', 'user'],
// },
// {
// id: 'plus',
// label: 'Plus',
// permissions: ['admin'],
// },
// {
// id: 'pro',
// label: 'Pro',
// permissions: ['admin'],
// {
// id: 'boost',
// label: 'Boost',
// permissions: ['admin'],
// },
// {
// id: 'nodes',
// label: 'Nodes',
// permissions: ['admin'],
// },
],
};
export default sidebarMenu;
......@@ -196,14 +196,7 @@ export class ChannelSidebar {
}
get proSettingsRouterLink() {
const isAdmin = window.Minds.Admin;
const route: any[] = ['/pro/settings'];
if (isAdmin) {
route.push({ user: this.user.username });
}
const route: any[] = ['/pro/' + this.user.username + '/settings'];
return route;
}
}
......@@ -16,10 +16,19 @@
Free your mind and get paid for creating content, driving traffic and
referring friends. A place to have open conversations and bring people
together.
<br />
Take back control of your social media!
</p>
<p class="m-marketing__description" i18n>
Take back control of your social media
</p>
<button
class="mf-button mf-button--alt m-homepage__joinButton"
(click)="goToLoginPage()"
i18n
>
Join Minds Now
</button>
<div class="m-homepage__appButtons">
<a
href="https://itunes.apple.com/app/minds-com/id961771928"
......@@ -135,25 +144,17 @@
<ol class="m-marketing__description">
<li>
Our code is free and open source for maximum transparency and
accountability.
</li>
<li>
Our content policy is based on the First Amendment in order to
minimize bias and censorship.
</li>
<li>
Open discourse is essential to solve global issues and change
minds.
</li>
<li>
You control your privacy and algorithms.
Our code is free and open source for maximum transparency,
democratization and accountability.
</li>
<li>
You are rewarded for your contributions.
Our content policy is based on the First Amendment to minimize
bias and censorship. Open discourse is essential to solve global
issues and change minds.
</li>
<li>
Build a resilient, democratized and self-sustainable network.
You deserve to be fairly rewarded for your contributions to the
network. We share our revenue with you.
</li>
</ol>
</div>
......@@ -180,40 +181,6 @@
</div>
</div>
<!-- Section 4 -->
<div class="m-marketing__section m-marketing__section--style-5">
<div class="m-grid m-marketing__wrapper">
<div
class="m-grid__column-6 m-grid__column-6--tablet m-grid__column-12--mobile m-marketing__body"
>
<h2 i18n>Open Discourse</h2>
<p class="m-marketing__description" i18n>
We believe that legal open discourse is the key to reducing violence
and radicalization, not censorship. All speech that is legal in the
United States can be posted to Minds, and we encourage open dialogue
to help bridge the gap in our world today.
</p>
<p class="m-marketing__description" i18n>
We are working closely with thought leaders like Daryl Davis on
deradicalizing through conversation and respect.
</p>
</div>
<div
class="m-grid__column-6 m-grid__column-6--tablet m-grid__column-12--mobile m-marketing__image"
>
<span>
<img
class="m-marketing__image--1"
[src]="cdnAssetsUrl + 'assets/homepage/homepage-4.png'"
/>
</span>
</div>
</div>
</div>
<!-- Final section -->
<div class="m-marketing__section m-marketing__section--tail">
......@@ -225,7 +192,7 @@
<div class="m-marketing__actionButtons">
<button
class="mf-button mf-button--alt mf-button--gradient"
class="mf-button mf-button--alt"
(click)="goToLoginPage()"
i18n
>
......
@import 'defaults';
m-homepage {
m-marketing {
margin-top: -52px;
padding-top: 52px;
}
.mf-button--alt {
background: #5dbac0 !important;
border-color: #5dbac0 !important;
}
.m-marketing__main {
&.m-marketing__section--style-2 {
.m-marketing__body {
&::before {
transform: translate(311px, -80px);
clip-path: polygon(57% 0, 100% 0, 100% 15%, 18% 87%);
transform: translate(311px, -123px);
clip-path: polygon(57% 0, 100% 0, 100% 15%, 15% 90%);
bottom: -80px;
@include m-theme() {
......@@ -14,6 +22,10 @@ m-homepage {
}
}
.m-homepage__joinButton {
margin-bottom: 120px;
}
&::after {
content: '';
clip-path: polygon(0 46%, 100% 0, 100% 100%, 0 100%);
......
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Subscription } from 'rxjs';
......@@ -23,6 +23,8 @@ export class SignupOnScrollModal implements OnInit, OnDestroy {
routerSubscription: Subscription;
@Input() disableScrollListener: true;
@ViewChild('modal', { static: true }) modal: SignupModal;
constructor(
......@@ -41,6 +43,10 @@ export class SignupOnScrollModal implements OnInit, OnDestroy {
}
listen() {
if (!this.disableScrollListener) {
return;
}
this.routerSubscription = this.router.events.subscribe(
(navigationEvent: NavigationEnd) => {
try {
......@@ -90,7 +96,10 @@ export class SignupOnScrollModal implements OnInit, OnDestroy {
}
unListen() {
this.routerSubscription.unsubscribe();
if (this.routerSubscription) {
this.routerSubscription.unsubscribe();
}
this.unlistenScroll();
}
......
......@@ -124,4 +124,6 @@
</div>
<m-overlay-modal #overlayModal></m-overlay-modal>
<m-modal-signup-on-scroll></m-modal-signup-on-scroll>
<m-modal-signup-on-scroll
[disableScrollListener]="false"
></m-modal-signup-on-scroll>
......@@ -229,7 +229,7 @@ export class ProChannelComponent implements OnInit, AfterViewInit, OnDestroy {
this.detectChanges();
try {
this.channel = await this.channelService.loadAndAuth(this.username);
this.channel = await this.channelService.load(this.username);
this.bindCssVariables();
this.shouldOpenWireModal();
......
......@@ -74,21 +74,16 @@ export class ProChannelService implements OnDestroy {
this.isLoggedIn$.unsubscribe();
}
async loadAndAuth(id: string): Promise<MindsUser> {
async load(id: string): Promise<MindsUser> {
try {
this.currentChannel = void 0;
const response = (await this.client.get(`api/v2/pro/channel/${id}`)) as {
channel;
me?;
};
this.currentChannel = response.channel;
if (this.site.isProDomain && response.me) {
this.session.login(response.me);
}
if (!this.currentChannel.pro_settings.tag_list) {
this.currentChannel.pro_settings.tag_list = [];
}
......@@ -111,7 +106,6 @@ export class ProChannelService implements OnDestroy {
try {
const response = (await this.client.get(`api/v2/pro/channel/${id}`)) as {
channel;
me?;
};
this.currentChannel = response.channel;
......
......@@ -23,7 +23,7 @@
<m-pro__subscribeButton (onAction)="closeMenu()"></m-pro__subscribeButton>
</li>
<li>
<li class="m-proHamburgerMenu__linkWrapper">
<a
[routerLink]="feedRouterLink"
routerLinkActive="m-proHamburgerMenu__item--active"
......@@ -91,7 +91,7 @@
<li class="m-proHamburgerMenu__spacer"></li>
<ng-container *ngIf="items && items.length">
<li *ngFor="let item of items">
<li *ngFor="let item of items" class="m-proHamburgerMenu__linkWrapper">
<a
[class.m-proHamburgerMenu__item--active]="item.isActive()"
(click)="item.onClick(); closeMenu()"
......
......@@ -26,6 +26,7 @@ m-pro__hamburger-menu {
top: 30px;
right: 40px;
padding: 8px;
cursor: pointer;
}
> ul {
......@@ -47,7 +48,9 @@ m-pro__hamburger-menu {
text-transform: uppercase;
font-weight: 700;
letter-spacing: 1px;
&.m-proHamburgerMenu__linkWrapper {
cursor: pointer;
}
&.m-proHamburgerMenu__logo {
height: 100px;
......@@ -114,3 +117,7 @@ m-pro__hamburger-menu {
body.hamburger-menu--open {
overflow: hidden;
}
.m-proHamburgerMenu__trigger {
cursor: pointer;
}
......@@ -9,13 +9,17 @@
</h1>
<h2 ngPreserveWhitespaces i18n>
The ultimate platform for <em>creators and brands</em>
A new revenue model for <em>creators</em>
</h2>
<p class="m-marketing__description" i18n>
Earn revenue for your content and upgrade your channel into a full
blown website with professional tools you need to do what you love.
</p>
<ul class="m-marketing__points" i18n>
<li i18n>Get paid for your traffic and referrals</li>
<li i18n>Launch your own website</li>
<li i18n>
Receive multi-currency tips and subscription payments from fans
</li>
<li i18n>Supports video, images, blogs and more</li>
</ul>
<div class="m-proMarketing__subscription">
<m-pro--subscription
......@@ -95,7 +99,7 @@
<p class="m-marketing__description" i18n>
Morph your channel into a full blown website with your own subdomain
or custom domain, logo, theme, categories and footer.
or custom domain, newsfeed, logo, theme, categories and footer.
</p>
<ul class="m-marketing__points">
......@@ -141,16 +145,18 @@
<div
class="m-grid__column-6 m-grid__column-12--mobile m-marketing__body"
>
<h2 i18n>Regain your freedom</h2>
<h2 i18n>Regain control of your business online</h2>
<p class="m-marketing__description" i18n>
Tired of the manipulation and unfair treatment from traditional
social networks?
Tired of demonetization, censorship and unfair treatment from
traditional social networks? Minds offers a fully transparent,
privacy-focused platform with no bias, hidden algorithms or
censorship.
</p>
<p class="m-marketing__description" i18n>
Minds offers a fully transparent, privacy-focused platform with no
bias, hidden algorithms or censorship.
Leverage the blockchain and crypto payments to eliminate the
middleman and maintain autonomy over your revenue streams.
</p>
</div>
<div
......@@ -166,56 +172,6 @@
</div>
</div>
<!-- Section 5 -->
<div class="m-marketing__section m-marketing__section--style-4">
<div class="m-grid m-marketing__wrapper">
<div
class="m-grid__column-6 m-grid__column-12--mobile m-marketing__body"
>
<h2 i18n>A sustainable revenue model for everyone.</h2>
<p class="m-marketing__description" i18n>
We are you. We are sharing our revenue with you to directly
compensate for contributions to the network. As a community-owned
company, it is essential to keep everyone's incentives aligned and
inspired.
</p>
<p class="m-marketing__description" i18n>
The more traffic and revenue that you drive, the more you earn and
the healthier the ecosystem becomes.
</p>
<div class="m-marketing__actionButtons">
<a
class="mf-button mf-button--smaller mf-button--hollow-mono"
routerLink="/upgrades"
target="_blank"
i18n
>
Learn About Upgrades
</a>
</div>
</div>
<div
class="m-grid__column-6 m-grid__column-12--mobile m-marketing__image"
>
<span>
<img
class="m-marketing__image--1"
[src]="cdnAssetsUrl + 'assets/product-pages/pro/pro-5.jpg'"
/>
<img
class="m-marketing__image--2"
[src]="cdnAssetsUrl + 'assets/product-pages/pro/pro-5-ux.png'"
/>
</span>
</div>
</div>
</div>
<!-- Final section -->
<div class="m-marketing__section m-marketing__section--tail">
......@@ -234,7 +190,9 @@
<h3 i18n>Professional media</h3>
<p class="m-marketing__description" i18n>
Upload and share up to 500 GB of HD video and photography (1080p).
Upload videos or images up to 60 minutes in length, 5 GB in size and
1080p in resolution. Publish your own blogs with our easy-to-use
editor.
</p>
</div>
......
......@@ -45,9 +45,11 @@ const routes: Routes = [
component: ProMarketingComponent,
},
{
path: 'settings',
component: ProSettingsComponent,
path: ':username/settings',
redirectTo: ':username/settings/general',
pathMatch: 'full',
},
{ path: ':username/settings/:tab', component: ProSettingsComponent },
{
path: ':username',
component: ProChannelComponent,
......
const sidebarMenu = {
header: {
id: 'pro_settings',
label: 'Pro Settings',
permissions: ['pro'],
},
links: [
{
id: 'general',
label: 'General',
},
{
id: 'theme',
label: 'Theme',
},
{
id: 'assets',
label: 'Assets',
},
{
id: 'hashtags',
label: 'Hashtags',
},
{
id: 'footer',
label: 'Footer',
},
{
id: 'domain',
label: 'Domain',
},
{
id: 'payouts',
label: 'Payouts',
},
{
id: 'subscription',
label: 'Pro Subscription',
path: 'pro',
},
{
id: ':username',
label: 'View Pro Channel',
path: 'pro/:username',
newWindow: true,
},
],
};
export default sidebarMenu;
......@@ -109,6 +109,17 @@
<span i18n="@@M__ACTION__SUGGESTED_HASHTAGS">Suggested Hashtags</span>
</a> -->
<a
class="m-page--sidebar--navigation--item"
*ngIf="session.getLoggedInUser().pro"
[routerLink]="[
'/pro/' + session.getLoggedInUser().username + '/settings'
]"
>
<i class="material-icons">business_center</i>
<span i18n>Pro Settings</span>
</a>
<a
class="m-page--sidebar--navigation--item"
(click)="openReferralsModal()"
......
......@@ -69,7 +69,7 @@
#withdrawPending
i18n="@@WALLET__TOKENS__WITHDRAW__LEDGER__PENDING_LABEL"
>
PENDING
{{ withdrawal.status.replace('_', ' ') | uppercase }}
</ng-template>
<div class="mdl-layout-spacer"></div>
......
......@@ -8,20 +8,23 @@
class="m-border mdl-color--white m-token-withdraw"
*ngIf="session.getLoggedInUser().rewards"
>
<p>
<ng-container i18n="@@WALLET__TOKENS__WITHDRAW__REQUEST_DESC">
You can request to withdraw your token rewards to your 'onchain' wallet
below. Note: a small amount of ETH will be charged to cover the
transaction fee. Withdrawals may take a few hours to complete.
</ng-container>
<ng-container
*ngIf="withholding"
i18n="@@WALLET__TOKENS__WITHDRAW__REQUEST_UNAVAILABLE_DESC"
>
{{ withholding | number }} tokens are unavailable due to credit card
payment. They will be released after 30 days the payment occurred.
</ng-container>
<p i18n>
You can request to withdraw up to {available, plural, =1
{{{available | number}} token} other {{{available | number}} tokens}} from
your rewards to your <b>OnChain</b> wallet below.
</p>
<p *ngIf="withholding" i18n>
{withholding, plural, =1
{{{withholding | number}} token} other {{{withholding | number}} tokens}}
are unavailable due to credit card payment. They will be released after 30
days the payment occurred.
</p>
<p class="m-token-withdraw__note" i18n>
Note: a small amount of ETH will be charged to cover the transaction fee.
Withdrawals <b>go through an approval process</b>
and may take up to 72 hours to complete.
</p>
<div class="m-token-withdraw--form">
......
......@@ -2,9 +2,18 @@
margin-bottom: $minds-padding;
padding: $minds-padding * 2;
> p {
margin-bottom: 8px;
&.m-token-withdraw__note {
font-size: 12px;
opacity: 0.7;
}
}
.m-token-withdraw--form {
display: flex;
margin-bottom: $minds-padding;
margin: 16px 0 8px;
.m-token-withdraw--input,
.m-token-withdraw--submit-button {
......
This diff is collapsed.
This diff is collapsed.
......@@ -46,6 +46,7 @@ import { AuthService } from './auth.service';
import { SiteService } from '../common/services/site.service';
import { SessionsStorageService } from './session-storage.service';
import { DiagnosticsService } from './diagnostics.service';
import { FormToastService } from '../common/services/form-toast.service';
export const MINDS_PROVIDERS: any[] = [
SiteService,
......@@ -67,12 +68,12 @@ export const MINDS_PROVIDERS: any[] = [
{
provide: Client,
useFactory: Client._,
deps: [HttpClient, Location, SiteService],
deps: [HttpClient, Location],
},
{
provide: Upload,
useFactory: Upload._,
deps: [HttpClient, SiteService],
deps: [HttpClient],
},
{
provide: Storage,
......@@ -112,6 +113,7 @@ export const MINDS_PROVIDERS: any[] = [
{
provide: Session,
useFactory: Session._,
deps: [SiteService],
},
{
provide: ThirdPartyNetworksService,
......@@ -234,4 +236,5 @@ export const MINDS_PROVIDERS: any[] = [
},
DiagnosticsService,
AuthService,
FormToastService,
];
This diff is collapsed.
src/assets/product-pages/pro/pro-1.jpg

235 KB | W: 900px | H: 1114px

src/assets/product-pages/pro/pro-1.jpg

693 KB | W: 1534px | H: 2301px

  • 2-up
  • Swipe
  • Onion skin