...
 
Commits (10)
/**
* @author Ben Hayward
* @desc E2E testing for Minds Pro's pages.
*/
context('Pro Page', () => {
const topBar = '.m-proChannel__topbar';
let categories = [
{ label: 'Technology', tag: '#technology' },
{ label: 'Food', tag: '#food' },
{ 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.get('#title').focus().clear().type('Title');
cy.get('#headline').focus().clear().type('This is a headline');
cy.contains('Hashtags')
.click();
// remove all hashtags
removeInputs();
for (let i = 0; i < categories.length; i++) {
let cat = categories[i];
addTag(cat.label, cat.tag, i);
}
cy.contains('Footer')
.click();
cy.get('#footer_text')
.clear()
.type('This is the footer text');
// remove all footer links
removeInputs();
for (let i = 0; i < footerLinks.length; i++) {
let link = footerLinks[i];
addFooterLink(link.label, link.link, i);
}
cy.contains('Save')
.click()
.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();
}
}
}
});
}
function addTag(label, tag, index) {
cy.contains('+ Add Tag')
.click();
cy.get(`#tag-label-${index}`)
.clear()
.type(label);
cy.get(`#tag-tag-${index}`)
.clear()
.type(tag);
}
function addFooterLink(label, link, index) {
cy.contains('Add Link')
.click();
cy.get(`#footer_link-title-${index}`)
.clear()
.type(label);
cy.get(`#footer_link-href-${index}`)
.clear()
.type(link);
}
before(() => {
cy.clearCookies();
cy.getCookie('minds_sess')
.then((sessionCookie) => {
if (sessionCookie === null) {
return cy.login(true);
}
});
// after logging in, we need to get to settings and set everything up
resetSettings();
// go to pro page
cy.visit(`/pro/${Cypress.env().username}`);
cy.get(topBar);
});
beforeEach(() => {
cy.server();
cy.preserveCookies();
});
after(() => {
cy.logout();
cy.clearCookies();
cy.login(false, 'minds','Password00!');
cy.visit(`/${Cypress.env().username}`);
cy.get(proButton).click();
});
it('should load the feed tab', () => {
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);
});
})
it('should load the videos tab', () => {
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);
});
})
it('should load the images tab', () => {
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);
});
// 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 .m-overlay-modal').should('not.be.visible');
// click on tile
cy.get('.m-proChannelListContent__list li:first-child m-pro--channel-tile').click();
cy.wait(200);
// media modal should appear
cy.get('m-pro--channel .m-overlay-modal').should('be.visible');
// 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.contains('Articles')
.click()
.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.contains('Groups')
.click()
.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);
});
})
})
/**
* @author Ben Hayward
* @desc E2E testing for Minds Pro's settings.
*/
context('Pro Settings', () => {
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',
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
},
logoGuid: '#logo_guid',
}
const hashtags = {
labelInput0: '#tag-label-0',
hashtagInput0: '#tag-tag-0',
labelInput1: '#tag-label-1',
hashtagInput1: '#tag-tag-1',
label1: 'label1',
label2: 'label2',
label3: 'label3',
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",
footerHref: 'https://www.minds.com/',
}
before(() => {
cy.clearCookies();
cy.getCookie('minds_sess')
.then((sessionCookie) => {
if (sessionCookie === null) {
return cy.login(true);
}
});
});
after(() => {
cy.visit("/pro/settings")
.location('pathname')
.should('eq', '/pro/settings');
clearHashtags();
});
beforeEach(()=> {
cy.preserveCookies();
cy.server();
cy.route("POST", "**/api/v2/pro/settings").as("settings");
cy.visit("/pro/settings")
.location('pathname')
.should('eq', '/pro/settings');
});
it('should update the title and headline', () => {
//enter data
cy.get(title)
.focus()
.clear()
.type(strings.title);
cy.get(headline)
.focus()
.clear()
.type(strings.headline);
saveAndPreview();
//check tab title.
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.get(theme.schemeDark)
.click();
saveAndPreview();
cy.contains('Feed')
.click();
cy.get(activityContainer)
.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.get(theme.schemeLight)
.click();
saveAndPreview();
cy.contains('Feed')
.click();
cy.get(activityContainer)
.should('have.css', 'background-color')
.and('eq', 'rgb(255, 255, 255)');
});
it('should allow the user to set category hashtags', () => {
cy.contains('Hashtags')
.click();
cy.contains('+ Add Tag')
.click();
cy.get(hashtags.labelInput0)
.clear()
.type(hashtags.label1);
cy.get(hashtags.hashtagInput0)
.clear()
.type(hashtags.hashtag1);
cy.contains('+ Add Tag')
.click();
cy.get(hashtags.labelInput1)
.first()
.clear()
.type(hashtags.label2);
cy.get(hashtags.hashtagInput1)
.first()
.clear()
.type(hashtags.hashtag2);
saveAndPreview();
//check the labels are present and clickable.
cy.contains('label1');
cy.contains('label2');
});
it('should allow the user to set footer', () => {
cy.contains('Footer')
.click();
cy.get(footerText)
.clear()
.type(strings.footer);
cy.contains('Add Link')
.click();
cy.get(footer.hrefInput)
.clear()
.type(strings.footerHref);
cy.get(footer.titleInput)
.clear()
.type(strings.footerTitle);
saveAndPreview();
cy.contains(strings.footerTitle)
.should('have.attr', 'href')
.should('contain', strings.footerHref);
});
//save, await response, preview.
function saveAndPreview() {
//save and await response
cy.contains('Save')
.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();
}
function clearHashtags() {
cy.contains('Hashtags')
.click();
cy.contains('+ Add Tag')
.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()
// .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();
// });
// })
})
......@@ -81,12 +81,13 @@ Cypress.Commands.add('login', (canary = false, username, password) => {
cy.server();
cy.route("POST", "/api/v1/authenticate").as("postLogin");
cy.get(loginForm.username).type(username);
cy.get(loginForm.password).type(password);
cy.get(loginForm.username).focus().type(username);
cy.get(loginForm.password).focus().type(password);
cy.get(loginForm.submit).click();
cy.wait('@postLogin').then((xhr) => {
cy.get(loginForm.submit)
.focus()
.click({force: true})
.wait('@postLogin').then((xhr) => {
expect(xhr.status).to.equal(200);
expect(xhr.response.body.status).to.equal('success');
});
......@@ -97,8 +98,7 @@ Cypress.Commands.add('login', (canary = false, username, password) => {
* @returns void
*/
Cypress.Commands.add('logout', () => {
cy.get(nav.hamburgerMenu).click();
cy.get(nav.logoutButton).click();
cy.visit('/logout')
});
/**
......
......@@ -13353,6 +13353,14 @@
"double-bits": "^1.1.0"
}
},
"ngx-drag-drop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ngx-drag-drop/-/ngx-drag-drop-2.0.0.tgz",
"integrity": "sha512-t+4/eiC8zaXKqU1ruNfFEfGs1GpMNwpffD0baopvZFKjQHCb5rhNqFilJ54wO4T0OwGp4/RnsVhlcxe1mX6UJg==",
"requires": {
"tslib": "^1.9.0"
}
},
"nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
......
......@@ -42,6 +42,7 @@
"material-design-icons": "~3.0.1",
"material-design-lite": "~1.3.0",
"medium-editor": "^5.23.2",
"ngx-drag-drop": "^2.0.0",
"plotly.js": "^1.47.4",
"qrcodejs2": "0.0.2",
"rxjs": "~6.5.2",
......
<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="!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-sidebar--markers
</m-v2-topbar>
<ng-template #legacyTopbar>
<m-topbar class="m-noshadow">
<ng-container search>
<m-search--bar></m-search--bar>
</ng-container>
<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>
<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"
......@@ -40,7 +45,7 @@
<router-outlet></router-outlet>
</m-body>
<m-messenger *ngIf="minds.LoggedIn"></m-messenger>
<m-messenger *ngIf="minds.LoggedIn && !isProDomain"></m-messenger>
<m-hovercard-popup></m-hovercard-popup>
......@@ -48,10 +53,10 @@
<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()"
*ngIf="session.isLoggedIn() && !isProDomain"
></m-juryDutySession__summons>
<m-modal-signup-on-scroll></m-modal-signup-on-scroll>
<m-modal-signup-on-scroll *ngIf="!isProDomain"></m-modal-signup-on-scroll>
<m-channel--onboarding *ngIf="showOnboarding"></m-channel--onboarding>
......
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
import { Component, HostBinding } from '@angular/core';
import { NotificationService } from './modules/notifications/notification.service';
import { AnalyticsService } from './services/analytics';
......@@ -7,18 +7,20 @@ import { Session } from './services/session';
import { LoginReferrerService } from './services/login-referrer.service';
import { ScrollToTopService } from './services/scroll-to-top.service';
import { ContextService } from './services/context.service';
import { BlockchainService } from './modules/blockchain/blockchain.service';
import { Web3WalletService } from './modules/blockchain/web3-wallet.service';
import { Client } from './services/api/client';
import { WebtorrentService } from './modules/webtorrent/webtorrent.service';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { ChannelOnboardingService } from './modules/onboarding/channel/onboarding.service';
import { BlockListService } from './common/services/block-list.service';
import { FeaturesService } from './services/features.service';
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 { Subscription } from 'rxjs';
import { RouterHistoryService } from './common/services/router-history.service';
import { PRO_DOMAIN_ROUTES } from './modules/pro/pro.module';
@Component({
moduleId: module.id,
......@@ -35,6 +37,8 @@ export class Minds {
paramsSubscription;
protected router$: Subscription;
constructor(
public session: Session,
public route: ActivatedRoute,
......@@ -54,20 +58,30 @@ export class Minds {
public themeService: ThemeService,
private bannedService: BannedService,
private diagnostics: DiagnosticsService,
private routerHistoryService: RouterHistoryService
private routerHistoryService: RouterHistoryService,
private site: SiteService
) {
this.name = 'Minds';
if (this.site.isProDomain) {
this.router.resetConfig(PRO_DOMAIN_ROUTES);
}
}
async ngOnInit() {
this.diagnostics.setUser(this.minds.user);
this.diagnostics.listen(); // Listen for user changes
this.notificationService.getNotifications();
if (!this.site.isProDomain) {
this.notificationService.getNotifications();
}
this.session.isLoggedIn(async is => {
if (is) {
this.showOnboarding = await this.onboardingService.showModal();
if (is && !this.site.isProDomain) {
if (!this.site.isProDomain) {
this.showOnboarding = await this.onboardingService.showModal();
}
if (this.minds.user.language !== this.minds.language) {
console.log(
'[app]:: language change',
......@@ -113,4 +127,16 @@ export class Minds {
this.scrollToTop.unlisten();
this.paramsSubscription.unsubscribe();
}
@HostBinding('class') get cssColorSchemeOverride() {
if (!this.site.isProDomain || !this.site.pro.scheme) {
return '';
}
return `m-theme--wrapper m-theme--wrapper__${this.site.pro.scheme}`;
}
get isProDomain() {
return this.site.isProDomain;
}
}
......@@ -68,6 +68,8 @@ import { IssuesModule } from './modules/issues/issues.module';
import { CanaryModule } from './modules/canary/canary.module';
import { HttpClientModule } from '@angular/common/http';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { ProModule } from './modules/pro/pro.module';
import { ChannelContainerModule } from './modules/channel-container/channel-container.module';
import * as Sentry from '@sentry/browser';
......@@ -104,6 +106,7 @@ export class SentryErrorHandler implements ErrorHandler {
RouterModule.forRoot(MindsAppRoutes, { onSameUrlNavigation: 'reload' }),
CaptchaModule,
CommonModule,
ProModule, // NOTE: Pro Module should be declared _BEFORE_ anything else
AnalyticsModule,
WalletModule,
//CheckoutModule,
......@@ -143,9 +146,10 @@ export class SentryErrorHandler implements ErrorHandler {
MobileModule,
IssuesModule,
CanaryModule,
ChannelsModule,
//last due to :username route
ChannelsModule,
ChannelContainerModule,
],
providers: [
{ provide: ErrorHandler, useClass: SentryErrorHandler },
......
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) {
return new MindsHttpClient(http);
static _(http: HttpClient, site: SiteService) {
return new MindsHttpClient(http, site);
}
constructor(public http: HttpClient) {}
constructor(public http: HttpClient, protected site: SiteService) {
if (this.site.isProDomain) {
this.base = window.Minds.site_url;
this.origin = document.location.host;
}
}
/**
* Return a GET request
......@@ -61,21 +69,35 @@ export class MindsHttpClient {
.join('&');
}
x;
/**
* Build the options
*/
private buildOptions(options: Object) {
const XSRF_TOKEN = this.cookie.get('XSRF-TOKEN') || '';
const headers = new HttpHeaders({
const headers = {
'X-XSRF-TOKEN': XSRF_TOKEN,
'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;
}
return Object.assign(options, {
headers: headers,
const builtOptions = {
headers: new HttpHeaders(headers),
cache: true,
});
};
if (this.origin) {
builtOptions['withCredentials'] = true;
}
return Object.assign(options, builtOptions);
}
}
......
......@@ -106,9 +106,18 @@ import { PosterDateSelectorComponent } from './components/poster-date-selector/s
import { ChannelModeSelectorComponent } from './components/channel-mode-selector/channel-mode-selector.component';
import { ShareModalComponent } from '../modules/modals/share/share';
import { RouterHistoryService } from './services/router-history.service';
import { DraggableListComponent } from './components/draggable-list/list.component';
import { DndModule } from 'ngx-drag-drop';
import { SiteService } from './services/site.service';
@NgModule({
imports: [NgCommonModule, RouterModule, FormsModule, ReactiveFormsModule],
imports: [
NgCommonModule,
DndModule,
RouterModule,
FormsModule,
ReactiveFormsModule,
],
declarations: [
MINDS_PIPES,
......@@ -196,8 +205,8 @@ import { RouterHistoryService } from './services/router-history.service';
SwitchComponent,
FeaturedContentComponent,
PosterDateSelectorComponent,
DraggableListComponent,
],
exports: [
MINDS_PIPES,
......@@ -284,8 +293,10 @@ import { RouterHistoryService } from './services/router-history.service';
FeaturedContentComponent,
PosterDateSelectorComponent,
ChannelModeSelectorComponent,
DraggableListComponent,
],
providers: [
SiteService,
{
provide: AttachmentService,
useFactory: AttachmentService._,
......@@ -301,7 +312,7 @@ import { RouterHistoryService } from './services/router-history.service';
{
provide: MindsHttpClient,
useFactory: MindsHttpClient._,
deps: [HttpClient],
deps: [HttpClient, SiteService],
},
{
provide: NSFWSelectorCreatorService,
......
......@@ -43,6 +43,15 @@ minds-button {
}
}
a.m-link-btn {
display: inline-block;
padding: 8px !important;
line-height: 1.2;
height: auto;
text-decoration: none;
font-weight: inherit;
}
.m-btn--slim {
height: 32px;
}
......
@import 'themes';
m-draggable-list {
ul.m-draggableList__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
transition: all ease 300ms;
&.dndDragover {
padding-top: 16px;
padding-bottom: 16px;
}
li.m-draggableList__listItem {
padding: 8px;
border: 1px solid #ddd;
display: flex;
align-items: center;
.handle {
@include m-theme() {
color: themed($grey-600);
}
}
}
}
}
import { Component, ContentChild, Input, TemplateRef } from '@angular/core';
import { DndDropEvent, EffectAllowed } 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>
`,
})
export class DraggableListComponent {
@Input() data: Array<any>;
@Input() dndEffectAllowed: EffectAllowed = 'copyMove';
@Input() id: string;
@ContentChild(TemplateRef, { static: false }) template: TemplateRef<any>;
trackByFunction(index, item) {
return this.id ? item[this.id] + index : index;
}
onDrop(event: DndDropEvent) {
if (
this.data &&
(event.dropEffect === 'copy' || event.dropEffect === 'move')
) {
let dragIndex = this.data.findIndex(
item => event.data[this.id] === item[this.id]
);
let dropIndex = event.index || this.data.length;
// remove element
this.data.splice(dragIndex, 1);
// add it back to new index
if (dragIndex < dropIndex) {
dropIndex--;
}
this.data.splice(dropIndex, 0, event.data);
}
}
}
<div class="m-notice--message">
<ng-container *ngIf="cookiesEnabled"
>Minds uses cookies to ensure you get the best experience. By continuing to
use Minds you agree to our privacy policy or you can
<a (click)="toggleCookies(false)">opt-out</a>.</ng-container
>{{ siteTitle }} uses cookies to ensure you get the best experience. By
continuing to use {{ siteTitle }} you agree to Minds privacy policy or you
can <a (click)="toggleCookies(false)">opt-out</a>.</ng-container
>
<ng-container *ngIf="!cookiesEnabled"
>You have disabled cookies from Minds which limits your experience. Consider
opting back in.</ng-container
>You have disabled cookies from {{ siteTitle }} which limits your
experience. Consider opting back in.</ng-container
>
</div>
<ng-container *ngIf="cookiesEnabled">
......
......@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { Client } from '../../../services/api/client';
import { Storage } from '../../../services/storage';
import { SiteService } from '../../services/site.service';
@Component({
selector: 'm-cookies-notice',
......@@ -21,7 +22,11 @@ export class DismissableNoticeComponent {
cookiesEnabled: boolean = true;
constructor(private client: Client, private storage: Storage) {
constructor(
private client: Client,
private storage: Storage,
private site: SiteService
) {
if (this.storage.get('cookies-notice-dismissed')) {
this.hidden = true;
}
......@@ -53,4 +58,8 @@ export class DismissableNoticeComponent {
this.client.delete(url);
}
}
get siteTitle() {
return this.site.title;
}
}
......@@ -122,7 +122,8 @@
}
}
body.m-overlay-modal--shown {
.m-overlay-modal--shown,
.m-overlay-modal--shown--no-scroll {
overflow: hidden;
}
......
import {
Component,
AfterViewInit,
ViewChild,
Component,
ComponentFactoryResolver,
ComponentRef,
Input,
ElementRef,
Injector,
ViewChild,
} from '@angular/core';
import { DynamicHostDirective } from '../../directives/dynamic-host.directive';
......@@ -19,7 +20,7 @@ import { OverlayModalService } from '../../../services/ux/overlay-modal';
[hidden]="hidden"
(click)="dismiss()"
></div>
<div class="m-overlay-modal {{ class }}" [hidden]="hidden">
<div class="m-overlay-modal {{ class }}" [hidden]="hidden" #modalElement>
<a class="m-overlay-modal--close" (click)="dismiss()"
><i class="material-icons">close</i></a
>
......@@ -30,6 +31,7 @@ import { OverlayModalService } from '../../../services/ux/overlay-modal';
export class OverlayModalComponent implements AfterViewInit {
hidden: boolean = true;
class: string = '';
root: HTMLElement;
@ViewChild(DynamicHostDirective, { static: true })
private host: DynamicHostDirective;
......@@ -37,16 +39,23 @@ export class OverlayModalComponent implements AfterViewInit {
private componentRef: ComponentRef<{}>;
private componentInstance;
@ViewChild('modalElement', { static: true })
protected modalElement: ElementRef;
constructor(
private service: OverlayModalService,
private _componentFactoryResolver: ComponentFactoryResolver
) {}
ngAfterViewInit() {
if (!this.root && document && document.body) {
this.root = document.body;
}
this.service.setContainer(this);
}
create(componentClass, opts?) {
create(componentClass, opts?, injector?: Injector) {
this.dismiss();
opts = {
......@@ -69,8 +78,17 @@ export class OverlayModalComponent implements AfterViewInit {
viewContainerRef.clear();
this.componentRef = viewContainerRef.createComponent(componentFactory);
this.componentRef = viewContainerRef.createComponent(
componentFactory,
void 0,
injector
);
this.componentInstance = this.componentRef.instance;
this.componentInstance.parent = this.modalElement.nativeElement;
}
setRoot(root: HTMLElement) {
this.root = root;
}
setData(data) {
......@@ -97,16 +115,18 @@ export class OverlayModalComponent implements AfterViewInit {
this.hidden = false;
if (document && document.body) {
document.body.classList.add('m-overlay-modal--shown');
if (this.root) {
this.root.classList.add('m-overlay-modal--shown');
document.body.classList.add('m-overlay-modal--shown--no-scroll');
}
}
dismiss() {
this.hidden = true;
if (document && document.body) {
document.body.classList.remove('m-overlay-modal--shown');
if (this.root) {
this.root.classList.remove('m-overlay-modal--shown');
document.body.classList.remove('m-overlay-modal--shown--no-scroll');
}
if (!this.componentInstance) {
......
......@@ -12,4 +12,9 @@ m-body {
&.has-v2-navbar {
margin-top: 52px;
}
&.is-pro-domain {
margin-top: 0;
padding-bottom: 0;
}
}
......@@ -8,6 +8,7 @@
@media screen and (max-width: $min-tablet) {
padding-left: 0;
flex-wrap: wrap;
}
&:not(.m-topbar--navigation--text-only) .m-topbar--navigation--item span {
......
import { Injectable } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { proRoutes } from '../../modules/pro/pro.routes';
@Injectable()
export class SiteService {
get pro() {
return window.Minds.pro;
}
get isProDomain(): boolean {
return Boolean(this.pro);
}
get title(): string {
return this.isProDomain ? this.pro.title || '' : 'Minds';
}
get oneLineHeadline(): string {
return this.isProDomain ? this.pro.one_line_headline || '' : '';
}
private router$: Subscription;
constructor(private router: Router) {
if (this.isProDomain) {
this.listen();
}
}
private listen() {
this.router$ = this.router.events.subscribe(
(navigationEvent: NavigationEnd) => {
try {
if (navigationEvent instanceof NavigationEnd) {
if (!navigationEvent.urlAfterRedirects) {
return;
}
let url = navigationEvent.url
.substring(1, navigationEvent.url.length)
.split('/')[0]
.split(';')[0]
.split('?')[0];
if (!this.searchRoutes(url, proRoutes)) {
window.open(window.Minds.site_url + url, '_blank');
}
}
} catch (e) {
console.error('Minds: router hook(SearchBar)', e);
}
}
);
}
private searchRoutes(url: string, routes: Array<string>): boolean {
for (let route of routes) {
if (route.includes(url)) {
return true;
}
}
return false;
}
}
......@@ -38,6 +38,12 @@ export interface KeyVal {
value: any;
}
export interface Tag {
tag: string;
label: string;
selected?: boolean;
}
export enum ChannelMode {
PUBLIC = 0,
MODERATED = 1,
......@@ -74,6 +80,22 @@ export interface MindsUser {
mature_lock?: boolean;
tags?: Array<string>;
toaster_notifications?: boolean;
pro?: boolean;
pro_settings?: {
logo_image: string;
logo_guid: string;
tag_list?: Tag[];
background_image: string;
title: string;
headline: string;
one_line_headline: string;
footer_text: string;
footer_links: { href: string; title: string }[];
scheme: string;
featured_content?: Array<string>;
tile_ratio?: string;
styles?: { [key: string]: string };
};
mode: ChannelMode;
}
......
......@@ -41,6 +41,7 @@ const routes: Routes = [
RegisterComponent,
ForgotPasswordComponent,
],
exports: [ForgotPasswordComponent],
entryComponents: [
LoginComponent,
LogoutComponent,
......
......@@ -8,7 +8,9 @@
</div>
<div>
<h3 i18n="@@M__COMMON__START_A_CHANNEL">Not on Minds? Start a channel</h3>
<h3 i18n="@@M__COMMON__START_A_CHANNEL_MINDS_REF">
Not on Minds? Start a Minds channel
</h3>
<minds-form-register
[referrer]="referrer"
(done)="registered()"
......
......@@ -115,7 +115,7 @@ describe('LoginComponent', () => {
);
expect(h3).not.toBeNull();
expect(h3.nativeElement.textContent).toContain(
'Not on Minds? Start a channel'
'Not on Minds? Start a Minds channel'
);
expect(
fixture.debugElement.query(By.css('minds-form-register'))
......
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Client } from '../../services/api';
import { Session } from '../../services/session';
import { AuthService } from '../../services/auth.service';
@Component({
template: ``,
})
export class LogoutComponent {
constructor(
public client: Client,
public router: Router,
public route: ActivatedRoute,
public session: Session
public auth: AuthService
) {
this.route.url.subscribe(segments => {
this.logout(
......@@ -22,11 +19,7 @@ export class LogoutComponent {
}
logout(closeAllSessions: boolean = false) {
let url: string = 'api/v1/authenticate';
if (closeAllSessions) url += '/all';
this.client.delete(url);
this.session.logout();
this.auth.logout(closeAllSessions);
this.router.navigate(['/login']);
}
}
......@@ -65,6 +65,10 @@
font-family: 'Roboto', Helvetica, sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@media only screen and (max-width: $min-tablet) {
font-size: 24px;
}
}
.minds-blog-body img {
@media screen and (max-width: 891px) {
......@@ -132,6 +136,18 @@ m-blog-view {
}
}
.minds-body {
a {
color: #607d8b;
}
}
.blogs-grid {
@include m-theme() {
background-color: themed($m-white) !important;
}
}
.m-actions-block {
& > * {
margin-right: 12px;
......
......@@ -38,7 +38,7 @@
</header>
<div
class="mdl-grid m-blog-container blogs-grid mdl-color--white"
class="mdl-grid m-blog-container blogs-grid"
style="max-width:740px"
*ngIf="blog.guid"
>
......@@ -66,9 +66,7 @@
</a>
</div>
<div class="minds-body">
<a
[routerLink]="['/', blog.ownerObj.username]"
class="mdl-color-text--blue-grey-500"
<a [routerLink]="['/', blog.ownerObj.username]"
>{{blog.ownerObj.name}}</a
>
<span *ngIf="blog.time_published || blog.time_created"
......@@ -77,7 +75,7 @@
>
</div>
</div>
<div class="mdl-cell mdl-cell--7-col m-actions-block">
<div class="mdl-cell mdl-cell--7-col m-actions-block" *ngIf="showActions">
<div class="mdl-layout-spacer"></div>
<m-post-menu
[entity]="blog"
......@@ -146,7 +144,11 @@
#lockScreen
></m-wire--lock-screen>
<div class="mdl-grid blogs-grid" style="max-width:660px" *ngIf="blog.guid">
<div
class="mdl-grid blogs-grid"
style="max-width:660px"
*ngIf="showComments && blog.guid"
>
<m-comments__tree [entity]="blog"> </m-comments__tree>
</div>
......
import {
ChangeDetectorRef,
Component,
ElementRef,
ViewChild,
ChangeDetectorRef,
OnInit,
Input,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { Router } from '@angular/router';
......@@ -25,7 +26,6 @@ import { ShareModalComponent } from '../../../modules/modals/share/share';
@Component({
moduleId: module.id,
selector: 'm-blog-view',
inputs: ['_blog: blog', '_index: index'],
host: {
class: 'm-blog',
},
......@@ -62,6 +62,27 @@ export class BlogView implements OnInit, OnDestroy {
'allow-comments',
];
@Input() showActions: boolean = true;
@Input() showComments: boolean = true;
@Input('blog') set _blog(value: MindsBlogEntity) {
this.blog = value;
setTimeout(() => {
this.calculateLockScreenHeight();
});
}
@Input('index') set _index(value: number) {
this.index = value;
if (this.index === 0) {
this.visible = true;
}
}
set data(value: any) {
this.blog = value;
}
@ViewChild('lockScreen', { read: ElementRef, static: false }) lockScreen;
constructor(
......@@ -121,20 +142,6 @@ export class BlogView implements OnInit, OnDestroy {
);
}
set _blog(value: MindsBlogEntity) {
this.blog = value;
setTimeout(() => {
this.calculateLockScreenHeight();
});
}
set _index(value: number) {
this.index = value;
if (this.index === 0) {
this.visible = true;
}
}
delete() {
this.client
.delete('api/v1/blog/' + this.blog.guid)
......
<ng-container *ngIf="channel; else loader">
<ng-container
*ngIf="!proEnabled || !channel.pro || isOwner || isAdmin; else isProChannel"
>
<m-channel #channelComponent></m-channel>
</ng-container>
<ng-template #isProChannel>
<m-pro--channel #proChannelComponent></m-pro--channel>
</ng-template>
</ng-container>
<ng-template #loader>
<div class="m-channel-container--loader" *ngIf="inProgress">
<div class="mdl-spinner mdl-js-spinner is-active" [mdl]></div>
</div>
</ng-template>
m-channel-container {
.m-channel-container--loader {
padding-top: 48px;
text-align: center;
}
}
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable, Subscription } from 'rxjs';
import { Client } from '../../services/api/client';
import { MindsUser } from '../../interfaces/entities';
import { MindsChannelResponse } from '../../interfaces/responses';
import { ChannelComponent } from '../channels/channel.component';
import { ProChannelComponent } from '../pro/channel/channel.component';
import { Session } from '../../services/session';
import { SiteService } from '../../common/services/site.service';
import { FeaturesService } from '../../services/features.service';
@Component({
selector: 'm-channel-container',
templateUrl: 'channel-container.component.html',
changeDetection: ChangeDetectionStrategy.Default,
})
export class ChannelContainerComponent implements OnInit, OnDestroy {
inProgress: boolean = false;
channel: MindsUser;
protected username: string;
protected param$: Subscription;
@ViewChild('channelComponent', { static: false })
channelComponent: ChannelComponent;
@ViewChild('proChannelComponent', { static: false })
proChannelComponent: ProChannelComponent;
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected client: Client,
protected session: Session,
protected site: SiteService,
protected features: FeaturesService
) {}
ngOnInit(): void {
this.param$ = this.route.params.subscribe(params => {
if (params['username']) {
this.username = params['username'];
if (
this.username &&
(!this.channel || this.channel.username != this.username)
) {
this.load();
}
}
});
}
canDeactivate(): boolean | Observable<boolean> {
if (this.channelComponent) {
return this.channelComponent.canDeactivate();
}
return true;
}
ngOnDestroy(): void {
this.param$.unsubscribe();
}
async load() {
if (!this.username) {
return;
}
this.inProgress = true;
try {
const response: MindsChannelResponse = (await this.client.get(
`api/v1/channel/${this.username}`
)) as MindsChannelResponse;
this.channel = response.channel;
const shouldRedirectToProHandler =
!this.site.isProDomain &&
this.channel.pro &&
!this.isOwner &&
!this.isAdmin &&
this.proEnabled;
// NOTE: Temporary workaround until channel component supports children routes
if (shouldRedirectToProHandler) {
this.router.navigate(['/pro', this.channel.username], {
replaceUrl: true,
});
}
} catch (e) {
console.error(e);
}
this.inProgress = false;
}
get isOwner() {
const currentUser = this.session.getLoggedInUser();
return this.channel && currentUser && this.channel.guid == currentUser.guid;
}
get isAdmin() {
return this.session.isAdmin();
}
get proEnabled() {
return this.features.has('pro');
}
}
import { NgModule } from '@angular/core';
import { CommonModule as NgCommonModule } from '@angular/common';
import { ChannelsModule } from '../channels/channels.module';
import { ProModule } from '../pro/pro.module';
import { ChannelContainerComponent } from './channel-container.component';
import { CommonModule } from '../../common/common.module';
@NgModule({
imports: [NgCommonModule, CommonModule, ProModule, ChannelsModule],
declarations: [ChannelContainerComponent],
entryComponents: [ChannelContainerComponent],
})
export class ChannelContainerModule {}
......@@ -80,6 +80,8 @@ export class ChannelComponent {
} else {
this.filter = params['filter'];
}
} else {
this.filter = 'feed';
}
if (params['editToggle']) {
......
......@@ -74,6 +74,7 @@ const routes: Routes = [
ChannelSocialProfiles,
ChannelFeedComponent,
ChannelSidebar,
ChannelComponent,
],
entryComponents: [ChannelComponent, ChannelsListComponent],
})
......
......@@ -28,7 +28,7 @@ export class ExplicitOverlayComponent {
login() {
this.storage.set(
'redirectTo',
'redirect',
window.Minds.site_url + this._channel.username
);
this.router.navigate(['/login']);
......
......@@ -262,11 +262,44 @@
<span [hidden]="!editing" i18n="@@M__ACTION__SAVE">Save</span>
</button>
</div>
<ng-container *mIfFeature="'pro'">
<a
*ngIf="showBecomeProButton"
class="m-btn m-link-btn m-btn--with-icon m-btn--slim m-btn--action"
routerLink="/pro"
>
<i class="material-icons">business_center</i>
<span i18n>Become Pro</span>
</a>
</ng-container>
<a
*ngIf="showProSettings"
class="m-btn m-link-btn m-btn--with-icon m-btn--slim"
[routerLink]="proSettingsRouterLink"
data-cy="data-minds-sidebar-admin-pro-button"
>
<i class="material-icons">business_center</i>
<span i18n>Pro</span>
</a>
<minds-button-boost
*ngIf="session.getLoggedInUser().guid == user.guid"
[object]="user"
></minds-button-boost>
<m-channel--badges [user]="user"></m-channel--badges>
<a *ngIf="session.getLoggedInUser()?.is_admin" (click)="proAdminToggle()">
<m-tooltip
icon="business_center"
[iconClass]="user.pro ? 'selected' : ''"
i18n
>
Pro
</m-tooltip>
</a>
</div>
</div>
......
......@@ -106,6 +106,11 @@ describe('ChannelSidebar', () => {
selector: 'm-channel-mode-selector',
inputs: ['user', 'enabled'],
}),
MockComponent({
selector: 'm-tooltip',
template: '<ng-content></ng-content>',
inputs: ['icon', 'iconClass'],
}),
IfFeatureDirective,
],
imports: [FormsModule, RouterTestingModule, NgCommonModule],
......@@ -140,6 +145,7 @@ describe('ChannelSidebar', () => {
fixture = TestBed.createComponent(ChannelSidebar);
featuresServiceMock.mock('es-feeds', false);
featuresServiceMock.mock('permissions', true);
featuresServiceMock.mock('pro', true);
clientMock.response = {};
uploadMock.response = {};
comp = fixture.componentInstance;
......
......@@ -159,4 +159,51 @@ export class ChannelSidebar {
)
.present();
}
async proAdminToggle() {
const value = !this.user.pro;
const method = value ? 'put' : 'delete';
this.user.pro = value;
try {
const response = (await this.client[method](
`api/v2/admin/pro/${this.user.guid}`
)) as any;
if (!response || response.status !== 'success') {
throw new Error('Invalid server response');
}
} catch (e) {
console.error(e);
this.user.pro = !value;
}
}
get showBecomeProButton() {
const isOwner =
this.session.isLoggedIn() &&
this.session.getLoggedInUser().guid == this.user.guid;
return isOwner && !this.user.pro;
}
get showProSettings() {
const isOwner =
this.session.isLoggedIn() &&
this.session.getLoggedInUser().guid == this.user.guid;
const isAdmin = window.Minds.Admin;
return (isOwner || isAdmin) && this.user.pro;
}
get proSettingsRouterLink() {
const isAdmin = window.Minds.Admin;
const route: any[] = ['/pro/settings'];
if (isAdmin) {
route.push({ user: this.user.username });
}
return route;
}
}
export interface SocialProfileMeta {
key: string;
label: string;
link: string;
icon: string;
customIcon?: boolean;
domain: string;
}
export const socialProfileMeta: SocialProfileMeta[] = [
{
key: 'facebook',
label: 'Facebook',
link: 'https://www.facebook.com/:value',
icon: 'facebook-official',
domain: 'facebook.com',
},
{
key: 'github',
label: 'Github',
link: 'https://github.com/:value',
icon: 'github',
domain: 'github.com',
},
{
key: 'twitch',
label: 'Twitch',
link: 'https://www.twitch.tv/:value',
icon: 'twitch',
domain: 'twitch.tv',
},
{
key: 'linkedin',
label: 'LinkedIn',
link: 'https://linkedin.com/in/:value',
icon: 'linkedin',
domain: 'linkedin.com',
},
{
key: 'youtube_channel',
label: 'YouTube Channel',
link: 'https://www.youtube.com/channel/:value',
icon: 'youtube',
domain: 'youtube.com',
},
{
key: 'minds',
label: 'Minds',
link: 'https://www.minds.com/:value',
icon: 'minds',
customIcon: true,
domain: 'minds.com',
},
{
key: 'reddit',
label: 'Reddit',
link: 'https://www.reddit.com/u/:value',
icon: 'reddit',
domain: 'reddit.com',
},
{
key: 'soundcloud',
label: 'SoundCloud',
link: 'https://soundcloud.com/:value',
icon: 'soundcloud',
domain: 'soundcloud.com',
},
{
key: 'tumblr',
label: 'Tumblr Site',
link: 'https://:value.tumblr.com',
icon: 'tumblr',
domain: 'tumblr.com',
},
{
key: 'twitter',
label: 'Twitter',
link: 'https://twitter.com/:value',
icon: 'twitter',
domain: 'twitter.com',
},
{
key: 'github',
label: 'Github',
link: 'https://github.com/:value',
icon: 'github',
domain: 'github.com',
},
{
key: 'instagram',
label: 'Instagram',
link: 'https://www.instagram.com/:value',
icon: 'instagram',
domain: 'instagram.com',
},
{
key: 'wikipedia_user',
label: 'Wikipedia User',
link: 'https://wikipedia.org/wiki/:value',
icon: 'wikipedia-w',
domain: 'wikipedia.com',
},
{
key: 'imdb_user',
label: 'IMDb User',
link: 'https://www.imdb.com/name/:value',
icon: 'imdb',
domain: 'imdb.com',
},
{
key: 'steam',
label: 'Steam Profile',
link: 'https://steamcommunity.com/id/:value/',
icon: 'steam',
domain: 'steamcommunity.com',
},
{
key: 'deviantart',
label: 'Deviantart User',
link: 'https://:value.deviantart.com/',
icon: 'deviantart',
domain: 'deviantart.com',
},
{
key: 'discord',
label: 'Discord Server',
link: 'https://discord.me/:value',
icon: 'discord',
domain: 'discord.me',
},
{
key: 'flickr',
label: 'Flickr Profile',
link: 'https://www.flickr.com/photos/:value/',
icon: 'flickr',
domain: 'flickr.com',
},
{
key: 'flipboard',
label: 'Flipboard Profile',
link: 'https://www.flipboard.com/:value',
icon: 'flipboard',
domain: 'flipboard.com',
},
{
key: 'gitlab',
label: 'Gitlab Profile',
link: 'https://www.gitlab.com/:value',
icon: 'gitlab',
domain: 'gitlab.com',
},
{
key: 'gitter',
label: 'Gitter Profile',
link: 'https://gitter.im/:value',
icon: 'gitter',
domain: 'gitter.im',
},
{
key: 'goodreads',
label: 'Goodreads Profile',
link: 'https://www.goodreads.com/user/show/:value',
icon: 'goodreads',
domain: 'goodreads.com',
},
{
key: 'google_plus',
label: 'Google Plus Profile',
link: 'https://plus.google.com/:value',
icon: 'google-plus',
domain: 'google.com',
},
{
key: 'mastodon',
label: 'Mastodon Profile',
link: 'https://mastodon.social/:value',
icon: 'mastodon',
domain: 'mastodon.social',
},
{
key: 'medium',
label: 'Medium Profile',
link: 'https://medium.com/:value',
icon: 'medium-m',
domain: 'medium.com',
},
{
key: 'patreon',
label: 'Patreon Profile',
link: 'https://www.patreon.com/:value',
icon: 'patreon',
domain: 'patreon.com',
},
{
key: 'slack',
label: 'Slack Channel',
link: 'https://:value.slack.com',
icon: 'slack',
domain: 'slack.com',
},
{
key: 'other',
label: 'Other',
link: '',
icon: 'link',
domain: '',
},
];
export function getSocialProfileMeta(key: string): SocialProfileMeta {
let defaultMeta: SocialProfileMeta = {
key: '',
label: '',
link: '#',
icon: 'link',
domain: '',
};
if (!key) {
return defaultMeta;
}
for (let i in socialProfileMeta) {
if (socialProfileMeta[i].key === key) {
return socialProfileMeta[i];
}
}
return defaultMeta;
}
import { Component, EventEmitter } from '@angular/core';
import { KeyVal } from '../../../interfaces/entities';
export interface SocialProfileMeta {
key: string;
label: string;
link: string;
icon: string;
customIcon?: boolean;
domain: string;
}
import {
getSocialProfileMeta,
SocialProfileMeta,
socialProfileMeta,
} from './meta';
@Component({
moduleId: module.id,
......@@ -24,205 +20,9 @@ export class ChannelSocialProfiles {
changed: EventEmitter<any> = new EventEmitter();
url: string = '';
private socialProfileMeta: SocialProfileMeta[] = [
{
key: 'facebook',
label: 'Facebook',
link: 'https://www.facebook.com/:value',
icon: 'facebook-official',
domain: 'facebook.com',
},
{
key: 'github',
label: 'Github',
link: 'https://github.com/:value',
icon: 'github',
domain: 'github.com',
},
{
key: 'twitch',
label: 'Twitch',
link: 'https://www.twitch.tv/:value',
icon: 'twitch',
domain: 'twitch.tv',
},
{
key: 'linkedin',
label: 'LinkedIn',
link: 'https://linkedin.com/in/:value',
icon: 'linkedin',
domain: 'linkedin.com',
},
{
key: 'youtube_channel',
label: 'YouTube Channel',
link: 'https://www.youtube.com/channel/:value',
icon: 'youtube',
domain: 'youtube.com',
},
{
key: 'minds',
label: 'Minds',
link: 'https://www.minds.com/:value',
icon: 'minds',
customIcon: true,
domain: 'minds.com',
},
{
key: 'reddit',
label: 'Reddit',
link: 'https://www.reddit.com/u/:value',
icon: 'reddit',
domain: 'reddit.com',
},
{
key: 'soundcloud',
label: 'SoundCloud',
link: 'https://soundcloud.com/:value',
icon: 'soundcloud',
domain: 'soundcloud.com',
},
{
key: 'tumblr',
label: 'Tumblr Site',
link: 'https://:value.tumblr.com',
icon: 'tumblr',
domain: 'tumblr.com',
},
{
key: 'twitter',
label: 'Twitter',
link: 'https://twitter.com/:value',
icon: 'twitter',
domain: 'twitter.com',
},
{
key: 'github',
label: 'Github',
link: 'https://github.com/:value',
icon: 'github',
domain: 'github.com',
},
{
key: 'instagram',
label: 'Instagram',
link: 'https://www.instagram.com/:value',
icon: 'instagram',
domain: 'instagram.com',
},
{
key: 'wikipedia_user',
label: 'Wikipedia User',
link: 'https://wikipedia.org/wiki/:value',
icon: 'wikipedia-w',
domain: 'wikipedia.com',
},
{
key: 'imdb_user',
label: 'IMDb User',
link: 'https://www.imdb.com/name/:value',
icon: 'imdb',
domain: 'imdb.com',
},
{
key: 'steam',
label: 'Steam Profile',
link: 'https://steamcommunity.com/id/:value/',
icon: 'steam',
domain: 'steamcommunity.com',
},
{
key: 'deviantart',
label: 'Deviantart User',
link: 'https://:value.deviantart.com/',
icon: 'deviantart',
domain: 'deviantart.com',
},
{
key: 'discord',
label: 'Discord Server',
link: 'https://discord.me/:value',
icon: 'discord',
domain: 'discord.me',
},
{
key: 'flickr',
label: 'Flickr Profile',
link: 'https://www.flickr.com/photos/:value/',
icon: 'flickr',
domain: 'flickr.com',
},
{
key: 'flipboard',
label: 'Flipboard Profile',
link: 'https://www.flipboard.com/:value',
icon: 'flipboard',
domain: 'flipboard.com',
},
{
key: 'gitlab',
label: 'Gitlab Profile',
link: 'https://www.gitlab.com/:value',
icon: 'gitlab',
domain: 'gitlab.com',
},
{
key: 'gitter',
label: 'Gitter Profile',
link: 'https://gitter.im/:value',
icon: 'gitter',
domain: 'gitter.im',
},
{
key: 'goodreads',
label: 'Goodreads Profile',
link: 'https://www.goodreads.com/user/show/:value',
icon: 'goodreads',
domain: 'goodreads.com',
},
{
key: 'google_plus',
label: 'Google Plus Profile',
link: 'https://plus.google.com/:value',
icon: 'google-plus',
domain: 'google.com',
},
{
key: 'mastodon',
label: 'Mastodon Profile',
link: 'https://mastodon.social/:value',
icon: 'mastodon',
domain: 'mastodon.social',
},
{
key: 'medium',
label: 'Medium Profile',
link: 'https://medium.com/:value',
icon: 'medium-m',
domain: 'medium.com',
},
{
key: 'patreon',
label: 'Patreon Profile',
link: 'https://www.patreon.com/:value',
icon: 'patreon',
domain: 'patreon.com',
},
{
key: 'slack',
label: 'Slack Channel',
link: 'https://:value.slack.com',
icon: 'slack',
domain: 'slack.com',
},
{
key: 'other',
label: 'Other',
link: '',
icon: 'link',
domain: '',
},
];
get socialProfilesMeta() {
return socialProfileMeta;
}
set _user(value: any) {
this.socialProfiles = this.polyfillLegacy(value.social_profiles) || [];
......@@ -256,7 +56,7 @@ export class ChannelSocialProfiles {
}
getSocialProfileIconClass({ key = '' }) {
let meta = this.getSocialProfileMeta(key),
let meta = getSocialProfileMeta(key),
domClass;
if (meta.customIcon) {
......@@ -268,7 +68,7 @@ export class ChannelSocialProfiles {
}
matchSocialProfile(index) {
for (let sm of this.socialProfileMeta) {
for (let sm of this.socialProfilesMeta) {
if (this.url.includes(sm.domain)) {
this.socialProfiles[index].key = sm.key;
this.socialProfiles[index].value = this.url;
......@@ -280,7 +80,7 @@ export class ChannelSocialProfiles {
polyfillLegacy(profiles) {
for (let i in profiles) {
let meta = this.getSocialProfileMeta(profiles[i].key);
let meta = getSocialProfileMeta(profiles[i].key);
if (profiles[i].key != 'other' && !profiles[i].value.includes('/')) {
profiles[i].value = this.buildSocialProfileLink(profiles[i]);
}
......@@ -290,30 +90,8 @@ export class ChannelSocialProfiles {
}
private buildSocialProfileLink({ key = '', value = '' }) {
let link = this.getSocialProfileMeta(key).link;
let link = getSocialProfileMeta(key).link;
return link.replace(':value', value);
}
private getSocialProfileMeta(key: string): SocialProfileMeta {
let defaultMeta: SocialProfileMeta = {
key: '',
label: '',
link: '#',
icon: 'link',
domain: '',
};
if (!key) {
return defaultMeta;
}
for (let i in this.socialProfileMeta) {
if (this.socialProfileMeta[i].key === key) {
return this.socialProfileMeta[i];
}
}
return defaultMeta;
}
}
......@@ -115,10 +115,7 @@
<!-- END: LOGIN -->
<!-- START: TWOFACTOR -->
<div
class="mdl-card mdl-shadow--2dp minds-login-box"
[hidden]="!twofactorToken"
>
<div class="mdl-card mdl-shadow--2dp minds-login-box" *ngIf="twofactorToken">
<div class="mdl-card__title">
<h2 class="mdl-card__title-text" i18n="@@FORMS__LOGIN__2FA_LOGIN_TITLE">
Twofactor Login
......
......@@ -4,6 +4,10 @@
margin-top: $minds-margin * 2;
width: 100% !important;
&:last-child {
margin-bottom: 0;
}
h3 {
margin: 0;
font-size: 24px;
......
......@@ -28,9 +28,6 @@ describe('LoginForm', () => {
let loginButton: DebugElement;
let errorMessage: DebugElement;
let twoFactorForm: DebugElement;
let twoFactorCode: DebugElement;
let twoFactorLoginButton: DebugElement;
let session: Session;
function login(response, _username = 'username') {
......@@ -52,6 +49,7 @@ describe('LoginForm', () => {
}
function twoFactorLogin(response) {
const twoFactorCode = getTwoFactorCode();
twoFactorCode.nativeElement.value = '123123';
twoFactorCode.nativeElement.dispatchEvent(new Event('input'));
......@@ -62,12 +60,24 @@ describe('LoginForm', () => {
tick();
fixture.detectChanges();
twoFactorLoginButton.nativeElement.click();
getTwoFactorLoginButton().nativeElement.click();
tick();
fixture.detectChanges();
}
function getTwoFactorForm() {
return fixture.debugElement.query(By.css('.minds-login-box:last-of-type'));
}
function getTwoFactorCode() {
return fixture.debugElement.query(By.css('#code'));
}
function getTwoFactorLoginButton() {
return fixture.debugElement.query(By.css('.mdl-card > button'));
}
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
......@@ -95,13 +105,6 @@ describe('LoginForm', () => {
password = fixture.debugElement.query(By.css('#password'));
loginButton = fixture.debugElement.query(By.css('.m-btn--login'));
errorMessage = fixture.debugElement.query(By.css('.m-error-box'));
twoFactorForm = fixture.debugElement.query(
By.css('.minds-login-box:last-of-type')
);
twoFactorCode = fixture.debugElement.query(By.css('#code'));
twoFactorLoginButton = fixture.debugElement.query(
By.css('.mdl-card > button')
);
session = comp.session;
......@@ -244,7 +247,8 @@ describe('LoginForm', () => {
login({ status: 'error', code: '403', message: 'imaprettymessage' });
expect(loginForm.nativeElement.hidden).toBeTruthy();
expect(twoFactorForm.nativeElement.hidden).toBeFalsy();
expect(getTwoFactorForm().nativeElement.hidden).toBeFalsy();
}));
it('should spawn error message when incorrect code is written', fakeAsync(() => {
......
import { Component, EventEmitter, NgZone } from '@angular/core';
import { Component, EventEmitter, NgZone, Output } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { Client } from '../../../services/api';
......@@ -7,10 +7,12 @@ import { Session } from '../../../services/session';
@Component({
moduleId: module.id,
selector: 'minds-form-login',
outputs: ['done', 'doneRegistered'],
templateUrl: 'login.html',
})
export class LoginForm {
@Output() done: EventEmitter<any> = new EventEmitter();
@Output() doneRegistered: EventEmitter<any> = new EventEmitter();
errorMessage: string = '';
twofactorToken: string = '';
hideLogin: boolean = false;
......@@ -20,9 +22,6 @@ export class LoginForm {
form: FormGroup;
done: EventEmitter<any> = new EventEmitter();
doneRegistered: EventEmitter<any> = new EventEmitter();
//Taken from advice in https://stackoverflow.com/a/1373724
private emailRegex: RegExp = new RegExp(
"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
......@@ -54,11 +53,14 @@ export class LoginForm {
this.errorMessage = '';
this.inProgress = true;
let opts = {
username: username,
password: this.form.value.password,
};
this.client
.post('api/v1/authenticate', {
username: username,
password: this.form.value.password,
})
.post('api/v1/authenticate', opts)
.then((data: any) => {
// TODO: [emi/sprint/bison] Find a way to reset controls. Old implementation throws Exception;
this.inProgress = false;
......
......@@ -20,15 +20,18 @@ import { RouterHistoryService } from '../../../common/services/router-history.se
templateUrl: 'register.html',
})
export class RegisterForm {
@Input() referrer: string;
@Input() parentId: string = '';
@Output() done: EventEmitter<any> = new EventEmitter();
errorMessage: string = '';
twofactorToken: string = '';
hideLogin: boolean = false;
inProgress: boolean = false;
@Input() referrer: string;
captcha: string;
takenUsername: boolean = false;
usernameValidationTimeout: any;
@Input() parentId: string = '';
showFbForm: boolean = false;
......@@ -36,8 +39,6 @@ export class RegisterForm {
fbForm: FormGroup;
minds = window.Minds;
@Output() done: EventEmitter<any> = new EventEmitter();
@ViewChild('reCaptcha', { static: false }) reCaptcha: ReCaptchaComponent;
constructor(
......@@ -92,8 +93,11 @@ export class RegisterForm {
this.form.value.parentId = this.parentId;
this.inProgress = true;
let opts = { ...this.form.value };
this.client
.post('api/v1/register', this.form.value)
.post('api/v1/register', opts)
.then((data: any) => {
// TODO: [emi/sprint/bison] Find a way to reset controls. Old implementation throws Exception;
......
......@@ -569,9 +569,13 @@ export class Activity implements OnInit {
this.activity.modal_source_url = this.router.url;
this.overlayModal
.create(MediaModalComponent, this.activity, {
class: 'm-overlayModal--media',
})
.create(
MediaModalComponent,
{ entity: this.activity },
{
class: 'm-overlayModal--media',
}
)
.present();
}
......
......@@ -207,9 +207,13 @@ export class Remind {
this.activity.modal_source_url = this.router.url;
this.overlayModal
.create(MediaModalComponent, this.activity, {
class: 'm-overlayModal--media',
})
.create(
MediaModalComponent,
{ entity: this.activity },
{
class: 'm-overlayModal--media',
}
)
.present();
}
}
......@@ -121,7 +121,7 @@ export class MindsVideoDirectHttpPlayer
try {
player.play();
} catch (e) {
console.error(e);
console.log(e);
}
}
......@@ -131,7 +131,7 @@ export class MindsVideoDirectHttpPlayer
try {
player.pause();
} catch (e) {
console.error(e);
console.log(e);
}
}
......@@ -146,13 +146,14 @@ export class MindsVideoDirectHttpPlayer
}
resumeFromTime(time: number = 0) {
// TODO detect if it's still transcoding
const player = this.getPlayer();
try {
player.currentTime = time;
this.play();
} catch (e) {
console.error(e);
console.log(e);
}
}
......
......@@ -195,7 +195,7 @@ export class MindsVideoTorrentPlayer
this.loadTorrent();
}
} catch (e) {
console.error(e);
console.log(e);
}
}
......@@ -205,7 +205,7 @@ export class MindsVideoTorrentPlayer
try {
player.pause();
} catch (e) {
console.error(e);
console.log(e);
}
}
......@@ -220,6 +220,7 @@ export class MindsVideoTorrentPlayer
}
resumeFromTime(time: number = 0) {
// TODO detect if it's still transcoding
const player = this.getPlayer();
try {
......@@ -234,7 +235,7 @@ export class MindsVideoTorrentPlayer
}
}
} catch (e) {
console.error(e);
console.log(e);
}
}
......
......@@ -183,7 +183,7 @@ export class MindsVideoComponent implements OnDestroy {
}
onError({ player, e }: { player?; e? } = {}) {
console.error('Received error when trying to reproduce video', e, player);
console.log('Received error when trying to reproduce video', e, player);
setTimeout(() => this.fallback(), 0);
}
......
......@@ -5,6 +5,7 @@
[style.width]="modalWidth + 'px'"
[style.height]="stageHeight + 'px'"
>
<!-- The stageWrapper is the element that goes into fullscreen -->
<div
class="m-mediaModal__stageWrapper"
[style.width]="stageWidth + 'px'"
......@@ -51,21 +52,20 @@
[isModal]="true"
[autoplay]="true"
[muted]="false"
[poster]="entity.custom_data.thumbnail_src"
[poster]="entity.thumbnail_src"
[src]="[
{
res: '360',
uri: 'api/v1/media/' + entity.custom_data.guid + '/play',
uri: 'api/v1/media/' + entity.entity_guid + '/play',
type: 'video/mp4'
}
]"
[guid]="entity.custom_data.guid"
[guid]="entity.entity_guid"
[playCount]="entity['play:count']"
[torrent]="[
{ res: '360', key: entity.custom_data.guid + '/360.mp4' }
]"
[torrent]="[{ res: '360', key: entity.entity_guid + '/360.mp4' }]"
(videoCanPlayThrough)="isLoaded()"
[@slowFadeAnimation]="isLoading ? 'out' : 'in'"
#player
>
<video-ads [player]="player" *ngIf="entity.monetized"></video-ads>
</m-video>
......@@ -79,7 +79,11 @@
[style.height]="mediaHeight + 'px'"
[@slowFadeAnimation]="isLoading ? 'out' : 'in'"
>
<m-blog-view [blog]="entity"></m-blog-view>
<m-blog-view
[blog]="entity"
[showActions]="false"
[showComments]="false"
></m-blog-view>
</div>
<!-- OVERLAY -->
......@@ -88,7 +92,10 @@
*ngIf="overlayVisible"
@fastFadeAnimation
>
<div class="m-mediaModal__overlayTitleWrapper">
<div
class="m-mediaModal__overlayTitleWrapper"
*ngIf="this.contentType !== 'blog'"
>
<!-- TITLE -->
<span
class="m-mediaModal__overlayTitle m-mediaModal__overlayTitle--notFullscreen"
......
......@@ -18,7 +18,7 @@ m-overlay-modal {
.m-overlay-modal--backdrop {
z-index: 9999995;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.85);
background-color: rgba(themed($m-black-always), 0.8);
}
}
......@@ -99,6 +99,52 @@ m-overlay-modal {
}
}
.m-mediaModal__mediaWrapper--blog {
line-height: initial;
overflow-y: auto;
text-align: left;
@include m-theme() {
color: themed($m-white-always);
}
h1.m-blog--title {
font-weight: 600;
font-size: 42px;
letter-spacing: 1.5px;
font-family: 'Roboto', Helvetica, sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
text-align: left;
@media only screen and (max-width: $min-tablet) {
font-size: 24px;
}
}
p {
@include m-theme() {
color: themed($m-white-always);
}
}
.minds-blog-body img {
@media screen and (max-width: 891px) {
width: 100vw;
}
max-width: initial;
height: auto;
width: 80%;
margin: 0 -100px;
}
.m-blog--image--caption {
@include m-theme() {
color: themed($m-white-always);
}
}
}
.m-mediaModal__stage {
display: flex;
align-items: center;
......@@ -144,9 +190,6 @@ m-overlay-modal {
left: unquote('calc(50% - 50px)');
}
m-video--progress-bar {
.seeker-ball {
top: 4px;
}
.progress-bar {
margin-right: 8px;
}
......@@ -162,11 +205,6 @@ m-overlay-modal {
.m-blog--image > img {
max-width: 100%;
}
.m-actions-block,
m-comments__tree {
display: none;
}
}
}
......
import {
Component,
OnInit,
OnDestroy,
Input,
HostListener,
Input,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { Location } from '@angular/common';
import { Router, Event, NavigationStart } from '@angular/router';
import { Event, NavigationStart, Router } from '@angular/router';
import {
trigger,
animate,
state,
style,
animate,
transition,
trigger,
} from '@angular/animations';
import { Subscription } from 'rxjs';
import { Session } from '../../../services/session';
......@@ -22,6 +22,12 @@ import { AnalyticsService } from '../../../services/analytics';
import { MindsVideoComponent } from '../components/video/video.component';
import isMobileOrTablet from '../../../helpers/is-mobile-or-tablet';
import { ActivityService } from '../../../common/services/activity.service';
import { SiteService } from '../../../common/services/site.service';
export type MediaModalParams = {
redirectUrl?: string;
entity: any;
};
@Component({
selector: 'm-media--modal',
......@@ -56,7 +62,9 @@ import { ActivityService } from '../../../common/services/activity.service';
})
export class MediaModalComponent implements OnInit, OnDestroy {
minds = window.Minds;
entity: any = {};
redirectUrl: string;
isLoading: boolean = true;
navigatedAway: boolean = false;
fullscreenHovering: boolean = false; // Used for fullscreen button transformation
......@@ -97,8 +105,9 @@ export class MediaModalComponent implements OnInit, OnDestroy {
routerSubscription: Subscription;
@Input('entity') set data(entity) {
this.entity = entity;
@Input('entity') set data(params: MediaModalParams) {
this.entity = JSON.parse(JSON.stringify(params.entity)); // deep clone
this.redirectUrl = params.redirectUrl || null;
}
// Used to make sure video progress bar seeker / hover works
......@@ -110,7 +119,8 @@ export class MediaModalComponent implements OnInit, OnDestroy {
public analyticsService: AnalyticsService,
private overlayModal: OverlayModalService,
private router: Router,
private location: Location
private location: Location,
private site: SiteService
) {}
ngOnInit() {
......@@ -125,12 +135,18 @@ export class MediaModalComponent implements OnInit, OnDestroy {
`${this.entity.ownerObj.name}'s post`;
this.entity.guid = this.entity.entity_guid || this.entity.guid;
this.thumbnail = this.entity.thumbnails.xlarge;
switch (this.entity.custom_type) {
case 'video':
this.contentType = 'video';
this.entity.width = this.entity.custom_data.dimensions.width;
this.entity.height = this.entity.custom_data.dimensions.height;
this.entity.thumbnail_src = this.entity.custom_data.thumbnail_src;
break;
case 'batch':
this.contentType = 'image';
this.entity.width = this.entity.custom_data[0].width;
this.entity.height = this.entity.custom_data[0].height;
}
break;
case 'object':
......@@ -138,16 +154,17 @@ export class MediaModalComponent implements OnInit, OnDestroy {
case 'video':
this.contentType = 'video';
this.title = this.entity.title;
this.entity.entity_guid = this.entity.guid;
break;
case 'image':
this.contentType = 'image';
// this.thumbnail = `${this.minds.cdn_url}fs/v1/thumbnail/${this.entity.guid}/xlarge`;
this.thumbnail = this.entity.thumbnail;
this.title = this.entity.title;
this.entity.entity_guid = this.entity.guid;
break;
case 'blog':
this.contentType = 'blog';
this.title = this.entity.title;
this.entity.guid = this.entity.guid;
this.entity.entity_guid = this.entity.guid;
}
break;
......@@ -166,7 +183,9 @@ export class MediaModalComponent implements OnInit, OnDestroy {
break;
}
if (this.contentType !== 'blog') {
if (this.redirectUrl) {
this.pageUrl = this.redirectUrl;
} else if (this.contentType !== 'blog') {
this.pageUrl = `/media/${this.entity.entity_guid}`;
} else {
this.pageUrl = this.entity.route
......@@ -176,6 +195,7 @@ export class MediaModalComponent implements OnInit, OnDestroy {
this.boosted = this.entity.boosted || this.entity.p2p_boosted || false;
// Set ownerIconTime
const session = this.session.getLoggedInUser();
if (session && session.guid === this.entity.ownerObj.guid) {
this.ownerIconTime = session.icontime;
......@@ -186,8 +206,14 @@ export class MediaModalComponent implements OnInit, OnDestroy {
this.isTablet =
isMobileOrTablet() && Math.min(screen.width, screen.height) >= 768;
let url = `${this.pageUrl}?ismodal=true`;
if (this.site.isProDomain) {
url = `/pro/${this.site.pro.user_guid}${url}`;
}
this.analyticsService.send('pageview', {
url: `${this.pageUrl}?ismodal=true`,
url,
});
// * LOCATION & ROUTING * -----------------------------------------------------------------------------------
......@@ -216,16 +242,14 @@ export class MediaModalComponent implements OnInit, OnDestroy {
switch (this.contentType) {
case 'video':
this.entityWidth = this.entity.custom_data.dimensions.width;
this.entityHeight = this.entity.custom_data.dimensions.height;
break;
case 'image':
this.entityWidth = this.entity.custom_data[0].width;
this.entityHeight = this.entity.custom_data[0].height;
this.entityWidth = this.entity.width;
this.entityHeight = this.entity.height;
break;
case 'blog':
this.entityWidth = window.innerWidth;
this.entityHeight = window.innerHeight;
this.entityWidth = window.innerWidth * 0.6;
this.entityHeight = window.innerHeight * 0.6;
break;
}
this.aspectRatio = this.entityWidth / this.entityHeight;
......@@ -243,11 +267,11 @@ export class MediaModalComponent implements OnInit, OnDestroy {
if (this.contentType === 'blog') {
this.mediaHeight = Math.max(
this.minStageHeight,
window.innerHeight - this.padding * 2
window.innerHeight * 0.9 - this.padding * 2
);
this.mediaWidth = Math.max(
this.minStageWidth,
window.innerWidth - this.contentWidth - this.padding * 2
window.innerWidth * 0.9 - this.contentWidth - this.padding * 2
);
this.stageHeight = this.mediaHeight;
this.stageWidth = this.mediaWidth;
......@@ -345,6 +369,7 @@ export class MediaModalComponent implements OnInit, OnDestroy {
this.mediaHeight = this.stageHeight;
}
// Scale width according to aspect ratio
this.mediaWidth = this.scaleWidth();
}
......@@ -389,6 +414,7 @@ export class MediaModalComponent implements OnInit, OnDestroy {
scaleHeight() {
return Math.round(this.mediaWidth / this.aspectRatio);
}
scaleWidth() {
return Math.round(this.mediaHeight * this.aspectRatio);
}
......
......@@ -126,6 +126,17 @@
<span>Upgrade to Plus</span>
</a>
<ng-container *mIfFeature="'pro'">
<a
class="m-page--sidebar--navigation--item"
routerLink="/pro"
*ngIf="session.isLoggedIn() && !session.getLoggedInUser().pro"
>
<i class="material-icons">business_center</i>
<span>Become Pro</span>
</a>
</ng-container>
<a
class="m-page--sidebar--navigation--item"
routerLink="/tokens"
......
......@@ -122,6 +122,7 @@ describe('NewsfeedComponent', () => {
clientMock.response = {};
featuresServiceMock.mock('top-feeds', false);
featuresServiceMock.mock('suggested-users', false);
featuresServiceMock.mock('pro', false);
sessionMock.user.admin = false;
sessionMock.loggedIn = true;
......
......@@ -104,6 +104,7 @@ const routes: Routes = [
NewsfeedBoostRotatorComponent,
NewsfeedEntityComponent,
NewsfeedTilesComponent,
NewsfeedComponent,
],
entryComponents: [NewsfeedComponent, NewsfeedSingleComponent],
})
......
......@@ -27,9 +27,9 @@ export class NewsfeedService {
channel = null,
clientMeta = {}
) {
if (!this.session.isLoggedIn()) {
return;
}
// if (!this.session.isLoggedIn()) {
// return;
// }
// if it's a boost we record the boost view AND the activity view
if (entity.boosted_guid) {
......
......@@ -23,7 +23,7 @@ import { sessionMock } from '../../../tests/session-mock.spec';
import { ExcerptPipe } from '../../common/pipes/excerpt';
describe('NotificationsComponent', () => {
describe('NotificationComponent', () => {
let comp: NotificationComponent;
let fixture: ComponentFixture<NotificationComponent>;
......
......@@ -17,6 +17,7 @@ import { NotificationsComponent } from './notifications.component';
import { NotificationService } from './notification.service';
import { NotificationsToasterComponent } from './toaster.component';
import { SiteService } from '../../common/services/site.service';
@NgModule({
imports: [
......@@ -38,7 +39,7 @@ import { NotificationsToasterComponent } from './toaster.component';
{
provide: NotificationService,
useFactory: NotificationService._,
deps: [Session, Client, SocketsService, MindsTitle],
deps: [Session, Client, SocketsService, MindsTitle, SiteService],
},
],
exports: [
......
......@@ -3,7 +3,17 @@ import { clientMock } from '../../../tests/client-mock.spec';
import { sessionMock } from '../../../tests/session-mock.spec';
import { socketMock } from '../../../tests/socket-mock.spec';
import { fakeAsync, tick } from '@angular/core/testing';
import { mindsTitleMock } from '../../../app/mocks/services/ux/minds-title.service.mock.spec';
import { mindsTitleMock } from '../../mocks/services/ux/minds-title.service.mock.spec';
import { MockService } from '../../utils/mock';
import { SiteService } from '../../common/services/site.service';
import { EventEmitter } from '@angular/core';
export let siteServiceMock = new (function() {
var pro = () => null;
var isProDomain = () => false;
var title = () => 'Minds';
var isAdmin = () => true;
})();
describe('NewsfeedService', () => {
let service: NotificationService;
......@@ -12,10 +22,11 @@ describe('NewsfeedService', () => {
jasmine.clock().uninstall();
jasmine.clock().install();
service = new NotificationService(
clientMock,
sessionMock,
clientMock,
socketMock,
mindsTitleMock
mindsTitleMock,
siteServiceMock
);
clientMock.response = {};
});
......
......@@ -4,6 +4,7 @@ import { SocketsService } from '../../services/sockets';
import { Session } from '../../services/session';
import { MindsTitle } from '../../services/ux/title';
import { Subscription, timer } from 'rxjs';
import { SiteService } from '../../common/services/site.service';
export class NotificationService {
socketSubscriptions: any = {
......@@ -18,20 +19,24 @@ export class NotificationService {
session: Session,
client: Client,
sockets: SocketsService,
title: MindsTitle
title: MindsTitle,
site: SiteService
) {
return new NotificationService(session, client, sockets, title);
return new NotificationService(session, client, sockets, title, site);
}
constructor(
public session: Session,
public client: Client,
public sockets: SocketsService,
public title: MindsTitle
public title: MindsTitle,
protected site: SiteService
) {
if (!window.Minds.notifications_count) window.Minds.notifications_count = 0;
this.listen();
if (!this.site.isProDomain) {
this.listen();
}
}
/**
......
<div
class="m-proChannel__category"
[class.m-proChannel__selectedCategory]="!!tag.selected"
(click)="selectTag(tag)"
*ngFor="let tag of tags"
>
{{ tag.label }}
</div>
m-pro--channel--categories {
display: flex;
flex-wrap: wrap;
justify-content: center;
.m-proChannel__category {
cursor: pointer;
color: var(--m-pro--text-color);
background: var(--m-pro--transparent-background-color);
padding: 4px 8px;
margin: 0 12px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.1em;
&.m-proChannel__selectedCategory {
color: var(--m-pro--primary-color);
}
}
}
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { ProChannelService } from '../channel.service';
import { Router } from '@angular/router';
import { MindsUser, Tag } from '../../../../interfaces/entities';
@Component({
selector: 'm-pro--channel--categories',
templateUrl: 'categories.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProCategoriesComponent {
@Input() set selectedHashtag(value: string) {
this.selectTag(value, false);
}
@Input() showAllTag: boolean = true;
@Output() onSelectTag: EventEmitter<string | null> = new EventEmitter<
string | null
>();
get channel(): MindsUser {
return this.channelService.currentChannel;
}
constructor(
protected channelService: ProChannelService,
protected router: Router,
protected cd: ChangeDetectorRef
) {}
selectTag(clickedTag: Tag | string, triggerEvent: boolean = true) {
if (typeof clickedTag !== 'string') {
clickedTag = clickedTag.tag;
}
for (let tag of this.channel.pro_settings.tag_list) {
tag.selected = tag.tag == clickedTag;
}
this.detectChanges();
if (triggerEvent) {
this.onSelectTag.emit(clickedTag !== 'all' ? clickedTag : null);
}
}
get tags() {
const tags = this.channel.pro_settings.tag_list.concat([]);
if (this.showAllTag) {
tags.unshift({ label: 'All', tag: 'all', selected: false });
}
return tags;
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
}
<div class="m-pro__channel">
<ng-container *ngIf="channel">
<div class="m-proChannel__topbar">
<ng-container *ngIf="!channel.pro_settings.logo_guid; else customLogo">
<minds-avatar [object]="channel"> </minds-avatar>
</ng-container>
<ng-template #customLogo>
<img
class="m-proChannelTopbar__logo"
[src]="channel.pro_settings.logo_image"
[routerLink]="homeRouterLink"
/>
</ng-template>
<div class="m-proChannelTopbar__searchBox">
<m-pro__searchBox
[(query)]="query"
(onSearch)="search()"
(onClearSearch)="clearSearch()"
></m-pro__searchBox>
</div>
<ng-container *ngIf="!collapseNavItems">
<a
class="m-proChannelTopbar__navItem"
[routerLink]="feedRouterLink"
routerLinkActive="active"
i18n
>
Feed
</a>
<a
class="m-proChannelTopbar__navItem"
[routerLink]="videosRouterLink"
routerLinkActive="active"
i18n
>
Videos
</a>
<a
class="m-proChannelTopbar__navItem"
[routerLink]="imagesRouterLink"
routerLinkActive="active"
i18n
>
Images
</a>
<a
class="m-proChannelTopbar__navItem"
[routerLink]="articlesRouterLink"
routerLinkActive="active"
i18n
>
Articles
</a>
<a
class="m-proChannelTopbar__navItem"
[routerLink]="groupsRouterLink"
routerLinkActive="active"
i18n
>
Groups
</a>
<ng-container
*ngIf="currentUser?.guid != channel.guid; else ownerNavLinks"
>
<a class="m-proChannelTopbar__navItem" (click)="wire()" i18n>Wire</a>
<m-pro__subscribeButton></m-pro__subscribeButton>
</ng-container>
<ng-template #ownerNavLinks>
<a
*ngIf="isProDomain"
class="m-proChannelTopbar__navItem"
[href]="proSettingsHref"
target="root_minds"
i18n
>
Settings
</a>
<a
*ngIf="!isProDomain"
class="m-proChannelTopbar__navItem"
[routerLink]="proSettingsLink"
i18n
>
Settings
</a>
</ng-template>
</ng-container>
<m-pro__hamburger-menu
[(query)]="query"
(onSearch)="search()"
(onClearSearch)="clearSearch()"
></m-pro__hamburger-menu>
</div>
<div class="m-proChannel__body">
<router-outlet></router-outlet>
</div>
<div class="m-proChannel__footer">
<m-pro--channel-footer></m-pro--channel-footer>
</div>
</ng-container>
<div class="m-proChannel__body" *ngIf="error">
<div class="m-proChannel__error">
{{ error }}
</div>
</div>
</div>
<m-overlay-modal #overlayModal></m-overlay-modal>
@import 'themes';
m-pro--channel {
--m-pro--primary-color: #4690df;
--m-pro--plain-background-color: #ffffff;
--m-pro--transparent-background-color: #ffffffa0;
--m-pro--more-transparent-background-color: #ffffff50;
--m-pro--text-color: #000000;
--m-pro--tile-ratio: 56.25%;
display: block;
background: none no-repeat center fixed;
background-size: cover;
background-blend-mode: overlay;
background-color: var(--m-pro--more-transparent-background-color) !important;
@media screen and (min-width: ($min-tablet + 1px)) {
m-pro__hamburger-menu {
display: none;
}
.m-proChannelList__title {
display: none;
}
}
minds-textarea {
background-color: var(--m-pro--transparent-background-color) !important;
}
.m-blog-view m-blog-view.m-blog {
color: var(--m-pro--text-color);
background-color: var(--m-pro--transparent-background-color) !important;
.minds-blog-body > p,
p,
a,
i.material-icons,
span {
color: var(--m-pro--text-color);
}
minds-button-boost > button {
color: var(--m-pro--primary-color);
border-color: var(--m-pro--primary-color);
}
.blogs-grid {
background-color: transparent !important;
}
}
&.m-overlay-modal--shown {
m-overlay-modal {
position: fixed;
z-index: 9999990;
}
}
}
@media screen and (max-width: $max-mobile) {
.m-pro__channel {
.m-proChannelTopbar__searchBox {
display: none;
}
.m-proChannel__body {
margin-top: 10px;
}
}
}
.m-pro__channel {
max-width: 1296px;
margin: 0 auto;
display: grid;
grid-template-rows: 100px 1fr 100px;
grid-template-columns: repeat(12, 1fr);
//min-height: calc(100vh - 52px); /* 52px is the topbar height */
min-height: 100%;
.is-pro-domain & {
min-height: 100vh;
}
.m-proChannel__topbar {
width: 100%;
box-sizing: border-box;
grid-row: 1 / span 1;
grid-column: 1 / span 12;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
minds-avatar {
width: 90px;
height: 90px;
}
.m-proChannelTopbar__logo {
max-width: 200px;
max-height: 100%;
cursor: pointer;
object-fit: contain;
}
.m-proChannelTopbar__logo:focus {
outline: none;
}
& > * {
padding: 0 16px 0 0;
}
.m-proChannelTopbar__searchBox {
margin: 0 16px;
@media screen and (max-width: 1081px) {
flex-grow: 1;
max-width: 300px;
}
}
.m-btn:disabled {
padding-left: 16px;
color: gray !important;
border: gray !important;
}
}
a.m-proChannelTopbar__navItem {
cursor: pointer;
text-decoration: none;
padding: 8px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.m-proChannel__body {
grid-row: 2 / span 1;
grid-column: 1 / span 12;
padding: 0;
@media screen and (max-width: $max-mobile) {
grid-row: 2 / span 2;
margin-bottom: 24px;
}
& > m-blog-view-infinite,
m-blog-view {
background-color: transparent !important;
}
.m-proChannel__error {
width: 100%;
display: inline-block;
margin: 16px 0;
padding: 17px 8px;
font-size: 24px;
text-align: center;
text-transform: uppercase;
background: var(--m-pro--transparent-background-color);
@include m-theme() {
color: themed($m-red);
}
}
}
.m-proChannel__footer {
grid-row: 4 / span 1;
grid-column: 2 / span 10;
width: 80%;
margin: 0 auto;
@media screen and (max-width: $min-tablet) {
display: none;
}
}
m-pro--channel h1 {
font-size: 70px;
font-weight: bold;
}
.minds-avatar {
margin: 0;
width: 40px;
height: 40px;
border-radius: 50%;
background-position: center;
background-size: cover;
}
// colors
a.active,
a:hover {
color: var(--m-pro--primary-color) !important;
}
a,
h1,
h2,
h3,
h4,
h5,
p {
color: var(--m-pro--text-color) !important;
}
}
.m-overlayModal--unsubscribe {
width: 600px;
@media screen and (max-width: 660px) {
width: 400px;
}
@media screen and (max-width: 420px) {
width: 250px;
}
.m-overlay-modal--close {
display: none;
}
}
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
HostListener,
Injector,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Session } from '../../../services/session';
import { Subscription } from 'rxjs';
import { MindsUser } from '../../../interfaces/entities';
import { Client } from '../../../services/api/client';
import { MindsTitle } from '../../../services/ux/title';
import { ProChannelService } from './channel.service';
import { SignupModalService } from '../../modals/signup/service';
import { OverlayModalService } from '../../../services/ux/overlay-modal';
import { OverlayModalComponent } from '../../../common/components/overlay-modal/overlay-modal.component';
import { SessionsStorageService } from '../../../services/session-storage.service';
import { SiteService } from '../../../common/services/site.service';
@Component({
providers: [ProChannelService, OverlayModalService],
selector: 'm-pro--channel',
templateUrl: 'channel.component.html',
changeDetection: ChangeDetectionStrategy.Default,
})
export class ProChannelComponent implements OnInit, AfterViewInit, OnDestroy {
username: string;
type: string;
query: string;
channel: MindsUser;
inProgress: boolean;
error: string;
collapseNavItems: boolean;
protected params$: Subscription;
protected loggedIn$: Subscription;
@ViewChild('overlayModal', { static: true })
protected overlayModal: OverlayModalComponent;
get currentUser() {
if (!this.session.isLoggedIn()) {
return null;
}
return this.session.getLoggedInUser();
}
get homeRouterLink() {
return this.channelService.getRouterLink('home');
}
get feedRouterLink() {
let params;
if (this.query) {
params = { query: this.query };
}
return this.channelService.getRouterLink('feed', params);
}
get videosRouterLink() {
let params;
if (this.query) {
params = { query: this.query };
}
return this.channelService.getRouterLink('videos', params);
}
get imagesRouterLink() {
let params;
if (this.query) {
params = { query: this.query };
}
return this.channelService.getRouterLink('images', params);
}
get articlesRouterLink() {
let params;
if (this.query) {
params = { query: this.query };
}
return this.channelService.getRouterLink('articles', params);
}
get groupsRouterLink() {
return this.channelService.getRouterLink('groups');
}
get proSettingsLink() {
return ['/pro/settings'];
}
get proSettingsHref() {
return window.Minds.site_url + 'pro/settings';
}
get isProDomain() {
return this.site.isProDomain;
}
@HostBinding('style.backgroundImage') get backgroundImageCssValue() {
if (!this.channel) {
return 'none';
}
return `url(${this.channel.pro_settings.background_image})`;
}
@HostBinding('class') get cssColorSchemeOverride() {
if (!this.channel) {
return '';
}
return `m-theme--wrapper m-theme--wrapper__${this.channel.pro_settings
.scheme || 'light'}`;
}
constructor(
protected element: ElementRef,
protected session: Session,
protected channelService: ProChannelService,
protected client: Client,
protected title: MindsTitle,
protected router: Router,
protected route: ActivatedRoute,
protected cd: ChangeDetectorRef,
protected modal: SignupModalService,
protected modalService: OverlayModalService,
protected sessionStorage: SessionsStorageService,
protected site: SiteService,
protected injector: Injector
) {}
ngOnInit() {
if (this.site.isProDomain) {
this.username = this.site.pro.user_guid;
}
this.listen();
this.onResize();
}
ngAfterViewInit() {
this.modalService
.setContainer(this.overlayModal)
.setRoot(this.element.nativeElement);
}
listen() {
this.params$ = this.route.params.subscribe(params => {
if (params['username']) {
this.username = params['username'];
}
if (params['type']) {
this.type = params['type'];
}
if (
this.username &&
(!this.channel || this.channel.username != this.username)
) {
this.load();
}
});
this.loggedIn$ = this.session.loggedinEmitter.subscribe(is => {
if (is) {
this.reload();
}
});
}
@HostListener('window:resize') onResize() {
this.collapseNavItems = window.innerWidth <= 768;
}
ngOnDestroy() {
this.params$.unsubscribe();
}
async load() {
this.error = null;
if (!this.username) {
return;
}
this.inProgress = true;
this.detectChanges();
try {
this.channel = await this.channelService.loadAndAuth(this.username);
this.bindCssVariables();
this.shouldOpenWireModal();
} catch (e) {
this.error = e.message;
if (e.message === 'E_NOT_PRO') {
if (this.site.isProDomain) {
this.error = 'This is not a Minds Pro channel...';
} else {
this.router.navigate(['/', this.username || ''], {
replaceUrl: true,
});
return;
}
}
}
this.detectChanges();
}
async reload() {
this.error = null;
try {
this.channel = await this.channelService.reload(this.username);
this.shouldOpenWireModal();
} catch (e) {
this.error = e.message;
}
this.detectChanges();
}
bindCssVariables() {
const styles = this.channel.pro_settings.styles;
for (const style in styles) {
if (!styles.hasOwnProperty(style)) {
continue;
}
let value =
typeof styles[style] === 'string' ? styles[style].trim() : null;
if (!value) {
continue;
}
const styleAttr = style.replace(/_/g, '-');
this.element.nativeElement.style.setProperty(
`--m-pro--${styleAttr}`,
styles[style]
);
}
}
wire() {
this.channelService.wire();
}
search(): Promise<boolean> {
return this.router.navigate(
this.channelService.getRouterLink('all', { query: this.query })
);
}
clearSearch() {
this.query = '';
const cleanUrl = this.router.url.split(';')[0];
this.router.navigate([cleanUrl]);
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
private shouldOpenWireModal() {
if (this.sessionStorage.get('pro::wire-modal::open')) {
this.wire();
}
}
}
import { Injectable, OnDestroy } from '@angular/core';
import { MindsChannelResponse } from '../../../interfaces/responses';
import { MindsUser } from '../../../interfaces/entities';
import { Client } from '../../../services/api/client';
import { EntitiesService } from '../../../common/services/entities.service';
import normalizeUrn from '../../../helpers/normalize-urn';
import { OverlayModalService } from '../../../services/ux/overlay-modal';
import { Session } from '../../../services/session';
import { ActivatedRoute, Router } from '@angular/router';
import { WireCreatorComponent } from '../../wire/creator/creator.component';
import { SessionsStorageService } from '../../../services/session-storage.service';
import { SiteService } from '../../../common/services/site.service';
import { BehaviorSubject, Subscription } from 'rxjs';
import { AnalyticsService } from '../../../services/analytics';
export type RouterLinkToType =
| 'home'
| 'all'
| 'feed'
| 'videos'
| 'images'
| 'articles'
| 'groups'
| 'donate'
| 'login';
export interface NavItems {
label: string;
onClick: () => void;
isActive: () => boolean;
}
type PaginationParams = { limit?: number; offset?: any };
type FeedsResponse = {
content: Array<any>;
offset: any;
};
@Injectable()
export class ProChannelService implements OnDestroy {
currentChannel: MindsUser;
readonly onChannelChange: BehaviorSubject<any> = new BehaviorSubject(null);
protected featuredContent: Array<any> | null;
protected menuNavItems: Array<NavItems> = [];
protected isLoggedIn$: Subscription;
constructor(
protected client: Client,
protected entitiesService: EntitiesService,
protected session: Session,
protected route: ActivatedRoute,
protected modalService: OverlayModalService,
protected sessionStorage: SessionsStorageService,
protected router: Router,
protected site: SiteService,
protected analytics: AnalyticsService
) {
this.listen();
}
listen() {
this.isLoggedIn$ = this.session.loggedinEmitter.subscribe(is => {
if (!is && this.currentChannel) {
this.currentChannel.subscribed = false;
}
});
}
ngOnDestroy() {
this.isLoggedIn$.unsubscribe();
}
async loadAndAuth(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 = [];
}
this.onChannelChange.next(this.currentChannel);
this.featuredContent = null;
return this.currentChannel;
} catch (e) {
if (e.status === 0) {
throw new Error('Connectivity error. Are you offline?');
} else {
throw new Error(e.message || 'There was an issue loading this channel');
}
}
}
async reload(id: string) {
try {
const response = (await this.client.get(`api/v2/pro/channel/${id}`)) as {
channel;
me?;
};
this.currentChannel = response.channel;
this.onChannelChange.next(this.currentChannel);
return this.currentChannel;
} catch (e) {
if (e.status === 0) {
throw new Error('Network error');
} else {
throw new Error('Error loading channel');
}
}
}
async getFeaturedContent(): Promise<Array<any>> {
if (!this.currentChannel) {
throw new Error('No channel');
}
if (!this.featuredContent) {
if (
this.currentChannel.pro_settings.featured_content &&
this.currentChannel.pro_settings.featured_content.length
) {
try {
const urns = this.currentChannel.pro_settings.featured_content.map(
guid => normalizeUrn(guid)
);
const { entities } = (await this.entitiesService.fetch(urns)) as any;
this.featuredContent = entities;
} catch (e) {
this.featuredContent = null;
return [];
}
} else {
this.featuredContent = [];
}
}
return this.featuredContent;
}
async getContent(params: PaginationParams = {}): Promise<FeedsResponse> {
if (!this.currentChannel) {
throw new Error('No channel');
}
const endpoint = `api/v2/pro/content/${this.currentChannel.guid}/all/top`;
const qs = {
limit: params.limit || 24,
from_timestamp: params.offset || '',
sync: 1,
exclude:
(this.currentChannel.pro_settings.featured_content || []).join(',') ||
'',
cache: true,
};
const {
entities: feedSyncEntities,
'load-next': loadNext,
} = (await this.client.get(endpoint, qs)) as any;
const { entities } = (await this.entitiesService.fetch(
feedSyncEntities.map(feedSyncEntity => normalizeUrn(feedSyncEntity.guid))
)) as any;
let nextOffset =
feedSyncEntities && feedSyncEntities.length ? loadNext : '';
return {
content: entities,
offset: nextOffset,
};
}
async getAllCategoriesContent() {
if (!this.currentChannel) {
throw new Error('No channel');
}
const { content } = (await this.client.get(
`api/v2/pro/channel/${this.currentChannel.guid}/content`
)) as any;
return content
.filter(entry => entry && entry.content && entry.content.length)
.map(entry => {
entry.content = entry.content.map(item => {
if (item.entity) {
return Promise.resolve(item.entity);
}
return this.entitiesService.single(item.urn);
});
return entry;
});
}
getRouterLink(to: RouterLinkToType, params?: { [key: string]: any }): any[] {
let root = '/';
if (this.route.parent) {
root = this.route.parent.pathFromRoot
.map(route =>
route.snapshot.url.map(urlSegment => urlSegment.toString()).join('')
)
.join('/');
}
const route: any[] = [root];
if (!this.site.isProDomain) {
route.push(this.currentChannel.username);
}
switch (to) {
case 'home':
/* Root */
break;
case 'all':
case 'feed':
case 'videos':
case 'images':
case 'articles':
case 'groups':
route.push(to);
if (params) {
route.push(params);
}
break;
case 'donate':
route.push(to);
break;
case 'login':
route.push('login');
break;
}
return route;
}
open(entity, modalServiceContext: OverlayModalService) {
switch (this.getEntityTaxonomy(entity)) {
case 'group':
window.open(
`${window.Minds.site_url}groups/profile/${entity.guid}`,
'_blank'
);
break;
}
}
getEntityTaxonomy(entity) {
return entity.type === 'object'
? `${entity.type}:${entity.subtype}`
: entity.type;
}
async subscribe() {
this.currentChannel.subscribed = true;
this.currentChannel.subscribers_count += 1;
try {
const response = (await this.client.post(
'api/v1/subscribe/' + this.currentChannel.guid
)) as any;
if (!response || response.error) {
throw new Error(response.error || 'Invalid server response');
}
} catch (e) {
this.currentChannel.subscribed = false;
this.currentChannel.subscribers_count -= 1;
}
}
async unsubscribe() {
this.currentChannel.subscribed = false;
this.currentChannel.subscribers_count -= 1;
try {
const response = (await this.client.delete(
'api/v1/subscribe/' + this.currentChannel.guid
)) as any;
if (!response || response.error) {
throw new Error(response.error || 'Invalid server response');
}
} catch (e) {
this.currentChannel.subscribed = true;
this.currentChannel.subscribers_count += 1;
}
}
wire() {
// save into sessionStorage before doing the logged in check so the modal opens after logging in
this.sessionStorage.set('pro::wire-modal::open', '1');
if (!this.session.isLoggedIn()) {
this.router.navigate(['/login']);
return;
}
if (this.session.getLoggedInUser().guid == this.currentChannel.guid) {
return;
}
this.modalService
.create(WireCreatorComponent, this.currentChannel, {
onComplete: () => {
this.sessionStorage.destroy('pro::wire-modal::open');
},
})
.onDidDismiss(() => {
this.sessionStorage.destroy('pro::wire-modal::open');
})
.present();
this.analytics.send('pageview', {
url: `/pro/${this.currentChannel.guid}/wire?ismodal=true`,
});
}
pushMenuNavItems(navItems: Array<NavItems>, clean?: boolean) {
if (clean) {
this.destroyMenuNavItems();
}
this.menuNavItems = this.menuNavItems.concat(navItems);
return this;
}
destroyMenuNavItems() {
this.menuNavItems = [];
return this;
}
getMenuNavItems(): Array<NavItems> {
return this.menuNavItems;
}
}
<div class="m-proChannelFooter">
<div class="m-proChannelFooter__items">
<a
*ngFor="let link of footerLinks"
class="m-proChannelFooter__link"
[href]="link.href"
target="_blank"
>
{{ link.title }}
</a>
<ng-container *ngIf="currentUser">
<ng-container *ngIf="isProDomain">
<a
class="m-proChannelFooter__link"
[href]="viewProfileHref"
target="_blank"
i18n
>
@{{ currentUsername }}
</a>
<a class="m-proChannelFooter__link" (click)="logout()" i18n>
Logout
</a>
</ng-container>
<ng-container *ngIf="!isOwner">
<span style="position: relative">
<minds-button-user-dropdown
[user]="user"
></minds-button-user-dropdown>
</span>
</ng-container>
</ng-container>
</div>
<div class="m-proChannelFooter__items m-proChannelFooter__socialItems">
<span *ngFor="let profile of footerSocialProfiles">
<a
*ngIf="profile.key && profile.value"
[href]="getSocialProfileURL(profile.value)"
rel="noopener noreferrer"
target="_blank"
>
<i [ngClass]="[getSocialProfileIconClass(profile)]"></i>
</a>
</span>
</div>
<div class="m-proChannelFooter__static">
<span class="m-proChannelFooter__text">{{ footerText }}</span>
</div>
</div>
.m-proChannelFooter {
text-align: center;
margin-bottom: 24px;
color: var(--m-pro--text-color);
.m-proChannelFooter__items > * {
display: inline-block;
margin: 8px 16px;
vertical-align: middle;
}
.m-proChannelFooter__socialItems {
margin-bottom: 8px;
font-size: 1.5em;
}
.m-proChannelFooter__text {
text-transform: uppercase;
letter-spacing: 0.1em;
}
a {
cursor: pointer;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.1em;
}
minds-button-user-dropdown {
button.material-icons {
border: none;
color: var(--m-pro--text-color) !important;
}
.minds-dropdown-menu {
top: -100px;
left: -130px;
}
}
}
import { Component } from '@angular/core';
import { ProChannelService } from '../channel.service';
import { Session } from '../../../../services/session';
import { AuthService } from '../../../../services/auth.service';
import { SiteService } from '../../../../common/services/site.service';
import {
getSocialProfileMeta,
socialProfileMeta,
} from '../../../channels/social-profiles/meta';
export interface SocialProfileMeta {
key: string;
label: string;
link: string;
icon: string;
customIcon?: boolean;
domain: string;
}
@Component({
selector: 'm-pro--channel-footer',
templateUrl: 'footer.component.html',
})
export class ProChannelFooterComponent {
constructor(
protected channelService: ProChannelService,
protected session: Session,
protected auth: AuthService,
protected site: SiteService
) {}
get socialProfilesMeta() {
return socialProfileMeta;
}
get footerLinks() {
return this.channelService.currentChannel.pro_settings.footer_links;
}
get footerText() {
return this.channelService.currentChannel.pro_settings.footer_text;
}
get footerSocialProfiles() {
return this.channelService.currentChannel.social_profiles;
}
getSocialProfileURL(url: string) {
if (url.includes('http://') || url.includes('https://')) {
return url;
} else {
return 'http://' + url;
}
}
getSocialProfileIconClass({ key = '' }) {
let meta = getSocialProfileMeta(key),
domClass;
if (meta.customIcon) {
domClass = `m-custom-icon m-custom-icon-${meta.icon}`;
} else {
domClass = `fa fa-fw fa-${meta.icon}`;
}
return domClass;
}
logout() {
this.auth.logout();
}
get user() {
return this.channelService.currentChannel;
}
get isOwner() {
return (
this.session.getLoggedInUser() &&
this.session.getLoggedInUser().guid == this.user.guid
);
}
get currentUser() {
return this.session.getLoggedInUser();
}
get currentUsername() {
return this.session.getLoggedInUser().username;
}
get viewProfileHref() {
return window.Minds.site_url + this.session.getLoggedInUser().username;
}
get isProDomain() {
return this.site.isProDomain;
}
}
<a class="m-proHamburgerMenu__trigger" (click)="toggleMenu()">
<i class="material-icons">menu</i>
</a>
<div class="m-proHamburgerMenu__menu">
<a class="m-proHamburgerMenu__close" (click)="closeMenu()">
<i class="material-icons">close</i>
</a>
<ul>
<li class="m-proHamburgerMenu__logo" *ngIf="channel">
<img
[routerLink]="homeRouterLink"
[src]="channel.pro_settings.logo_image"
(click)="closeMenu()"
/>
</li>
<li
class="m-proHamburgerMenu__subscribe"
*ngIf="currentUser?.guid != channel?.guid"
>
<m-pro__subscribeButton (onAction)="closeMenu()"></m-pro__subscribeButton>
</li>
<li>
<a
[routerLink]="feedRouterLink"
routerLinkActive="m-proHamburgerMenu__item--active"
(click)="closeMenu()"
i18n
>Feed</a
>
</li>
<li>
<a
[routerLink]="videosRouterLink"
routerLinkActive="m-proHamburgerMenu__item--active"
(click)="closeMenu()"
i18n
>Videos</a
>
</li>
<li>
<a
[routerLink]="imagesRouterLink"
routerLinkActive="m-proHamburgerMenu__item--active"
(click)="closeMenu()"
i18n
>Images</a
>
</li>
<li>
<a
[routerLink]="articlesRouterLink"
routerLinkActive="m-proHamburgerMenu__item--active"
(click)="closeMenu()"
i18n
>Articles</a
>
</li>
<li>
<a
[routerLink]="groupsRouterLink"
routerLinkActive="m-proHamburgerMenu__item--active"
(click)="closeMenu()"
i18n
>Groups</a
>
</li>
<li *ngIf="currentUser?.guid != channel?.guid">
<a (click)="wire(); closeMenu()">
Wire
</a>
</li>
<li class="m-proHamburgerMenu__searchBox">
<m-pro__searchBox
[query]="query"
(queryChange)="queryChange.emit($event)"
(onSearch)="onSearch.emit($event); closeMenu()"
(onClearSearch)="onClearSearch.emit($event); closeMenu()"
></m-pro__searchBox>
</li>
<li class="m-proHamburgerMenu__spacer"></li>
<ng-container *ngIf="items && items.length">
<li *ngFor="let item of items">
<a
[class.m-proHamburgerMenu__item--active]="item.isActive()"
(click)="item.onClick(); closeMenu()"
>{{ item.label }}</a
>
</li>
<li class="m-proHamburgerMenu__spacer"></li>
</ng-container>
</ul>
<m-pro--channel-footer (click)="closeMenu()"></m-pro--channel-footer>
</div>
m-pro__hamburger-menu {
position: relative;
.m-proHamburgerMenu__menu {
right: -110%;
visibility: hidden;
position: fixed;
width: 95vw;
z-index: 999999999;
top: 0;
bottom: 0;
background: var(--m-pro--plain-background-color);
color: var(--m-pro--text-color);
box-sizing: border-box;
transition: right 0.2s ease-in;
overflow: auto;
body.hamburger-menu--open & {
right: 0;
visibility: visible;
}
.m-proHamburgerMenu__close {
position: absolute;
top: 30px;
right: 40px;
padding: 8px;
z-index: 9999;
}
> ul {
padding: 0 16px;
list-style: none;
margin: 0;
top: 0;
left: 0;
width: 100%;
height: auto;
box-sizing: border-box;
> li {
margin: 0;
padding: 0;
display: block;
font-size: 18px;
line-height: 1.25;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 1px;
&.m-proHamburgerMenu__logo {
height: 100px;
> img {
max-width: 200px;
max-height: 100%;
object-fit: contain;
}
}
&.m-proHamburgerMenu__subscribe {
padding: 0 0 16px;
text-align: center;
.m-pro__subscribeButton {
width: calc(100% - 32px);
margin: 0;
}
}
&.m-proHamburgerMenu__searchBox {
> m-pro__searchbox {
width: 100%;
display: block;
margin: 0 auto 16px;
.mdl-textfield__input {
border: 1px solid var(--m-pro--text-color);
border-radius: 6px;
}
}
}
&.m-proHamburgerMenu__spacer {
height: 0;
border-top: 1px solid var(--m-pro--text-color);
margin: 16px 0;
}
> a {
font: inherit;
text-decoration: none;
padding: 12px 0;
display: block;
&.m-proHamburgerMenu__item--active {
color: var(--m-pro--primary-color) !important;
}
}
}
}
}
.m-pro--channel-footer {
display: block;
padding: 0 16px;
.m-proChannelFooter__socialItems {
margin: 16px 0;
}
}
}
body.hamburger-menu--open {
overflow: hidden;
}
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { ProChannelService } from '../channel.service';
import { Session } from '../../../../services/session';
@Component({
selector: 'm-pro__hamburger-menu',
templateUrl: 'hamburger-menu.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProHamburgerMenu {
@Input() query: string = '';
@Output() queryChange: EventEmitter<string> = new EventEmitter<string>();
@Output() onSearch: EventEmitter<void> = new EventEmitter<void>();
@Output() onClearSearch: EventEmitter<void> = new EventEmitter<void>();
constructor(
protected service: ProChannelService,
protected session: Session
) {}
toggleMenu() {
if (document.body) {
if (document.body.classList.contains('hamburger-menu--open')) {
document.body.classList.remove('hamburger-menu--open');
} else {
document.body.classList.add('hamburger-menu--open');
}
}
}
closeMenu() {
if (
document.body &&
document.body.classList.contains('hamburger-menu--open')
) {
document.body.classList.remove('hamburger-menu--open');
}
}
wire() {
this.service.wire();
}
get homeRouterLink() {
return this.service.getRouterLink('home');
}
get feedRouterLink() {
return this.service.getRouterLink('feed');
}
get videosRouterLink() {
return this.service.getRouterLink('videos');
}
get imagesRouterLink() {
return this.service.getRouterLink('images');
}
get articlesRouterLink() {
return this.service.getRouterLink('articles');
}
get groupsRouterLink() {
return this.service.getRouterLink('groups');
}
get items() {
return this.service.getMenuNavItems();
}
get channel() {
return this.service.currentChannel;
}
get currentUser() {
if (!this.session.isLoggedIn()) {
return null;
}
return this.session.getLoggedInUser();
}
}
<div class="m-proChannel__home">
<m-pro--channel--categories
[showAllTag]="false"
[selectedHashtag]="''"
(onSelectTag)="navigateToCategory($event)"
></m-pro--channel--categories>
<div class="m-proChannelHome__section" *ngIf="featuredContent?.length">
<div class="m-proChannelHome__featuredContent">
<m-pro--channel-tile
*ngFor="let entity of featuredContent"
[entity]="entity"
(click)="onContentClick(entity)"
></m-pro--channel-tile>
</div>
</div>
<div class="m-proChannelHome__section" *ngFor="let category of categories">
<h2 i18n>
<a [routerLink]="getCategoryRoute(category.tag?.tag)">{{
category.tag?.label
}}</a>
</h2>
<div class="m-proChannelHome__categoryContent">
<ng-container *ngFor="let entity$ of category?.content">
<m-pro--channel-tile
*ngIf="entity$ | async as entity"
[entity]="entity"
(click)="onContentClick(entity)"
></m-pro--channel-tile>
</ng-container>
</div>
</div>
<div class="m-proChannelHome__loader" *ngIf="inProgress">
<div class="mdl-spinner mdl-js-spinner is-active" [mdl]></div>
</div>
</div>
m-proChannel__home {
display: block;
padding: 0 32px;
}
.m-proChannel__home {
m-pro--channel--categories {
margin: 16px 0 32px;
@media screen and (max-width: $max-mobile) {
display: none;
}
}
.m-proChannelHome__section {
margin-bottom: 72px;
&:last-child {
margin-bottom: 0;
}
> h2 {
margin: 0 0 24px;
padding: 0;
text-align: center;
text-transform: uppercase;
font-size: 24px;
font-weight: 300;
letter-spacing: 2px;
line-height: 1.25;
> a {
font: inherit;
text-decoration: none;
}
}
}
.m-proChannelHome__loader {
text-align: center;
margin: 32px auto;
}
.m-proChannelHome__featuredContent,
.m-proChannelHome__categoryContent {
width: 80%;
margin: 0 auto;
display: grid;
grid-gap: 24px;
@media screen and (max-width: $max-mobile) {
width: 100%;
}
}
.m-proChannelHome__featuredContent {
grid-template-columns: repeat(2, 1fr);
*:nth-child(1) {
grid-column: span 2;
}
@media screen and (max-width: $max-mobile) {
grid-template-columns: 100%;
*:nth-child(1) {
grid-column: initial;
}
}
}
.m-proChannelHome__categoryContent {
grid-template-columns: repeat(2, 1fr);
@media screen and (max-width: $max-mobile) {
grid-template-columns: 100%;
}
}
}
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { NavItems, ProChannelService } from '../channel.service';
import { OverlayModalService } from '../../../../services/ux/overlay-modal';
import { MindsTitle } from '../../../../services/ux/title';
@Component({
selector: 'm-pro--channel-home',
changeDetection: ChangeDetectionStrategy.Default,
templateUrl: 'home.component.html',
})
export class ProChannelHomeComponent implements OnInit, OnDestroy {
inProgress: boolean = false;
featuredContent: Array<any> = [];
categories: Array<{
tag: { tag: string; label: string };
content: Array<Observable<any>>;
}> = [];
moreData: boolean = true;
constructor(
protected router: Router,
protected channelService: ProChannelService,
protected modalService: OverlayModalService,
protected title: MindsTitle,
protected cd: ChangeDetectorRef
) {}
ngOnInit() {
this.load();
this.setMenuNavItems();
this.setTitle();
}
ngOnDestroy() {
this.channelService.destroyMenuNavItems();
}
async load() {
const MAX_FEATURED_CONTENT = 17; // 1 + (8 * 2)
this.inProgress = true;
this.featuredContent = [];
this.categories = [];
this.moreData = true;
this.detectChanges();
try {
this.featuredContent = await this.channelService.getFeaturedContent();
this.detectChanges();
const { content } = await this.channelService.getContent({
limit: MAX_FEATURED_CONTENT,
});
this.featuredContent = this.featuredContent
.concat(content)
.slice(0, MAX_FEATURED_CONTENT);
this.detectChanges();
this.categories = await this.channelService.getAllCategoriesContent();
this.detectChanges();
} catch (e) {
this.moreData = false;
}
this.inProgress = false;
this.detectChanges();
}
setMenuNavItems() {
const tags = this.channelService.currentChannel.pro_settings.tag_list.concat(
[]
);
const navItems: Array<NavItems> = tags.map(tag => ({
label: tag.label,
onClick: () => {
this.navigateToCategory(tag.tag);
},
isActive: () => {
return false;
},
}));
this.channelService.pushMenuNavItems(navItems, true);
}
setTitle() {
this.title.setTitle('Home');
}
getCategoryRoute(tag) {
if (!this.channelService.currentChannel || !tag) {
return [];
}
return this.channelService.getRouterLink('all', { hashtag: tag });
}
onContentClick(entity: any) {
return this.channelService.open(entity, this.modalService);
}
navigateToCategory(tag) {
this.router.navigate(
this.channelService.getRouterLink('all', { hashtag: tag })
);
}
get settings() {
return (
this.channelService.currentChannel &&
this.channelService.currentChannel.pro_settings
);
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
}
<m-pro--channel--categories
[selectedHashtag]="selectedHashtag"
(onSelectTag)="selectHashtag($event)"
[style.visibility]="shouldShowCategories ? 'visible' : 'hidden'"
></m-pro--channel--categories>
<h1 class="m-proChannelList__title" [ngSwitch]="type">
<ng-container *ngSwitchCase="'all'" i18n>
All
</ng-container>
<ng-container *ngSwitchCase="'videos'" i18n>
Videos
</ng-container>
<ng-container *ngSwitchCase="'images'" i18n>
Images
</ng-container>
<ng-container *ngSwitchCase="'blogs'" i18n>
Articles
</ng-container>
<ng-container *ngSwitchCase="'groups'" i18n>
Groups
</ng-container>
<ng-container *ngSwitchCase="'activities'" i18n>
Feed
</ng-container>
</h1>
<div class="m-proChannelList__tools" *ngIf="query !== ''">
<div class="m-proChannelListTools__searchResult">
<span>
Showing results for: <strong> {{ query }}</strong></span
>
</div>
</div>
<div class="m-proChannelList__content">
<ul
class="m-proChannelListContent__list"
[class.m-proChannelListContent__normalList]="type === 'activities'"
>
<li *ngFor="let entity$ of entities$ | async; let i = index">
<ng-container *ngIf="entity$ | async as entity">
<ng-container
*ngIf="
type === 'all' ||
type === 'images' ||
type === 'videos' ||
type === 'blogs'
"
>
<m-pro--channel-tile
[entity]="entity"
(click)="onTileClicked(entity)"
></m-pro--channel-tile>
</ng-container>
<ng-container *ngIf="type === 'groups'">
<m-pro--channel--group-tile
[entity]="entity"
(onOpen)="onTileClicked(entity)"
[class.big]="i === 0 || i === 1"
></m-pro--channel--group-tile>
</ng-container>
<ng-container *ngIf="type === 'activities'">
<minds-activity [object]="entity"></minds-activity>
</ng-container>
</ng-container>
</li>
</ul>
<ng-container *ngIf="!(inProgress$ | async)">
<div
*ngIf="hasMore$ | async; else noMore"
class="m-proChannelListContent__loadMore"
(click)="loadMore()"
i18n
>
Click to load more
</div>
<ng-template #noMore>
<div class="m-proChannelListContent__loadMore" i18n>
Nothing more to load
</div>
</ng-template>
</ng-container>
<div class="m-proChannelList__loader" *ngIf="inProgress$ | async">
<div class="mdl-spinner mdl-js-spinner is-active" [mdl]></div>
</div>
</div>
This diff is collapsed.
This diff is collapsed.
<section class="m-proChannelLogin__hero">
<div class="m-proChannelLoginHero__inner">
<div class="m-proChannelLoginHero__slogans">
<h2>{{ settings?.headline }}</h2>
</div>
<div class="m-proChannelLogin__form">
<ng-container *ngIf="currentSection === 'login'">
<span class="m-proChannelLogin__subtext">
Not on {{ settings?.title }}?
<a (click)="currentSection = 'register'">Start a Minds channel</a>
</span>
<minds-form-login (done)="registered()"></minds-form-login>
</ng-container>
<ng-container *ngIf="currentSection === 'register'">
<span class="m-proChannelLogin__subtext">
<a (click)="currentSection = 'login'">
I already have a Minds account
</a>
</span>
<minds-form-register
[referrer]="referrer"
(done)="registered()"
></minds-form-register>
</ng-container>
</div>
</div>
</section>
This diff is collapsed.
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { Session } from '../../../../services/session';
import { ProChannelService } from '../channel.service';
import { Storage } from '../../../../services/storage';
@Component({
selector: 'm-pro--channel-login',
templateUrl: 'login.component.html',
})
export class ProChannelLoginComponent {
username: string;
currentSection: 'login' | 'register' = 'login';
paramsSubscription: Subscription;
redirectTo: string;
get settings() {
return this.service.currentChannel.pro_settings;
}
get referrer() {
return this.service.currentChannel.username;
}
constructor(
public session: Session,
public service: ProChannelService,
private router: Router,
private route: ActivatedRoute,
private storage: Storage
) {
this.paramsSubscription = this.route.params.subscribe(params => {
if (params['username']) {
this.username = params['username'];
}
if (this.session.isLoggedIn()) {
this.router.navigate(this.service.getRouterLink('home'));
}
});
}
ngOnInit() {
this.redirectTo = this.storage.get('redirect');
}
registered() {
if (this.redirectTo) {
this.storage.destroy('redirect');
this.router.navigate([this.redirectTo]);
return;
}
this.router.navigate(this.service.getRouterLink('home'));
}
}
<div class="mdl-textfield mdl-js-textfield m-pro__searchBox">
<i class="material-icons search">search</i>
<input
[ngModel]="query"
(ngModelChange)="queryChange.emit($event)"
id="search"
autocomplete="off"
class="mdl-textfield__input ng-pristine ng-valid ng-touched"
type="text"
(keyup.enter)="onSearch.emit()"
/>
<ng-container *ngIf="query">
<i class="material-icons clear" (click)="onClearSearch.emit()">close</i>
</ng-container>
<label class="mdl-textfield__label" for="search"></label>
</div>
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
@Component({
selector: 'm-pro__searchBox',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'search-box.component.html',
})
export class SearchBoxComponent {
@Input() query: string = '';
@Output() queryChange: EventEmitter<string> = new EventEmitter<string>();
@Output() onSearch: EventEmitter<void> = new EventEmitter<void>();
@Output() onClearSearch: EventEmitter<void> = new EventEmitter<void>();
}
m-pro--subscription {
.m-proSubscription__error {
display: block;
font-weight: bold;
color: #fff;
margin: 8px 0 0 5px;
}
}
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.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.