Add auth, nav, and home page
This commit is contained in:
parent
b1e2ff4813
commit
d678707dc5
47
src/my-prayer-journal/package-lock.json
generated
47
src/my-prayer-journal/package-lock.json
generated
@ -500,6 +500,20 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-9.1.9.tgz",
|
||||
"integrity": "sha512-4u+CWMPB4hCkAsFCEzC94YEWT0wVozqGkc/Dortt2hFaqvZpIegg6iJVZlDxuyDjzFYBPnnbTDdgiTTA8ckfuA=="
|
||||
},
|
||||
"@auth0/auth0-spa-js": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-1.9.0.tgz",
|
||||
"integrity": "sha512-p+9m13k8MMpIthoWso7pXxaEZq5tAon8GPUPU+Wu4hgDQyJZIzqtCMHcQT0qdzfK/KoZwmoifj4aOvJxKO9I4Q==",
|
||||
"requires": {
|
||||
"abortcontroller-polyfill": "^1.4.0",
|
||||
"browser-tabs-lock": "^1.2.8",
|
||||
"core-js": "^3.6.4",
|
||||
"es-cookie": "^1.3.2",
|
||||
"fast-text-encoding": "^1.0.1",
|
||||
"promise-polyfill": "^8.1.3",
|
||||
"unfetch": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz",
|
||||
@ -1940,6 +1954,11 @@
|
||||
"through": ">=2.2.7 <3"
|
||||
}
|
||||
},
|
||||
"abortcontroller-polyfill": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz",
|
||||
"integrity": "sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA=="
|
||||
},
|
||||
"accepts": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
|
||||
@ -2617,6 +2636,11 @@
|
||||
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
|
||||
"dev": true
|
||||
},
|
||||
"browser-tabs-lock": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.2.8.tgz",
|
||||
"integrity": "sha512-Xrj33YUTltPDoGrD1KnaAn5ZuxnnlJFcIW9srVTPHbMNPd9MlcnBCWaGV0STlvGKu8Ok0ad5qxyx5sIwFTr/Ig=="
|
||||
},
|
||||
"browserify-aes": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
|
||||
@ -3520,8 +3544,7 @@
|
||||
"core-js": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
|
||||
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
|
||||
},
|
||||
"core-js-compat": {
|
||||
"version": "3.6.5",
|
||||
@ -4535,6 +4558,11 @@
|
||||
"string.prototype.trimright": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"es-cookie": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
|
||||
"integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q=="
|
||||
},
|
||||
"es-to-primitive": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
|
||||
@ -4906,6 +4934,11 @@
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-text-encoding": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.2.tgz",
|
||||
"integrity": "sha512-5rQdinSsycpzvAoHga2EDn+LRX1d5xLFsuNG0Kg61JrAT/tASXcLL0nf/33v+sAxlQcfYmWbTURa1mmAf55jGw=="
|
||||
},
|
||||
"fastparse": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
||||
@ -9419,6 +9452,11 @@
|
||||
"integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=",
|
||||
"dev": true
|
||||
},
|
||||
"promise-polyfill": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz",
|
||||
"integrity": "sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g=="
|
||||
},
|
||||
"promise-retry": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-1.1.1.tgz",
|
||||
@ -12078,6 +12116,11 @@
|
||||
"integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==",
|
||||
"dev": true
|
||||
},
|
||||
"unfetch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz",
|
||||
"integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg=="
|
||||
},
|
||||
"unicode-canonical-property-names-ecmascript": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"@angular/platform-browser": "~9.1.9",
|
||||
"@angular/platform-browser-dynamic": "~9.1.9",
|
||||
"@angular/router": "~9.1.9",
|
||||
"@auth0/auth0-spa-js": "^1.9.0",
|
||||
"rxjs": "~6.5.4",
|
||||
"tslib": "^1.10.0",
|
||||
"zone.js": "~0.10.2"
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { HomeComponent } from './pages/home/home.component'
|
||||
|
||||
|
||||
const routes: Routes = [];
|
||||
const routes: Routes = [
|
||||
{ path: '', component: HomeComponent }
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
|
@ -5,10 +5,12 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { AppComponent } from './app.component'
|
||||
import { SharedModule } from './shared/shared.module'
|
||||
import { HomeComponent } from './pages/home/home.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
AppComponent,
|
||||
HomeComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
16
src/my-prayer-journal/src/app/auth.service.spec.ts
Normal file
16
src/my-prayer-journal/src/app/auth.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
|
||||
import { AuthService } from './auth.service'
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({})
|
||||
service = TestBed.inject(AuthService)
|
||||
})
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy()
|
||||
})
|
||||
})
|
126
src/my-prayer-journal/src/app/auth.service.ts
Normal file
126
src/my-prayer-journal/src/app/auth.service.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import createAuth0Client, { GetUserOptions } from '@auth0/auth0-spa-js'
|
||||
import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client'
|
||||
import { BehaviorSubject, combineLatest, from, Observable, of, throwError } from 'rxjs'
|
||||
import { catchError, concatMap, shareReplay, tap } from 'rxjs/operators'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
// Create an observable of Auth0 instance of client
|
||||
auth0Client$ = (from(
|
||||
createAuth0Client({
|
||||
domain: "djs-consulting.auth0.com",
|
||||
client_id: "Of2s0RQCQ3mt3dwIkOBY5h85J9sXbF2n",
|
||||
redirect_uri: `${window.location.origin}`
|
||||
})
|
||||
) as Observable<Auth0Client>).pipe(
|
||||
shareReplay(1), // Every subscription receives the same shared value
|
||||
catchError(err => throwError(err))
|
||||
)
|
||||
|
||||
// Define observables for SDK methods that return promises by default
|
||||
// For each Auth0 SDK method, first ensure the client instance is ready
|
||||
// concatMap: Using the client instance, call SDK method; SDK returns a promise
|
||||
// from: Convert that resulting promise into an observable
|
||||
isAuthenticated$ = this.auth0Client$.pipe(
|
||||
concatMap((client: Auth0Client) => from(client.isAuthenticated())),
|
||||
tap(res => this.loggedIn = res)
|
||||
)
|
||||
handleRedirectCallback$ = this.auth0Client$.pipe(
|
||||
concatMap((client: Auth0Client) => from(client.handleRedirectCallback()))
|
||||
)
|
||||
// Create subject and public observable of user profile data
|
||||
private userProfileSubject$ = new BehaviorSubject<any>(null)
|
||||
userProfile$ = this.userProfileSubject$.asObservable()
|
||||
// Create a local property for login status
|
||||
loggedIn = false
|
||||
|
||||
constructor(private router: Router) {
|
||||
// On initial load, check authentication state with authorization server
|
||||
// Set up local auth streams if user is already authenticated
|
||||
this.localAuthSetup()
|
||||
// Handle redirect from Auth0 login
|
||||
this.handleAuthCallback()
|
||||
}
|
||||
|
||||
// When calling, options can be passed if desired
|
||||
// https://auth0.github.io/auth0-spa-js/classes/auth0client.html#getuser
|
||||
getUser$(options?: GetUserOptions): Observable<any> {
|
||||
return this.auth0Client$.pipe(
|
||||
concatMap((client: Auth0Client) => from(client.getUser(options))),
|
||||
tap(user => this.userProfileSubject$.next(user))
|
||||
)
|
||||
}
|
||||
|
||||
private localAuthSetup() {
|
||||
// This should only be called on app initialization
|
||||
// Set up local authentication streams
|
||||
const checkAuth$ = this.isAuthenticated$.pipe(
|
||||
concatMap((loggedIn: boolean) => {
|
||||
if (loggedIn) {
|
||||
// If authenticated, get user and set in app
|
||||
// NOTE: you could pass options here if needed
|
||||
return this.getUser$()
|
||||
}
|
||||
// If not authenticated, return stream that emits 'false'
|
||||
return of(loggedIn)
|
||||
})
|
||||
)
|
||||
checkAuth$.subscribe()
|
||||
}
|
||||
|
||||
login(redirectPath: string = '/') {
|
||||
// A desired redirect path can be passed to login method
|
||||
// (e.g., from a route guard)
|
||||
// Ensure Auth0 client instance exists
|
||||
this.auth0Client$.subscribe((client: Auth0Client) => {
|
||||
// Call method to log in
|
||||
client.loginWithRedirect({
|
||||
redirect_uri: `${window.location.origin}`,
|
||||
appState: { target: redirectPath }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleAuthCallback() {
|
||||
// Call when app reloads after user logs in with Auth0
|
||||
const params = window.location.search
|
||||
if (params.includes('code=') && params.includes('state=')) {
|
||||
let targetRoute: string // Path to redirect to after login processsed
|
||||
const authComplete$ = this.handleRedirectCallback$.pipe(
|
||||
// Have client, now call method to handle auth callback redirect
|
||||
tap(cbRes => {
|
||||
// Get and set target redirect route from callback results
|
||||
targetRoute = cbRes.appState && cbRes.appState.target ? cbRes.appState.target : '/';
|
||||
}),
|
||||
concatMap(() => {
|
||||
// Redirect callback complete; get user and login status
|
||||
return combineLatest([
|
||||
this.getUser$(),
|
||||
this.isAuthenticated$
|
||||
])
|
||||
})
|
||||
)
|
||||
// Subscribe to authentication completion observable
|
||||
// Response will be an array of user and login status
|
||||
authComplete$.subscribe(([user, loggedIn]) => {
|
||||
// Redirect to target route after callback processing
|
||||
this.router.navigate([targetRoute])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
// Ensure Auth0 client instance exists
|
||||
this.auth0Client$.subscribe((client: Auth0Client) => {
|
||||
// Call method to log out
|
||||
client.logout({
|
||||
client_id: "Of2s0RQCQ3mt3dwIkOBY5h85J9sXbF2n",
|
||||
returnTo: `${window.location.origin}`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
13
src/my-prayer-journal/src/app/pages/home/home.component.html
Normal file
13
src/my-prayer-journal/src/app/pages/home/home.component.html
Normal file
@ -0,0 +1,13 @@
|
||||
<main role="main" class="mpj-main-content">
|
||||
<p> </p>
|
||||
<p>
|
||||
myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them,
|
||||
update them as God moves in the situation, and record a final answer received on that request. It also allows
|
||||
individuals to review their answered prayers.
|
||||
</p>
|
||||
<p>
|
||||
This site is open and available to the general public. To get started, simply click the “Log On” link
|
||||
above, and log on with either a Microsoft or Google account. You can also learn more about the site at the
|
||||
“Docs” link, also above.
|
||||
</p>
|
||||
</main>
|
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { HomeComponent } from './home.component'
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent
|
||||
let fixture: ComponentFixture<HomeComponent>
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ HomeComponent ]
|
||||
})
|
||||
.compileComponents()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HomeComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
})
|
15
src/my-prayer-journal/src/app/pages/home/home.component.ts
Normal file
15
src/my-prayer-journal/src/app/pages/home/home.component.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
/**
|
||||
* Home Page.
|
||||
*
|
||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||
* @version 3
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html'
|
||||
})
|
||||
export class HomeComponent {
|
||||
constructor() { }
|
||||
}
|
@ -2,12 +2,22 @@ import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { MatToolbarModule } from '@angular/material/toolbar'
|
||||
|
||||
import { NavigationComponent } from './ui/navigation/navigation.component'
|
||||
import { TopHeaderComponent } from './ui/top-header/top-header.component'
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* myPrayerJournal Shared Module.
|
||||
*
|
||||
* This module contains UI components designed to be used throughout the application.
|
||||
*
|
||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||
* @version 3
|
||||
*/
|
||||
@NgModule({
|
||||
declarations: [TopHeaderComponent],
|
||||
declarations: [
|
||||
NavigationComponent,
|
||||
TopHeaderComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatToolbarModule
|
||||
|
@ -0,0 +1,9 @@
|
||||
<ng-container *ngIf="auth.loggedIn">
|
||||
<a routerLink="/journal">Journal</a>
|
||||
<a routerLink="/requests/active">Active</a>
|
||||
<a *ngIf="hasSnoozed" routerLink="/requests/snoozed">Snoozed</a>
|
||||
<a routerLink="/requests/answered">Answered</a>
|
||||
<a href="/user/log-off" (click)="logOff($event)">Log Off</a>
|
||||
</ng-container>
|
||||
<a *ngIf="!auth.loggedIn" href="/user/log-on" (click)="logOn($event)">Log On</a>
|
||||
<a href="https://docs.prayerjournal.me" target="_blank">Docs</a>
|
@ -0,0 +1,11 @@
|
||||
a,
|
||||
a:link,
|
||||
a:visited
|
||||
color: white
|
||||
font-size: .9rem
|
||||
text-transform: uppercase
|
||||
text-decoration: none
|
||||
padding: 0 1rem
|
||||
|
||||
a:hover
|
||||
border-bottom: solid 1px white
|
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NavigationComponent } from './navigation.component';
|
||||
|
||||
describe('NavigationComponent', () => {
|
||||
let component: NavigationComponent;
|
||||
let fixture: ComponentFixture<NavigationComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ NavigationComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NavigationComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,41 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AuthService } from 'src/app/auth.service'
|
||||
|
||||
/**
|
||||
* myPrayerJournal Navigation.
|
||||
*
|
||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||
* @version 3
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrls: ['./navigation.component.sass']
|
||||
})
|
||||
export class NavigationComponent {
|
||||
|
||||
// TODO: get this from the store's state
|
||||
hasSnoozed = false
|
||||
|
||||
constructor(public auth: AuthService) { }
|
||||
|
||||
/**
|
||||
* Start the log on process.
|
||||
*
|
||||
* @param e The click event (used to stop the default action)
|
||||
*/
|
||||
logOn(e: Event) {
|
||||
e.preventDefault()
|
||||
this.auth.login()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user off.
|
||||
*
|
||||
* @param e The click event (used to stop the default action)
|
||||
*/
|
||||
logOff(e: Event) {
|
||||
e.preventDefault()
|
||||
this.auth.logout()
|
||||
}
|
||||
}
|
@ -6,6 +6,6 @@
|
||||
</a>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<span>Links go here</span>
|
||||
<app-navigation></app-navigation>
|
||||
</mat-toolbar-row>
|
||||
</mat-toolbar>
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
|
||||
/**
|
||||
* myPrayerJournal Top Header.
|
||||
*
|
||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||
* @version 3
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-top-header',
|
||||
templateUrl: './top-header.component.html',
|
||||
|
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MyPrayerJournal</title>
|
||||
<title>myPrayerJournal</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
@ -1 +1,37 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
p
|
||||
margin-bottom: 0
|
||||
footer
|
||||
border-top: solid 1px lightgray
|
||||
margin: 1rem -1rem 0
|
||||
padding: 0 1rem
|
||||
footer p
|
||||
margin: 0
|
||||
.mpj-full-page-card
|
||||
font-size: 1rem
|
||||
line-height: 1.25rem
|
||||
.mpj-main-content
|
||||
max-width: 61rem
|
||||
padding: 0 .5rem
|
||||
margin: auto
|
||||
.mpj-request-text
|
||||
white-space: pre-line
|
||||
p.mpj-request-text
|
||||
margin-top: 0
|
||||
.mpj-text-center
|
||||
text-align: center
|
||||
.mpj-text-nowrap
|
||||
white-space: nowrap
|
||||
.mpj-text-right
|
||||
text-align: right
|
||||
.mpj-muted-text
|
||||
color: rgba(0, 0, 0, .6)
|
||||
.mpj-valign-top
|
||||
vertical-align: top
|
||||
.mpj-narrow
|
||||
max-width: 40rem
|
||||
margin: auto
|
||||
.mpj-skinny
|
||||
max-width: 20rem
|
||||
margin: auto
|
||||
.mpj-full-width
|
||||
width: 100%
|
||||
|
Loading…
Reference in New Issue
Block a user