feat(aio): implement resources with resources.json

This commit is contained in:
Ward Bell
2017-04-14 17:53:49 -07:00
committed by Pete Bacon Darwin
parent 46b0c7a18c
commit 196203f6d7
21 changed files with 1445 additions and 29 deletions

View File

@ -1,25 +1,22 @@
import { Component, OnInit } from '@angular/core';
import { Contributor } from './contributors.model';
import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ContributorGroup } from './contributors.model';
import { ContributorService } from './contributor.service';
@Component({
selector: `aio-contributor-list`,
selector: 'aio-contributor-list',
template: `
<section *ngFor="let group of groups" class="grid-fluid">
<h4 class="title">{{group}}</h4>
<aio-contributor *ngFor="let person of contributorGroups[group]" [person]="person"></aio-contributor>
<section *ngFor="let group of groups | async" class="grid-fluid">
<h4 class="title">{{group.name}}</h4>
<aio-contributor *ngFor="let person of group.contributors" [person]="person"></aio-contributor>
</section>`
})
export class ContributorListComponent implements OnInit {
contributorGroups = new Map<string, Contributor[]>();
groups: string[];
export class ContributorListComponent {
groups: Observable<ContributorGroup[]>;
constructor(private contributorService: ContributorService) { }
ngOnInit() {
this.contributorService.contributors.subscribe(cgs => {
this.groups = ['Lead', 'Google', 'Community'];
this.contributorGroups = cgs;
});
constructor(private contributorService: ContributorService) {
this.groups = this.contributorService.contributors;
}
}

View File

@ -0,0 +1,118 @@
import { ReflectiveInjector } from '@angular/core';
import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend } from '@angular/http/testing';
import { ContributorService } from './contributor.service';
import { Contributor, ContributorGroup } from './contributors.model';
import { Logger } from 'app/shared/logger.service';
describe('ContributorService', () => {
let injector: ReflectiveInjector;
let backend: MockBackend;
let contribService: ContributorService;
function createResponse(body: any) {
return new Response(new ResponseOptions({ body: JSON.stringify(body) }));
}
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
ContributorService,
{ provide: ConnectionBackend, useClass: MockBackend },
{ provide: RequestOptions, useClass: BaseRequestOptions },
Http,
Logger
]);
backend = injector.get(ConnectionBackend);
contribService = injector.get(ContributorService);
});
it('should be creatable', () => {
expect(contribService).toBeTruthy();
});
it('should make a single connection to the server', () => {
expect(backend.connectionsArray.length).toEqual(1);
expect(backend.connectionsArray[0].request.url).toEqual('content/contributors.json');
});
describe('#contributors', () => {
let contribs: ContributorGroup[];
let testData: any;
beforeEach(() => {
testData = getTestContribs();
backend.connectionsArray[0].mockRespond(createResponse(testData));
contribService.contributors.subscribe(results => contribs = results);
});
it('contributors observable should complete', () => {
let completed = false;
contribService.contributors.subscribe(null, null, () => completed = true);
expect(true).toBe(true, 'observable completed');
});
it('should reshape the contributor json to expected result', () => {
const groupNames = contribs.map(g => g.name);
expect(groupNames).toEqual(['Lead', 'Google', 'Community']);
});
it('should have expected "Lead" contribs in order', () => {
const leads = contribs[0];
const actualLeadNames = leads.contributors.map(l => l.name).join(',');
const expectedLeadNames = [testData.igor, testData.misko, testData.naomi].map(l => l.name).join(',');
expect(actualLeadNames).toEqual(expectedLeadNames);
});
});
it('should do WHAT(?) if the request fails');
});
function getTestContribs() {
// tslint:disable:quotemark
return {
"misko": {
"name": "Miško Hevery",
"picture": "misko.jpg",
"twitter": "mhevery",
"website": "http://misko.hevery.com",
"bio": "Miško Hevery is the creator of AngularJS framework.",
"group": "Lead"
},
"igor": {
"name": "Igor Minar",
"picture": "igor-minar.jpg",
"twitter": "IgorMinar",
"website": "https://google.com/+IgorMinar",
"bio": "Igor is a software engineer at Google.",
"group": "Lead"
},
"kara": {
"name": "Kara Erickson",
"picture": "kara-erickson.jpg",
"twitter": "karaforthewin",
"website": "https://github.com/kara",
"bio": "Kara is a software engineer on the Angular team at Google and a co-organizer of the Angular-SF Meetup. ",
"group": "Google"
},
"jeffcross": {
"name": "Jeff Cross",
"picture": "jeff-cross.jpg",
"twitter": "jeffbcross",
"website": "https://twitter.com/jeffbcross",
"bio": "Jeff was one of the earliest core team members on AngularJS.",
"group": "Community"
},
"naomi": {
"name": "Naomi Black",
"picture": "naomi.jpg",
"twitter": "naomitraveller",
"website": "http://google.com/+NaomiBlack",
"bio": "Naomi is Angular's TPM generalist and jack-of-all-trades.",
"group": "Lead"
}
};
}

View File

@ -6,13 +6,14 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/operator/publishLast';
import { Logger } from 'app/shared/logger.service';
import { Contributor } from './contributors.model';
import { Contributor, ContributorGroup } from './contributors.model';
const contributorsPath = 'content/contributors.json';
const knownGroups = ['Lead', 'Google', 'Community'];
@Injectable()
export class ContributorService {
contributors: Observable<Map<string, Contributor[]>>;
contributors: Observable<ContributorGroup[]>;
constructor(private http: Http, private logger: Logger) {
this.contributors = this.getContributors();
@ -21,24 +22,49 @@ export class ContributorService {
private getContributors() {
const contributors = this.http.get(contributorsPath)
.map(res => res.json())
.map(contribs => {
const contribGroups = new Map<string, Contributor[]>();
// Create group map
.map(contribs => {
const contribMap = new Map<string, Contributor[]>();
Object.keys(contribs).forEach(key => {
const contributor = contribs[key];
const group = contributor.group;
const contribGroup = contribGroups[group];
const contribGroup = contribMap[group];
if (contribGroup) {
contribGroup.push(contributor);
} else {
contribGroups[group] = [contributor];
contribMap[group] = [contributor];
}
});
return contribGroups;
return contribMap;
})
// Flatten group map into sorted group array of sorted contributors
.map(cmap => {
return Object.keys(cmap).map(key => {
const order = knownGroups.indexOf(key);
return {
name: key,
order: order === -1 ? knownGroups.length : order,
contributors: cmap[key].sort(compareContributors)
} as ContributorGroup;
})
.sort(compareGroups);
})
.publishLast();
contributors.connect();
return contributors;
}
}
function compareContributors(l: Contributor, r: Contributor) {
return l.name.toUpperCase() > r.name.toUpperCase() ? 1 : -1;
}
function compareGroups(l: ContributorGroup, r: ContributorGroup) {
return l.order === r.order ?
(l.name > r.name ? 1 : -1) :
l.order > r.order ? 1 : -1;
}

View File

@ -1,3 +1,9 @@
export class ContributorGroup {
name: string;
order: number;
contributors: Contributor[];
}
export class Contributor {
group: string;
name: string;

View File

@ -20,13 +20,15 @@ import { ContributorListComponent } from './contributor/contributor-list.compone
import { ContributorComponent } from './contributor/contributor.component';
import { DocTitleComponent } from './doc-title.component';
import { LiveExampleComponent, EmbeddedPlunkerComponent } from './live-example/live-example.component';
import { ResourceListComponent } from './resource/resource-list.component';
import { ResourceService } from './resource/resource.service';
/** Components that can be embedded in docs
* such as CodeExampleComponent, LiveExampleComponent,...
*/
export const embeddedComponents: any[] = [
ApiListComponent, CodeExampleComponent, CodeTabsComponent,
ContributorListComponent, DocTitleComponent, LiveExampleComponent
ContributorListComponent, DocTitleComponent, LiveExampleComponent, ResourceListComponent
];
/** Injectable class w/ property returning components that can be embedded in docs */
@ -46,7 +48,8 @@ export class EmbeddedComponents {
ContributorService,
CopierService,
EmbeddedComponents,
PrettyPrinter
PrettyPrinter,
ResourceService
],
entryComponents: [ embeddedComponents ]
})

View File

@ -0,0 +1,42 @@
<div class="resources grid-fixed">
<div class="c8">
<div class="l-flex--column">
<div class="showcase" *ngFor="let category of categories">
<header class="c-resource-header">
<a class="h-anchor-offset" id="{{category.id}}"></a>
<h2 class="text-headline text-uppercase">{{category.title}}</h2>
</header>
<div class="shadow-1">
<div *ngFor="let subCategory of category.subCategories">
<a class="h-anchor-offset" id="{{subCategory.id}}"></a>
<h3 class="text-uppercase subcategory-title">{{subCategory.title}}</h3>
<div *ngFor="let resource of subCategory.resources">
<div class="c-resource" *ngIf="resource.rev">
<a class="l-flex--column resource-row-link" target="_blank" [href]="resource.url">
<div>
<h4>{{resource.title}}</h4>
<p class="resource-description">{{resource.desc || 'No Description'}}</p>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="c3">
<div class="c-resource-nav shadow-1 l-flex--column h-affix" [ngClass]="{ 'affix-top': scrollPos > 200 }">
<div class="category" *ngFor="let category of categories">
<a class="category-link h-capitalize" [href]="href(category)">{{category.title}}</a>
<div class="subcategory" *ngFor="let subCategory of category.subCategories">
<a class="subcategory-link" [href]="href(subCategory)">{{subCategory.title}}</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,81 @@
import { ReflectiveInjector } from '@angular/core';
import { PlatformLocation } from '@angular/common';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { ResourceListComponent } from './resource-list.component';
import { ResourceService } from './resource.service';
import { Category } from './resource.model';
// Testing the component class behaviors, independent of its template
// Let e2e tests verify how it displays.
describe('ResourceListComponent', () => {
let injector: ReflectiveInjector;
let location: TestPlatformLocation;
let resourceService: TestResourceService;
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
ResourceListComponent,
{provide: PlatformLocation, useClass: TestPlatformLocation },
{provide: ResourceService, useClass: TestResourceService }
]);
location = injector.get(PlatformLocation);
resourceService = injector.get(ResourceService);
});
it('should set the location w/o leading slashes', () => {
location.pathname = '////resources';
const component = getComponent();
expect(component.location).toBe('resources');
});
it('href(id) should return the expected href', () => {
location.pathname = '////resources';
const component = getComponent();
expect(component.href({id: 'foo'})).toBe('resources#foo');
});
it('should set scroll position to zero when no target element', () => {
const component = getComponent();
component.onScroll(undefined);
expect(component.scrollPos).toBe(0);
});
it('should set scroll position to element.scrollTop when that is defined', () => {
const component = getComponent();
component.onScroll({scrollTop: 42});
expect(component.scrollPos).toBe(42);
});
it('should set scroll position to element.body.scrollTop when that is defined', () => {
const component = getComponent();
component.onScroll({body: {scrollTop: 42}});
expect(component.scrollPos).toBe(42);
});
it('should set scroll position to 0 when no target.body.scrollTop defined', () => {
const component = getComponent();
component.onScroll({body: {}});
expect(component.scrollPos).toBe(0);
});
//// Test Helpers ////
function getComponent(): ResourceListComponent { return injector.get(ResourceListComponent); }
class TestPlatformLocation {
pathname = 'resources';
}
class TestResourceService {
categories = of(getTestData);
}
function getTestData(): Category[] {
return []; // Not interested in the data in these tests
}
});

View File

@ -0,0 +1,37 @@
import { Component, HostListener, OnInit } from '@angular/core';
import { PlatformLocation } from '@angular/common';
import { Category } from './resource.model';
import { ResourceService } from './resource.service';
@Component({
selector: 'aio-resource-list',
templateUrl: 'resource-list.component.html'
})
export class ResourceListComponent implements OnInit {
categories: Category[];
location: string;
scrollPos = 0;
constructor(
location: PlatformLocation,
private resourceService: ResourceService) {
this.location = location.pathname.replace(/^\/+/, '');
}
href(cat: {id: string}) {
return this.location + '#' + cat.id;
}
ngOnInit() {
// Not using async pipe because cats appear twice in template
// No need to unsubscribe because categories observable completes.
this.resourceService.categories.subscribe(cats => this.categories = cats);
}
@HostListener('window:scroll', ['$event.target'])
onScroll(target: any) {
this.scrollPos = target ? target.scrollTop || target.body.scrollTop || 0 : 0;
}
}

View File

@ -0,0 +1,23 @@
export class Category {
id: string; // "education"
title: string; // "Education"
order: number; // 2
subCategories: SubCategory[];
}
export class SubCategory {
id: string; // "books"
title: string; // "Books"
order: number; // 1
resources: Resource[];
}
export class Resource {
category: string; // "Education"
subCategory: string; // "Books"
id: string; // "-KLI8vJ0ZkvWhqPembZ7"
desc: string; // "This books shows all the steps necessary for the development of SPA"
rev: boolean; // true (always true in the original)
title: string; // "Practical Angular 2",
url: string; // "https://leanpub.com/practical-angular-2"
}

View File

@ -0,0 +1,161 @@
import { ReflectiveInjector } from '@angular/core';
import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend } from '@angular/http/testing';
import { ResourceService } from './resource.service';
import { Category, SubCategory, Resource } from './resource.model';
import { Logger } from 'app/shared/logger.service';
describe('ResourceService', () => {
let injector: ReflectiveInjector;
let backend: MockBackend;
let resourceService: ResourceService;
function createResponse(body: any) {
return new Response(new ResponseOptions({ body: JSON.stringify(body) }));
}
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
ResourceService,
{ provide: ConnectionBackend, useClass: MockBackend },
{ provide: RequestOptions, useClass: BaseRequestOptions },
Http,
Logger
]);
backend = injector.get(ConnectionBackend);
resourceService = injector.get(ResourceService);
});
it('should be creatable', () => {
expect(resourceService).toBeTruthy();
});
it('should make a single connection to the server', () => {
expect(backend.connectionsArray.length).toEqual(1);
expect(backend.connectionsArray[0].request.url).toEqual('content/resources.json');
});
describe('#categories', () => {
let categories: Category[];
let testData: any;
beforeEach(() => {
testData = getTestResources();
backend.connectionsArray[0].mockRespond(createResponse(testData));
resourceService.categories.subscribe(results => categories = results);
});
it('categories observable should complete', () => {
let completed = false;
resourceService.categories.subscribe(null, null, () => completed = true);
expect(true).toBe(true, 'observable completed');
});
it('should reshape contributors.json to sorted category array', () => {
const actualIds = categories.map(c => c.id).join(',');
expect(actualIds).toBe('cat-1,cat-3');
});
it('should convert ids to canonical form', () => {
// canonical form is lowercase with dashes for spaces
const cat = categories[1];
const sub = cat.subCategories[0];
const res = sub.resources[0];
expect(cat.id).toBe('cat-3', 'category id');
expect(sub.id).toBe('cat3-subcat2', 'subcat id');
expect(res.id).toBe('cat3-subcat2-res1', 'resources id');
});
it('resource knows its category and sub-category titles', () => {
const cat = categories[1];
const sub = cat.subCategories[0];
const res = sub.resources[0];
expect(res.category).toBe(cat.title, 'category title');
expect(res.subCategory).toBe(sub.title, 'subcategory title');
});
it('should have expected SubCategories of "Cat 3"', () => {
const actualIds = categories[1].subCategories.map(s => s.id).join(',');
expect(actualIds).toBe('cat3-subcat2,cat3-subcat1');
});
it('should have expected sorted resources of "Cat 1:SubCat1"', () => {
const actualIds = categories[0].subCategories[0].resources.map(r => r.id).join(',');
expect(actualIds).toBe('a-a-a,s-s-s,z-z-z');
});
});
it('should do WHAT(?) if the request fails');
});
function getTestResources() {
// tslint:disable:quotemark
return {
"Cat 3": {
"order": 3,
"subCategories": {
"Cat3 SubCat1": {
"order": 2,
"resources": {
"Cat3 SubCat1 Res1": {
"desc": "Meetup in Barcelona, Spain. ",
"rev": true,
"title": "Angular Beers",
"url": "http://www.meetup.com/AngularJS-Beers/"
},
"Cat3 SubCat1 Res2": {
"desc": "Angular Camps in Barcelona, Spain.",
"rev": true,
"title": "Angular Camp",
"url": "http://angularcamp.org/"
}
}
},
"Cat3 SubCat2": {
"order": 1,
"resources": {
"Cat3 SubCat2 Res1": {
"desc": "A community index of components and libraries",
"rev": true,
"title": "Catalog of Angular Components & Libraries",
"url": "https://a/b/c"
}
}
},
}
},
"Cat 1": {
"order": 1,
"subCategories": {
"Cat1 SubCat1": {
"order": 1,
"resources": {
"S S S": {
"desc": "SSS",
"rev": true,
"title": "Sssss",
"url": "http://s/s/s"
},
"A A A": {
"desc": "AAA",
"rev": true,
"title": "Aaaa",
"url": "http://a/a/a"
},
"Z Z Z": {
"desc": "ZZZ",
"rev": true,
"title": "Zzzzz",
"url": "http://z/z/z"
}
}
},
},
}
};
}

View File

@ -0,0 +1,83 @@
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/publishLast';
import { Logger } from 'app/shared/logger.service';
import { Category, Resource, SubCategory } from './resource.model';
const resourcesPath = 'content/resources.json';
@Injectable()
export class ResourceService {
categories: Observable<Category[]>;
constructor(private http: Http, private logger: Logger) {
this.categories = this.getCategories();
}
private getCategories(): Observable<Category[]> {
const categories = this.http.get(resourcesPath)
.map(res => res.json())
.map(data => mkCategories(data))
.publishLast();
categories.connect();
return categories;
};
}
// Extract sorted Category[] from resource JSON data
function mkCategories(categoryJson: any): Category[] {
return Object.keys(categoryJson).map(catKey => {
const cat = categoryJson[catKey];
return {
id: makeId(catKey),
title: catKey,
order: cat.order,
subCategories: mkSubCategories(cat.subCategories, catKey)
} as Category;
})
.sort(compareCats);
}
// Extract sorted SubCategory[] from JSON category data
function mkSubCategories(subCategoryJson: any, catKey: string): SubCategory[] {
return Object.keys(subCategoryJson).map(subKey => {
const sub = subCategoryJson[subKey];
return {
id: makeId(subKey),
title: subKey,
order: sub.order,
resources: mkResources(sub.resources, subKey, catKey)
} as SubCategory;
})
.sort(compareCats);
}
// Extract sorted Resource[] from JSON subcategory data
function mkResources(resourceJson: any, subKey: string, catKey: string): Resource[] {
return Object.keys(resourceJson).map(resKey => {
const res = resourceJson[resKey];
res.category = catKey;
res.subCategory = subKey;
res.id = makeId(resKey);
return res as Resource;
})
.sort(compareTitles);
}
function compareCats(l: Category | SubCategory, r: Category | SubCategory) {
return l.order === r.order ? compareTitles(l, r) : l.order > r.order ? 1 : -1;
}
function compareTitles(l: {title: string}, r: {title: string}) {
return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1;
}
function makeId(title: string) {
return title.toLowerCase().replace(/\s+/g, '-');
}

View File

@ -51,7 +51,12 @@ describe('NavigationService', () => {
expect(viewsEvents).toEqual([]);
backend.connectionsArray[0].mockRespond(createResponse({ TopBar: [ { url: 'a' }] }));
expect(viewsEvents).toEqual([{ TopBar: [ { url: 'a' }] }]);
});
it('navigationViews observable should complete', () => {
let completed = false;
navService.navigationViews.subscribe(null, null, () => completed = true);
expect(true).toBe(true, 'observable completed');
});
it('should return the same object to all subscribers', () => {

View File

@ -354,7 +354,7 @@ describe('LocationService', () => {
});
it('should call locationChanged with initial URL', () => {
const initialUrl = location.path().replace(/^\/+/, '');
const initialUrl = location.path().replace(/^\/+/, ''); // strip leading slashes
expect(gaLocationChanged.calls.count()).toBe(1, 'gaService.locationChanged');
const args = gaLocationChanged.calls.first().args;

View File

@ -125,4 +125,8 @@ header.bckground-sky.l-relative {
}
}
}
}
}
.text-uppercase {
text-transform: uppercase;
}

View File

@ -21,4 +21,5 @@
@import 'hr';
@import 'live-example';
@import 'scrollbar';
@import 'callout';
@import 'callout';
@import 'resources';

View File

@ -0,0 +1,238 @@
.text-headline {
margin: 0px 0px ($unit * 2) 0px;
font-size: 24px;
font-weight: 400;
line-height: 32px;
}
.grid-fixed {
margin: 0 auto;
*zoom: 1;
width: 960px;
}
.grid-fixed .c3, .grid-fixed .c8, {
display: inline;
margin-left: 10px;
margin-right: 10px;
}
.grid-fixed:after, .grid-fixed:before {
content: '.';
clear: both;
display: block;
overflow: hidden;
visibility: hidden;
font-size: 0;
line-height: 0;
width: 0;
height: 0;
}
.grid-fixed .c3 {
width: 220px;
}
.grid-fixed .c8 {
width: 620px;
}
@media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) {
.grid-fixed {
width: auto;
}
}
@media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) {
.grid-fixed .c3, .grid-fixed .c8 {
margin-left: 20px;
margin-right: 20px;
float: none;
display: block;
width: auto;
}
}
@media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 480px) {
.grid-fixed .c3, .grid-fixed .c8 {
margin-left: 0px;
margin-right: 0px;
float: none;
display: block;
width: auto;
}
}
@media handheld and (max-width: 900px), screen and (max-width: 900px) {
/* line 6, ../scss/_responsive.scss */
.grid-fixed{
margin: 0 auto;
*zoom: 1;
}
.grid-fixed:after, .grid-fixed:before, {
content: '.';
clear: both;
display: block;
overflow: hidden;
visibility: hidden;
font-size: 0;
line-height: 0;
width: 0;
height: 0;
}
}
@media handheld and (max-width: 480px), screen and (max-width: 480px) {
/* line 6, ../scss/_responsive.scss */
.grid-fixed {
margin: 0 auto;
*zoom: 1;
}
.grid-fixed:after, .grid-fixed:before {
content: '.';
clear: both;
display: block;
overflow: hidden;
visibility: hidden;
font-size: 0;
line-height: 0;
width: 0;
height: 0;
}
}
.resources {
.shadow-1 {
transition: box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 4px 0 rgba($black, 0.37);
}
.showcase {
margin-bottom: $unit * 6;
border-radius: 4px;
}
.h-affix {
position: fixed;
}
.affix-top {
top: 150px;
}
.c-resource {
h4 {
margin: 0;
line-height: 24px;
}
p {
margin: 0;
}
}
.c-resource-nav {
margin-top: 48px;
width: $unit * 20;
z-index: 1;
background-color: #fff;
border-radius: 2px;
a {
color: #373E41;
text-decoration: none;
}
.category {
padding: 10px 0;
.category-link {
display: block;
margin: 2px 0;
padding: 3px 14px;
font-size: 18px !important;
&:hover {
background: #edf0f2;
color: #2B85E7;
}
}
}
.subcategory {
.subcategory-link {
display: block;
margin: 2px 0;
padding: 4px 14px;
&:hover {
background: #edf0f2;
color: #2B85E7;
}
}
}
}
.h-anchor-offset {
display: block;
position: relative;
top: -20px;
visibility: hidden;
}
.l-flex--column {
display: flex;
flex-direction: column;
}
.c-resource-header {
margin-bottom: 16px;
}
.c-contribute {
margin-bottom: 24px;
}
.c-resource-header h2 {
margin: 0;
}
.subcategory-title {
padding: 16px 23px;
margin: 0;
background-color: $mist;
color: #373E41;
}
.h-capitalize {
text-transform: capitalize;
}
.h-hide {
display: none;
}
.resource-row-link {
color: #1a2326;
border: transparent solid 1px;
margin: 0;
padding: 16px 23px 16px 23px;
position: relative;
text-decoration: none;
transition: all .3s;
}
.resource-row-link:hover {
color: #1a2326;
text-decoration: none;
border-color: #2B85E7;
border-radius: 4px;
box-shadow: 0 8px 8px rgba(1, 67, 163, .24), 0 0 8px rgba(1, 67, 163, .12), 0 6px 18px rgba(43, 133, 231, .12);
transform: translate3d(0, -2px, 0);
}
@media(max-width: 900px) {
.c-resource-nav {
display: none;
}
}
}

View File

@ -14,6 +14,7 @@ $white: #FFFFFF;
$offwhite: #FAFAFA;
$backgroundgray: #F1F1F1;
$lightgray: #DBDBDB;
$mist: #ECEFF1;
$mediumgray: #7E7E7E;
$darkgray: #333;
$black: #0A1014;