.eslintrc.json
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json",
"e2e/tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/component-selector": [
"error",
{
"prefix": "app",
"style": "kebab-case",
"type": "element"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"prefix": "app",
"style": "camelCase",
"type": "attribute"
}
]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
}
.gitignore
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# dependencies
/node_modules
# IDEs and editors
.vscode/
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
testem.log
/typings
yarn-error.log
# e2e
/e2e/*.js
/e2e/*.map
# System Files
.DS_Store
Thumbs.db
.travis.yml
language: node_js
node_js:
- "12.3.1"
branches:
only:
- master
before_script:
- yarn install --frozen-lockfile
env:
- NG_CLI_ANALYTICS=ci
script:
- yarn build
deploy:
provider: pages
skip-cleanup: true
github-token: $GITHUB_TOKEN # Set in travis-ci.org dashboard, marked secure
keep-history: true
on:
branch: master
local_dir: dist
LICENSE.txt
MIT License
Copyright (c) [2022] [Khaled Osman]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
README.md
[](http://realworld.io)
[](https://app.netlify.com/sites/angular-realworld/deploys)
# 
> ### Angular codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API.
<a href="https://stackblitz.com/edit/angular-realworld" target="_blank"><img width="187" src="https://github.com/gothinkster/realworld/blob/master/media/edit_on_blitz.png?raw=true" /></a> <a href="https://thinkster.io/tutorials/building-real-world-angular-2-apps" target="_blank"><img width="384" src="https://raw.githubusercontent.com/gothinkster/realworld/master/media/learn-btn-hr.png" /></a>
### [Demo](https://angular-realworld.netlify.app/) [RealWorld](https://github.com/gothinkster/realworld)
This codebase was created to demonstrate a fully fledged application built with Angular that interacts with an actual backend server including CRUD operations, authentication, routing, pagination, and more. We've gone to great lengths to adhere to the [Angular Styleguide](https://angular.io/styleguide) & best practices.
Additionally, there is an Angular 1.5 version of this codebase that you can [fork](https://github.com/gothinkster/angularjs-realworld-example-app) and/or [learn how to recreate](https://thinkster.io/angularjs-es6-tutorial).
# How it works
We're currently working on some docs for the codebase (explaining where functionality is located, how it works, etc) but the codebase should be straightforward to follow as is. We've also released a [step-by-step tutorial w/ screencasts](https://thinkster.io/tutorials/building-real-world-angular-2-apps) that teaches you how to recreate the codebase from scratch.
### Making requests to the backend API
For convenience, we have a live API server running at https://api.realworld.io/api for the application to make requests against. You can view [the API spec here](https://github.com/GoThinkster/productionready/blob/master/api) which contains all routes & responses for the server.
The source code for the backend server (available for Node, Rails and Django) can be found in the [main RealWorld repo](https://github.com/gothinkster/realworld).
If you want to change the API URL to a local server, simply edit `src/environments/environment.ts` and change `api_url` to the local server's URL (i.e. `localhost:3000/api`)
# Getting started
Make sure you have the [Angular CLI](https://github.com/angular/angular-cli#installation) installed globally. We use [Yarn](https://yarnpkg.com) to manage the dependencies, so we strongly recommend you to use it. you can install it from [Here](https://yarnpkg.com/en/docs/install), then run `yarn install` to resolve all dependencies (might take a minute).
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
### Building the project
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build.
## Functionality overview
The example application is a social blogging site (i.e. a Medium.com clone) called "Conduit". It uses a custom API for all requests, including authentication. You can view a live demo over at https://angular.realworld.io
**General functionality:**
- Authenticate users via JWT (login/signup pages + logout button on settings page)
- CRU* users (sign up & settings page - no deleting required)
- CRUD Articles
- CR*D Comments on articles (no updating required)
- GET and display paginated lists of articles
- Favorite articles
- Follow other users
**The general page breakdown looks like this:**
- Home page (URL: /#/ )
- List of tags
- List of articles pulled from either Feed, Global, or by Tag
- Pagination for list of articles
- Sign in/Sign up pages (URL: /#/login, /#/register )
- Uses JWT (store the token in localStorage)
- Authentication can be easily switched to session/cookie based
- Settings page (URL: /#/settings )
- Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
- Article page (URL: /#/article/article-slug-here )
- Delete article button (only shown to article's author)
- Render markdown from server client side
- Comments section at bottom of page
- Delete comment button (only shown to comment's author)
- Profile page (URL: /#/profile/:username, /#/profile/:username/favorites )
- Show basic user info
- List of articles populated from author's created articles or author's favorited articles
<br />
[](https://thinkster.io)
angular.json
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ang2-conduit": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": [
"src/assets",
"src/favicon.ico",
"src/manifest.webmanifest"
],
"styles": [
"src/styles.css"
],
"scripts": [],
"namedChunks": true,
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"aot": true
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
}
},
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "ang2-conduit:build"
},
"configurations": {
"production": {
"browserTarget": "ang2-conduit:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ang2-conduit:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"karmaConfig": "./karma.conf.js",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"scripts": [],
"styles": [
"src/styles.css"
],
"assets": [
"src/assets",
"src/favicon.ico",
"src/manifest.webmanifest"
]
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
},
"ang2-conduit-e2e": {
"root": "e2e",
"sourceRoot": "e2e",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "./protractor.conf.js",
"devServerTarget": "ang2-conduit:serve"
}
}
}
}
},
"defaultProject": "ang2-conduit",
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
"style": "css"
},
"@schematics/angular:directive": {
"prefix": "app"
}
},
"cli": {
"analytics": false,
"defaultCollection": "@angular-eslint/schematics"
}
}
e2e
+---- app.e2e-spec.ts
import { Ng2RealApp } from './app.po';
describe('ng-demo App', () => {
let page: Ng2RealApp;
beforeEach(() => {
page = new Ng2RealApp();
});
it('should display message saying app works', () => {
page.navigateTo();
expect(page.getParagraphText()).toContain('conduit');
});
});
+---- app.po.ts
import { browser, element, by } from 'protractor';
export class Ng2RealApp {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('.logo-font')).getText();
}
}
+---- tsconfig.e2e.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}
karma.conf.js
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function(config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, 'coverage'), reports: ['html', 'lcovonly'],
fixWebpackSourcePaths: true
},
reporters:
config.angularCli && config.angularCli.codeCoverage
? ['progress', 'coverage-istanbul']
: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};
ngsw-config.json
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/*.css",
"/*.js",
"/manifest.webmanifest"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
]
}
}
]
}
package.json
{
"name": "ang2-conduit",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build --configuration production --base-href ./ && cp CNAME dist/CNAME",
"test": "ng test",
"lint": "ng lint --force",
"e2e": "ng e2e",
"postinstall": "ngcc --properties es2015 browser module main --first-only --create-ivy-entry-points"
},
"pre-commit": [
"lint"
],
"private": true,
"dependencies": {
"@angular/animations": "13.3.2",
"@angular/common": "13.3.2",
"@angular/compiler": "13.3.2",
"@angular/core": "13.3.2",
"@angular/forms": "13.3.2",
"@angular/platform-browser": "13.3.2",
"@angular/platform-browser-dynamic": "13.3.2",
"@angular/router": "13.3.2",
"@angular/service-worker": "13.3.2",
"core-js": "^3.21.1",
"marked": "^4.0.14",
"ngx-quicklink": "^0.2.7",
"rxjs": "^7.5.5",
"tslib": "^2.3.1",
"zone.js": "~0.11.5"
},
"devDependencies": {
"@angular-devkit/build-angular": "~13.3.2",
"@angular-eslint/builder": "13.2.0",
"@angular-eslint/eslint-plugin": "13.2.0",
"@angular-eslint/eslint-plugin-template": "13.2.0",
"@angular-eslint/schematics": "13.2.0",
"@angular-eslint/template-parser": "13.2.0",
"@angular/cli": "^13.3.2",
"@angular/compiler-cli": "13.3.2",
"@angular/language-service": "13.3.2",
"@types/jasmine": "~4.0.2",
"@types/jasminewd2": "~2.0.10",
"@types/node": "^17.0.23",
"@typescript-eslint/eslint-plugin": "5.17.0",
"@typescript-eslint/parser": "5.17.0",
"eslint": "^8.12.0",
"jasmine-core": "~4.1.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.3.18",
"karma-chrome-launcher": "~3.1.1",
"karma-cli": "~2.0.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~5.0.0",
"karma-jasmine-html-reporter": "^1.7.0",
"pre-commit": "^1.2.2",
"protractor": "~7.0.0",
"ts-node": "~10.7.0",
"typescript": "4.6.3"
}
}
protractor.conf.js
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./e2e/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: 'e2e/tsconfig.e2e.json'
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};
src
+---- app
| +---- app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { QuicklinkModule, QuicklinkStrategy } from 'ngx-quicklink';
const routes: Routes = [
{
path: 'settings',
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule)
},
{
path: 'profile',
loadChildren: () => import('./profile/profile.module').then(m => m.ProfileModule)
},
{
path: 'editor',
loadChildren: () => import('./editor/editor.module').then(m => m.EditorModule)
},
{
path: 'article',
loadChildren: () => import('./article/article.module').then(m => m.ArticleModule)
}
];
@NgModule({
imports: [
QuicklinkModule,
RouterModule.forRoot(routes, {
// preload all modules; optionally we could
// implement a custom preloading strategy for just some
// of the modules (PRs welcome 😉)
preloadingStrategy: QuicklinkStrategy,
relativeLinkResolution: 'legacy'
})],
exports: [RouterModule]
})
export class AppRoutingModule {}
| +---- app.component.html
<app-layout-header></app-layout-header>
<router-outlet></router-outlet>
<app-layout-footer></app-layout-footer>
| +---- app.component.ts
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { UserService } from "./core";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements OnInit {
constructor(private userService: UserService) {}
ngOnInit() {
this.userService.populate();
}
}
| +---- app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { AuthModule } from './auth/auth.module';
import { HomeModule } from './home/home.module';
import {
FooterComponent,
HeaderComponent,
SharedModule
} from './shared';
import { AppRoutingModule } from './app-routing.module';
import { CoreModule } from './core/core.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
@NgModule({
declarations: [AppComponent, FooterComponent, HeaderComponent],
imports: [
BrowserModule,
CoreModule,
SharedModule,
HomeModule,
AuthModule,
AppRoutingModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production })
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
| +---- article
| +---- article-comment.component.html
<div class="card">
<div class="card-block">
<p class="card-text">
{{ comment.body }}
</p>
</div>
<div class="card-footer">
<a class="comment-author" [routerLink]="['/profile', comment.author.username]">
<img [src]="comment.author.image || 'https://static.productionready.io/images/smiley-cyrus.jpg'" alt="author image" class="comment-author-img" />
</a>
<a class="comment-author" [routerLink]="['/profile', comment.author.username]">
{{ comment.author.username }}
</a>
<span class="date-posted">
{{ comment.createdAt | date: 'longDate' }}
</span>
<span class="mod-options" [hidden]="!canModify">
<i class="ion-trash-a" (click)="deleteClicked()"></i>
</span>
</div>
</div>
| +---- article-comment.component.ts
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { Comment, User, UserService } from '../core';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-article-comment',
templateUrl: './article-comment.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ArticleCommentComponent implements OnInit, OnDestroy {
constructor(
private userService: UserService,
private cd: ChangeDetectorRef
) {}
private subscription: Subscription;
@Input() comment: Comment;
@Output() deleteComment = new EventEmitter<boolean>();
canModify: boolean;
ngOnInit() {
// Load the current user's data
this.subscription = this.userService.currentUser.subscribe(
(userData: User) => {
this.canModify = (userData.username === this.comment.author.username);
this.cd.markForCheck();
}
);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
deleteClicked() {
this.deleteComment.emit(true);
}
}
| +---- article-resolver.service.ts
import { Injectable, } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Article, ArticlesService, UserService } from '../core';
import { catchError } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class ArticleResolver implements Resolve<Article> {
constructor(
private articlesService: ArticlesService,
private router: Router,
private userService: UserService
) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<any> {
return this.articlesService.get(route.params['slug'])
.pipe(catchError((err) => this.router.navigateByUrl('/')));
}
}
| +---- article-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ArticleComponent } from './article.component';
import { ArticleResolver } from './article-resolver.service';
const routes: Routes = [
{
path: ':slug',
component: ArticleComponent,
resolve: {
article: ArticleResolver
}
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ArticleRoutingModule {}
| +---- article.component.html
<div class="article-page">
<div class="banner">
<div class="container">
<h1>{{ article.title }}</h1>
<app-article-meta [article]="article">
<span [hidden]="!canModify">
<a class="btn btn-sm btn-outline-secondary"
[routerLink]="['/editor', article.slug]">
<i class="ion-edit"></i> Edit Article
</a>
<button class="btn btn-sm btn-outline-danger"
[ngClass]="{disabled: isDeleting}"
(click)="deleteArticle()">
<i class="ion-trash-a"></i> Delete Article
</button>
</span>
<span [hidden]="canModify">
<app-follow-button
[profile]="article.author"
(toggle)="onToggleFollowing($event)">
</app-follow-button>
<app-favorite-button
[article]="article"
(toggle)="onToggleFavorite($event)">
{{ article.favorited ? 'Unfavorite' : 'Favorite' }} Article <span class="counter">({{ article.favoritesCount }})</span>
</app-favorite-button>
</span>
</app-article-meta>
</div>
</div>
<div class="container page">
<div class="row article-content">
<div class="col-md-12">
<div [innerHTML]="article.body | markdown"></div>
<ul class="tag-list">
<li *ngFor="let tag of article.tagList; trackBy: trackByFn"
class="tag-default tag-pill tag-outline">
{{ tag }}
</li>
</ul>
</div>
</div>
<hr />
<div class="article-actions">
<app-article-meta [article]="article">
<span [hidden]="!canModify">
<a class="btn btn-sm btn-outline-secondary"
[routerLink]="['/editor', article.slug]">
<i class="ion-edit"></i> Edit Article
</a>
<button class="btn btn-sm btn-outline-danger"
[ngClass]="{disabled: isDeleting}"
(click)="deleteArticle()">
<i class="ion-trash-a"></i> Delete Article
</button>
</span>
<span [hidden]="canModify">
<app-follow-button
[profile]="article.author"
(toggle)="onToggleFollowing($event)">
</app-follow-button>
<app-favorite-button
[article]="article"
(toggle)="onToggleFavorite($event)">
{{ article.favorited ? 'Unfavorite' : 'Favorite' }} Article <span class="counter">({{ article.favoritesCount }})</span>
</app-favorite-button>
</span>
</app-article-meta>
</div>
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
<div *appShowAuthed="true">
<app-list-errors [errors]="commentFormErrors"></app-list-errors>
<form class="card comment-form" (ngSubmit)="addComment()">
<fieldset [disabled]="isSubmitting">
<div class="card-block">
<textarea class="form-control"
placeholder="Write a comment..."
rows="3"
[formControl]="commentControl"
></textarea>
</div>
<div class="card-footer">
<img [src]="currentUser.image || 'https://static.productionready.io/images/smiley-cyrus.jpg'" alt="user image" class="comment-author-img" />
<button class="btn btn-sm btn-primary" type="submit">
Post Comment
</button>
</div>
</fieldset>
</form>
</div>
<div *appShowAuthed="false">
<a [routerLink]="['/login']">Sign in</a> or <a [routerLink]="['/register']">sign up</a> to add comments on this article.
</div>
<app-article-comment
*ngFor="let comment of comments; trackBy: trackByFn"
[comment]="comment"
(deleteComment)="onDeleteComment(comment)">
</app-article-comment>
</div>
</div>
</div>
</div>
| +---- article.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormControl } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import {
Article,
ArticlesService,
Comment,
CommentsService,
User,
UserService
} from '../core';
@Component({
selector: 'app-article-page',
templateUrl: './article.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ArticleComponent implements OnInit {
article: Article;
currentUser: User;
canModify: boolean;
comments: Comment[];
commentControl = new FormControl();
commentFormErrors = {};
isSubmitting = false;
isDeleting = false;
constructor(
private route: ActivatedRoute,
private articlesService: ArticlesService,
private commentsService: CommentsService,
private router: Router,
private userService: UserService,
private cd: ChangeDetectorRef
) { }
ngOnInit() {
// Retreive the prefetched article
this.route.data.subscribe(
(data: { article: Article }) => {
this.article = data.article;
// Load the comments on this article
this.populateComments();
this.cd.markForCheck();
}
);
// Load the current user's data
this.userService.currentUser.subscribe(
(userData: User) => {
this.currentUser = userData;
this.canModify = (this.currentUser.username === this.article.author.username);
this.cd.markForCheck();
}
);
}
onToggleFavorite(favorited: boolean) {
this.article.favorited = favorited;
if (favorited) {
this.article.favoritesCount++;
} else {
this.article.favoritesCount--;
}
}
trackByFn(index, item) {
return index;
}
onToggleFollowing(following: boolean) {
this.article.author.following = following;
}
deleteArticle() {
this.isDeleting = true;
this.articlesService.destroy(this.article.slug)
.subscribe(
success => {
this.router.navigateByUrl('/');
}
);
}
populateComments() {
this.commentsService.getAll(this.article.slug)
.subscribe(comments => {
this.comments = comments;
this.cd.markForCheck();
});
}
addComment() {
this.isSubmitting = true;
this.commentFormErrors = {};
const commentBody = this.commentControl.value;
this.commentsService
.add(this.article.slug, commentBody)
.subscribe(
comment => {
this.comments.unshift(comment);
this.commentControl.reset('');
this.isSubmitting = false;
this.cd.markForCheck();
},
errors => {
this.isSubmitting = false;
this.commentFormErrors = errors;
this.cd.markForCheck();
}
);
}
onDeleteComment(comment) {
this.commentsService.destroy(comment.id, this.article.slug)
.subscribe(
success => {
this.comments = this.comments.filter((item) => item !== comment);
this.cd.markForCheck();
}
);
}
}
| +---- article.module.ts
import { NgModule } from '@angular/core';
import { ArticleComponent } from './article.component';
import { ArticleCommentComponent } from './article-comment.component';
import { MarkdownPipe } from './markdown.pipe';
import { SharedModule } from '../shared';
import { ArticleRoutingModule } from './article-routing.module';
@NgModule({
imports: [
SharedModule,
ArticleRoutingModule
],
declarations: [
ArticleComponent,
ArticleCommentComponent,
MarkdownPipe
],
providers: [
]
})
export class ArticleModule {}
| +---- markdown.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import * as marked from 'marked';
@Pipe({name: 'markdown'})
export class MarkdownPipe implements PipeTransform {
transform(content: string): string {
return marked(content, { sanitize: true });
}
}
| +---- auth
| +---- auth-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthComponent } from './auth.component';
import { NoAuthGuard } from './no-auth-guard.service';
const routes: Routes = [
{
path: 'login',
component: AuthComponent,
canActivate: [NoAuthGuard]
},
{
path: 'register',
component: AuthComponent,
canActivate: [NoAuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AuthRoutingModule {}
| +---- auth.component.html
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">{{ title }}</h1>
<p class="text-xs-center">
<a [routerLink]="['/login']" *ngIf="authType === 'register'">Have an account?</a>
<a [routerLink]="['/register']" *ngIf="authType === 'login'">Need an account?</a>
</p>
<app-list-errors [errors]="errors"></app-list-errors>
<form [formGroup]="authForm" (ngSubmit)="submitForm()">
<fieldset [disabled]="isSubmitting">
<fieldset class="form-group">
<input
formControlName="username"
placeholder="Username"
class="form-control form-control-lg"
type="text"
*ngIf="authType === 'register'" />
</fieldset>
<fieldset class="form-group">
<input
formControlName="email"
placeholder="Email"
class="form-control form-control-lg"
type="text" />
</fieldset>
<fieldset class="form-group">
<input
formControlName="password"
placeholder="Password"
class="form-control form-control-lg"
type="password" />
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right" [disabled]="!authForm.valid" type="submit">
{{ title }}
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
| +---- auth.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormBuilder, FormGroup, FormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Errors, UserService } from '../core';
@Component({
selector: 'app-auth-page',
templateUrl: './auth.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AuthComponent implements OnInit {
authType: String = '';
title: String = '';
errors: Errors = {errors: {}};
isSubmitting = false;
authForm: FormGroup;
constructor(
private route: ActivatedRoute,
private router: Router,
private userService: UserService,
private fb: FormBuilder,
private cd: ChangeDetectorRef
) {
// use FormBuilder to create a form group
this.authForm = this.fb.group({
'email': ['', Validators.required],
'password': ['', Validators.required]
});
}
ngOnInit() {
this.route.url.subscribe(data => {
// Get the last piece of the URL (it's either 'login' or 'register')
this.authType = data[data.length - 1].path;
// Set a title for the page accordingly
this.title = (this.authType === 'login') ? 'Sign in' : 'Sign up';
// add form control for username if this is the register page
if (this.authType === 'register') {
this.authForm.addControl('username', new FormControl());
}
this.cd.markForCheck();
});
}
submitForm() {
this.isSubmitting = true;
this.errors = {errors: {}};
const credentials = this.authForm.value;
this.userService
.attemptAuth(this.authType, credentials)
.subscribe(
data => this.router.navigateByUrl('/'),
err => {
this.errors = err;
this.isSubmitting = false;
this.cd.markForCheck();
}
);
}
}
| +---- auth.module.ts
import { NgModule } from '@angular/core';
import { AuthComponent } from './auth.component';
import { NoAuthGuard } from './no-auth-guard.service';
import { SharedModule } from '../shared';
import { AuthRoutingModule } from './auth-routing.module';
@NgModule({
imports: [
SharedModule,
AuthRoutingModule
],
declarations: [
AuthComponent
],
providers: [
NoAuthGuard
]
})
export class AuthModule {}
| +---- no-auth-guard.service.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { UserService } from '../core';
import { map , take } from 'rxjs/operators';
@Injectable()
export class NoAuthGuard implements CanActivate {
constructor(
private router: Router,
private userService: UserService
) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
return this.userService.isAuthenticated.pipe(take(1), map(isAuth => !isAuth));
}
}
| +---- core
| +---- core.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpTokenInterceptor } from './interceptors/http.token.interceptor';
@NgModule({
imports: [
CommonModule
],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: HttpTokenInterceptor, multi: true }
],
declarations: []
})
export class CoreModule { }
| +---- index.ts
export * from './core.module';
export * from './services';
export * from './models';
export * from './interceptors';
| +---- interceptors
| +---- http.token.interceptor.ts
import { Injectable, Injector } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { JwtService } from '../services';
@Injectable()
export class HttpTokenInterceptor implements HttpInterceptor {
constructor(private jwtService: JwtService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const headersConfig = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
const token = this.jwtService.getToken();
if (token) {
headersConfig['Authorization'] = `Token ${token}`;
}
const request = req.clone({ setHeaders: headersConfig });
return next.handle(request);
}
}
| +---- index.ts
export * from './http.token.interceptor';
| +---- models
| +---- article-list-config.model.ts
export interface ArticleListConfig {
type: string;
filters: {
tag?: string,
author?: string,
favorited?: string,
limit?: number,
offset?: number
};
}
| +---- article.model.ts
import { Profile } from './profile.model';
export interface Article {
slug: string;
title: string;
description: string;
body: string;
tagList: string[];
createdAt: string;
updatedAt: string;
favorited: boolean;
favoritesCount: number;
author: Profile;
}
| +---- comment.model.ts
import { Profile } from './profile.model';
export interface Comment {
id: number;
body: string;
createdAt: string;
author: Profile;
}
| +---- errors.model.ts
export interface Errors {
errors: {[key: string]: string};
}
| +---- index.ts
export * from './article.model';
export * from './article-list-config.model';
export * from './comment.model';
export * from './errors.model';
export * from './profile.model';
export * from './user.model';
| +---- profile.model.ts
export interface Profile {
username: string;
bio: string;
image: string;
following: boolean;
}
| +---- user.model.ts
export interface User {
email: string;
token: string;
username: string;
bio: string;
image: string;
}
| +---- services
| +---- api.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
import { HttpHeaders, HttpClient, HttpParams } from '@angular/common/http';
import { Observable , throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(
private http: HttpClient
) {}
private formatErrors(error: any) {
return throwError(error.error);
}
get(path: string, params: HttpParams = new HttpParams()): Observable<any> {
return this.http.get(`${environment.api_url}${path}`, { params })
.pipe(catchError(this.formatErrors));
}
put(path: string, body: Object = {}): Observable<any> {
return this.http.put(
`${environment.api_url}${path}`,
JSON.stringify(body)
).pipe(catchError(this.formatErrors));
}
post(path: string, body: Object = {}): Observable<any> {
return this.http.post(
`${environment.api_url}${path}`,
JSON.stringify(body)
).pipe(catchError(this.formatErrors));
}
delete(path): Observable<any> {
return this.http.delete(
`${environment.api_url}${path}`
).pipe(catchError(this.formatErrors));
}
}
| +---- articles.service.ts
import { Injectable } from '@angular/core';
import { HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Article, ArticleListConfig } from '../models';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class ArticlesService {
constructor (
private apiService: ApiService
) {}
query(config: ArticleListConfig): Observable<{articles: Article[], articlesCount: number}> {
// Convert any filters over to Angular's URLSearchParams
const params = {};
Object.keys(config.filters)
.forEach((key) => {
params[key] = config.filters[key];
});
return this.apiService
.get(
'/articles' + ((config.type === 'feed') ? '/feed' : ''),
new HttpParams({ fromObject: params })
);
}
get(slug): Observable<Article> {
return this.apiService.get('/articles/' + slug)
.pipe(map(data => data.article));
}
destroy(slug) {
return this.apiService.delete('/articles/' + slug);
}
save(article): Observable<Article> {
// If we're updating an existing article
if (article.slug) {
return this.apiService.put('/articles/' + article.slug, {article: article})
.pipe(map(data => data.article));
// Otherwise, create a new article
} else {
return this.apiService.post('/articles/', {article: article})
.pipe(map(data => data.article));
}
}
favorite(slug): Observable<Article> {
return this.apiService.post('/articles/' + slug + '/favorite');
}
unfavorite(slug): Observable<Article> {
return this.apiService.delete('/articles/' + slug + '/favorite');
}
}
| +---- auth-guard.service.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { UserService } from './user.service';
import { take } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private userService: UserService
) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
return this.userService.isAuthenticated.pipe(take(1));
}
}
| +---- comments.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Comment } from '../models';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class CommentsService {
constructor (
private apiService: ApiService
) {}
add(slug, payload): Observable<Comment> {
return this.apiService
.post(
`/articles/${slug}/comments`,
{ comment: { body: payload } }
).pipe(map(data => data.comment));
}
getAll(slug): Observable<Comment[]> {
return this.apiService.get(`/articles/${slug}/comments`)
.pipe(map(data => data.comments));
}
destroy(commentId, articleSlug) {
return this.apiService
.delete(`/articles/${articleSlug}/comments/${commentId}`);
}
}
| +---- index.ts
export * from './api.service';
export * from './articles.service';
export * from './auth-guard.service';
export * from './comments.service';
export * from './jwt.service';
export * from './profiles.service';
export * from './tags.service';
export * from './user.service';
| +---- jwt.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class JwtService {
getToken(): String {
return window.localStorage['jwtToken'];
}
saveToken(token: String) {
window.localStorage['jwtToken'] = token;
}
destroyToken() {
window.localStorage.removeItem('jwtToken');
}
}
| +---- profiles.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Profile } from '../models';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class ProfilesService {
constructor (
private apiService: ApiService
) {}
get(username: string): Observable<Profile> {
return this.apiService.get('/profiles/' + username)
.pipe(map((data: {profile: Profile}) => data.profile));
}
follow(username: string): Observable<Profile> {
return this.apiService.post('/profiles/' + username + '/follow');
}
unfollow(username: string): Observable<Profile> {
return this.apiService.delete('/profiles/' + username + '/follow');
}
}
| +---- tags.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class TagsService {
constructor (
private apiService: ApiService
) {}
getAll(): Observable<[string]> {
return this.apiService.get('/tags')
.pipe(map(data => data.tags));
}
}
| +---- user.service.ts
import { Injectable } from '@angular/core';
import { Observable , BehaviorSubject , ReplaySubject } from 'rxjs';
import { ApiService } from './api.service';
import { JwtService } from './jwt.service';
import { User } from '../models';
import { map , distinctUntilChanged } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class UserService {
private currentUserSubject = new BehaviorSubject<User>({} as User);
public currentUser = this.currentUserSubject.asObservable().pipe(distinctUntilChanged());
private isAuthenticatedSubject = new ReplaySubject<boolean>(1);
public isAuthenticated = this.isAuthenticatedSubject.asObservable();
constructor (
private apiService: ApiService,
private jwtService: JwtService
) {}
// Verify JWT in localstorage with server & load user's info.
// This runs once on application startup.
populate() {
// If JWT detected, attempt to get & store user's info
if (this.jwtService.getToken()) {
this.apiService.get('/user')
.subscribe(
data => this.setAuth(data.user),
err => this.purgeAuth()
);
} else {
// Remove any potential remnants of previous auth states
this.purgeAuth();
}
}
setAuth(user: User) {
// Save JWT sent from server in localstorage
this.jwtService.saveToken(user.token);
// Set current user data into observable
this.currentUserSubject.next(user);
// Set isAuthenticated to true
this.isAuthenticatedSubject.next(true);
}
purgeAuth() {
// Remove JWT from localstorage
this.jwtService.destroyToken();
// Set current user to an empty object
this.currentUserSubject.next({} as User);
// Set auth status to false
this.isAuthenticatedSubject.next(false);
}
attemptAuth(type, credentials): Observable<User> {
const route = (type === 'login') ? '/login' : '';
return this.apiService.post(`/users${route}`, {user: credentials})
.pipe(map(
data => {
this.setAuth(data.user);
return data;
}
));
}
getCurrentUser(): User {
return this.currentUserSubject.value;
}
// Update the user on the server (email, pass, etc)
update(user): Observable<User> {
return this.apiService
.put('/user', { user })
.pipe(map(data => {
// Update the currentUser observable
this.currentUserSubject.next(data.user);
return data.user;
}));
}
}
| +---- editor
| +---- editable-article-resolver.service.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Article, ArticlesService, UserService } from '../core';
import { catchError , map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class EditableArticleResolver implements Resolve<Article> {
constructor(
private articlesService: ArticlesService,
private router: Router,
private userService: UserService
) { }
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<any> {
return this.articlesService.get(route.params['slug'])
.pipe(
map(
article => {
if (this.userService.getCurrentUser().username === article.author.username) {
return article;
} else {
this.router.navigateByUrl('/');
}
}
),
catchError((err) => this.router.navigateByUrl('/'))
);
}
}
| +---- editor-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { EditorComponent } from './editor.component';
import { EditableArticleResolver } from './editable-article-resolver.service';
import { AuthGuard } from '../core';
import { SharedModule } from '../shared';
const routes: Routes = [
{
path: '',
component: EditorComponent,
canActivate: [AuthGuard]
},
{
path: ':slug',
component: EditorComponent,
canActivate: [AuthGuard],
resolve: {
article: EditableArticleResolver
}
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class EditorRoutingModule {}
| +---- editor.component.html
<div class="editor-page">
<div class="container page">
<div class="row">
<div class="col-md-10 offset-md-1 col-xs-12">
<app-list-errors [errors]="errors"></app-list-errors>
<form [formGroup]="articleForm">
<fieldset [disabled]="isSubmitting">
<fieldset class="form-group">
<input class="form-control form-control-lg"
formControlName="title"
type="text"
placeholder="Article Title" />
</fieldset>
<fieldset class="form-group">
<input class="form-control"
formControlName="description"
type="text"
placeholder="What's this article about?" />
</fieldset>
<fieldset class="form-group">
<textarea class="form-control"
formControlName="body"
rows="8"
placeholder="Write your article (in markdown)">
</textarea>
</fieldset>
<fieldset class="form-group">
<input class="form-control"
type="text"
placeholder="Enter tags"
[formControl]="tagField"
(keyup.enter)="addTag()" />
<div class="tag-list">
<span *ngFor="let tag of article.tagList; trackBy: trackByFn"
class="tag-default tag-pill">
<i class="ion-close-round" (click)="removeTag(tag)"></i>
{{ tag }}
</span>
</div>
</fieldset>
<button class="btn btn-lg pull-xs-right btn-primary" type="button" (click)="submitForm()">
Publish Article
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
| +---- editor.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormBuilder, FormGroup, FormControl } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Article, ArticlesService } from '../core';
@Component({
selector: 'app-editor-page',
templateUrl: './editor.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditorComponent implements OnInit {
article: Article = {} as Article;
articleForm: FormGroup;
tagField = new FormControl();
errors: Object = {};
isSubmitting = false;
constructor(
private articlesService: ArticlesService,
private route: ActivatedRoute,
private router: Router,
private fb: FormBuilder,
private cd: ChangeDetectorRef
) {
// use the FormBuilder to create a form group
this.articleForm = this.fb.group({
title: '',
description: '',
body: ''
});
// Initialized tagList as empty array
this.article.tagList = [];
// Optional: subscribe to value changes on the form
// this.articleForm.valueChanges.subscribe(value => this.updateArticle(value));
}
ngOnInit() {
// If there's an article prefetched, load it
this.route.data.subscribe((data: { article: Article }) => {
if (data.article) {
this.article = data.article;
this.articleForm.patchValue(data.article);
this.cd.markForCheck();
}
});
}
trackByFn(index, item) {
return index;
}
addTag() {
// retrieve tag control
const tag = this.tagField.value;
// only add tag if it does not exist yet
if (this.article.tagList.indexOf(tag) < 0) {
this.article.tagList.push(tag);
}
// clear the input
this.tagField.reset('');
}
removeTag(tagName: string) {
this.article.tagList = this.article.tagList.filter(tag => tag !== tagName);
}
submitForm() {
this.isSubmitting = true;
// update the model
this.updateArticle(this.articleForm.value);
// post the changes
this.articlesService.save(this.article).subscribe(
article => {
this.router.navigateByUrl('/article/' + article.slug);
this.cd.markForCheck();
},
err => {
this.errors = err;
this.isSubmitting = false;
this.cd.markForCheck();
}
);
}
updateArticle(values: Object) {
Object.assign(this.article, values);
}
}
| +---- editor.module.ts
import { NgModule } from '@angular/core';
import { EditorComponent } from './editor.component';
import { SharedModule } from '../shared';
import { EditorRoutingModule } from './editor-routing.module';
@NgModule({
imports: [SharedModule, EditorRoutingModule],
declarations: [EditorComponent],
providers: []
})
export class EditorModule {}
| +---- home
| +---- home-auth-resolver.service.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { UserService } from '../core';
import { take } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class HomeAuthResolver implements Resolve<boolean> {
constructor(
private router: Router,
private userService: UserService
) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
return this.userService.isAuthenticated.pipe(take(1));
}
}
| +---- home-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home.component';
import { HomeAuthResolver } from './home-auth-resolver.service';
const routes: Routes = [
{
path: '',
component: HomeComponent,
resolve: {
isAuthenticated: HomeAuthResolver
}
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HomeRoutingModule {}
| +---- home.component.css
.nav-link {
cursor:pointer;
}
.tag-pill{
cursor:pointer;
}
| +---- home.component.html
<div class="home-page">
<div class="banner" *appShowAuthed="false">
<div class="container">
<h1 class="logo-font">conduit</h1>
<p>A place to share your <i>Angular</i> knowledge.</p>
</div>
</div>
<div class="container page">
<div class="row">
<div class="col-md-9">
<div class="feed-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a class="nav-link"
[ngClass]="{'active': listConfig.type === 'feed'}"
(click)="setListTo('feed')">
Your Feed
</a>
</li>
<li class="nav-item">
<a class="nav-link"
[ngClass]="{'active': listConfig.type === 'all' && !listConfig.filters.tag}"
(click)="setListTo('all')">
Global Feed
</a>
</li>
<li class="nav-item" [hidden]="!listConfig.filters.tag">
<a class="nav-link active">
<i class="ion-pound"></i> {{ listConfig.filters.tag }}
</a>
</li>
</ul>
</div>
<app-article-list [limit]="10" [config]="listConfig"></app-article-list>
</div>
<div class="col-md-3">
<div class="sidebar">
<p>Popular Tags</p>
<div class="tag-list">
<a *ngFor="let tag of tags; trackBy: trackByFn"
(click)="setListTo('all', {tag: tag})"
class="tag-default tag-pill">
{{ tag }}
</a>
</div>
<div [hidden]="tagsLoaded">
Loading tags...
</div>
<div [hidden]="!tagsLoaded || tags.length > 0">
No tags are here... yet.
</div>
</div>
</div>
</div>
</div>
</div>
| +---- home.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { Router } from '@angular/router';
import { ArticleListConfig, TagsService, UserService } from '../core';
@Component({
selector: 'app-home-page',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomeComponent implements OnInit {
constructor(
private router: Router,
private tagsService: TagsService,
private userService: UserService,
private cd: ChangeDetectorRef
) {}
isAuthenticated: boolean;
listConfig: ArticleListConfig = {
type: 'all',
filters: {}
};
tags: Array<string> = [];
tagsLoaded = false;
ngOnInit() {
this.userService.isAuthenticated.subscribe(
(authenticated) => {
this.isAuthenticated = authenticated;
// set the article list accordingly
if (authenticated) {
this.setListTo('feed');
} else {
this.setListTo('all');
}
this.cd.markForCheck();
}
);
this.tagsService.getAll()
.subscribe(tags => {
this.tags = tags;
this.tagsLoaded = true;
this.cd.markForCheck();
});
}
trackByFn(index, item) {
return index;
}
setListTo(type: string = '', filters: Object = {}) {
// If feed is requested but user is not authenticated, redirect to login
if (type === 'feed' && !this.isAuthenticated) {
this.router.navigateByUrl('/login');
return;
}
// Otherwise, set the list object
this.listConfig = {type: type, filters: filters};
}
}
| +---- home.module.ts
import { NgModule } from '@angular/core';
import { HomeComponent } from './home.component';
import { SharedModule } from '../shared';
import { HomeRoutingModule } from './home-routing.module';
@NgModule({
imports: [
SharedModule,
HomeRoutingModule
],
declarations: [
HomeComponent
],
providers: [
]
})
export class HomeModule {}
| +---- index.ts
export * from './app.component';
export * from './app.module';
| +---- profile
| +---- profile-articles.component.html
<app-article-list [limit]="10" [config]="articlesConfig">
</app-article-list>
| +---- profile-articles.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ArticleListConfig, Profile } from '../core';
@Component({
selector: 'app-profile-articles',
templateUrl: './profile-articles.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProfileArticlesComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private router: Router,
private cd: ChangeDetectorRef
) {}
profile: Profile;
articlesConfig: ArticleListConfig = {
type: 'all',
filters: {}
};
ngOnInit() {
this.route.parent.data.subscribe(
(data: {profile: Profile}) => {
this.profile = data.profile;
this.articlesConfig = {
type: 'all',
filters: {}
}; // Only method I found to refresh article load on swap
this.articlesConfig.filters.author = this.profile.username;
this.cd.markForCheck();
}
);
}
}
| +---- profile-favorites.component.html
<app-article-list [limit]="10" [config]="favoritesConfig">
</app-article-list>
| +---- profile-favorites.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ArticleListConfig, Profile } from '../core';
@Component({
selector: 'app-profile-favorites',
templateUrl: './profile-favorites.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProfileFavoritesComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private cd: ChangeDetectorRef
) {}
profile: Profile;
favoritesConfig: ArticleListConfig = {
type: 'all',
filters: {}
};
ngOnInit() {
this.route.parent.data.subscribe(
(data: {profile: Profile}) => {
this.profile = data.profile;
this.favoritesConfig = {...this.favoritesConfig};
this.favoritesConfig.filters.favorited = this.profile.username;
this.cd.markForCheck();
}
);
}
}
| +---- profile-resolver.service.ts
import { Injectable, } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Profile, ProfilesService } from '../core';
import { catchError } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class ProfileResolver implements Resolve<Profile> {
constructor(
private profilesService: ProfilesService,
private router: Router
) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<any> {
return this.profilesService.get(route.params['username'])
.pipe(catchError((err) => this.router.navigateByUrl('/')));
}
}
| +---- profile-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProfileArticlesComponent } from './profile-articles.component';
import { ProfileFavoritesComponent } from './profile-favorites.component';
import { ProfileResolver } from './profile-resolver.service';
import { ProfileComponent } from './profile.component';
const routes: Routes = [
{
path: ':username',
component: ProfileComponent,
resolve: {
profile: ProfileResolver
},
children: [
{
path: '',
component: ProfileArticlesComponent
},
{
path: 'favorites',
component: ProfileFavoritesComponent
}
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ProfileRoutingModule {}
| +---- profile.component.html
<div class="profile-page">
<div class="user-info">
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<img [src]="profile.image || 'https://static.productionready.io/images/smiley-cyrus.jpg'" alt="user image" class="user-img" />
<h4>{{ profile.username }}</h4>
<p>{{ profile.bio }}</p>
<app-follow-button
[hidden]="isUser"
[profile]="profile"
(toggle)="onToggleFollowing($event)">
</app-follow-button>
<a [routerLink]="['/settings']"
[hidden]="!isUser"
class="btn btn-sm btn-outline-secondary action-btn">
<i class="ion-gear-a"></i> Edit Profile Settings
</a>
</div>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<div class="articles-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a class="nav-link"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="['/profile', profile.username]">
My Posts
</a>
</li>
<li class="nav-item">
<a class="nav-link"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="['/profile', profile.username, 'favorites']">
Favorited Posts
</a>
</li>
</ul>
</div>
<router-outlet></router-outlet>
</div>
</div>
</div>
</div>
| +---- profile.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { User, UserService, Profile } from '../core';
import { concatMap , tap } from 'rxjs/operators';
@Component({
selector: 'app-profile-page',
templateUrl: './profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProfileComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private userService: UserService,
private cd: ChangeDetectorRef
) { }
profile: Profile;
currentUser: User;
isUser: boolean;
ngOnInit() {
this.route.data.pipe(
concatMap((data: { profile: Profile }) => {
this.profile = data.profile;
// Load the current user's data.
return this.userService.currentUser.pipe(tap(
(userData: User) => {
this.currentUser = userData;
this.isUser = (this.currentUser.username === this.profile.username);
}
));
})
).subscribe((() => {
this.cd.markForCheck();
}));
}
onToggleFollowing(following: boolean) {
this.profile.following = following;
}
}
| +---- profile.module.ts
import { NgModule } from '@angular/core';
import { ProfileArticlesComponent } from './profile-articles.component';
import { ProfileComponent } from './profile.component';
import { ProfileFavoritesComponent } from './profile-favorites.component';
import { SharedModule } from '../shared';
import { ProfileRoutingModule } from './profile-routing.module';
@NgModule({
imports: [
SharedModule,
ProfileRoutingModule
],
declarations: [
ProfileArticlesComponent,
ProfileComponent,
ProfileFavoritesComponent
],
providers: [
]
})
export class ProfileModule {}
| +---- settings
| +---- settings-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '../core';
import { SettingsComponent } from './settings.component';
const routes: Routes = [
{
path: '',
component: SettingsComponent,
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SettingsRoutingModule {}
| +---- settings.component.html
<div class="settings-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Your Settings</h1>
<app-list-errors [errors]="errors"></app-list-errors>
<form [formGroup]="settingsForm" (ngSubmit)="submitForm()">
<fieldset [disabled]="isSubmitting">
<fieldset class="form-group">
<input class="form-control"
type="text"
placeholder="URL of profile picture"
formControlName="image" />
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="text"
placeholder="Username"
formControlName="username" />
</fieldset>
<fieldset class="form-group">
<textarea class="form-control form-control-lg"
rows="8"
placeholder="Short bio about you"
formControlName="bio">
</textarea>
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="email"
placeholder="Email"
formControlName="email" />
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="password"
placeholder="New Password"
formControlName="password" />
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right"
type="submit">
Update Settings
</button>
</fieldset>
</form>
<!-- Line break for logout button -->
<hr />
<button class="btn btn-outline-danger"
(click)="logout()">
Or click here to logout.
</button>
</div>
</div>
</div>
</div>
| +---- settings.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { User, UserService } from '../core';
@Component({
selector: 'app-settings-page',
templateUrl: './settings.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SettingsComponent implements OnInit {
user: User = {} as User;
settingsForm: FormGroup;
errors: Object = {};
isSubmitting = false;
constructor(
private router: Router,
private userService: UserService,
private fb: FormBuilder,
private cd: ChangeDetectorRef
) {
// create form group using the form builder
this.settingsForm = this.fb.group({
image: '',
username: '',
bio: '',
email: '',
password: ''
});
// Optional: subscribe to changes on the form
// this.settingsForm.valueChanges.subscribe(values => this.updateUser(values));
}
ngOnInit() {
// Make a fresh copy of the current user's object to place in editable form fields
Object.assign(this.user, this.userService.getCurrentUser());
// Fill the form
this.settingsForm.patchValue(this.user);
}
logout() {
this.userService.purgeAuth();
this.router.navigateByUrl('/');
}
submitForm() {
this.isSubmitting = true;
// update the model
this.updateUser(this.settingsForm.value);
this.userService
.update(this.user)
.subscribe(
updatedUser => this.router.navigateByUrl('/profile/' + updatedUser.username),
err => {
this.errors = err;
this.isSubmitting = false;
this.cd.markForCheck();
}
);
}
updateUser(values: Object) {
Object.assign(this.user, values);
}
}
| +---- settings.module.ts
import { NgModule } from '@angular/core';
import { SettingsComponent } from './settings.component';
import { SharedModule } from '../shared';
import { SettingsRoutingModule } from './settings-routing.module';
@NgModule({
imports: [
SharedModule,
SettingsRoutingModule
],
declarations: [
SettingsComponent
]
})
export class SettingsModule {}
| +---- shared
| +---- article-helpers
| +---- article-list.component.css
.page-link {
cursor: pointer;
}
| +---- article-list.component.html
<app-article-preview
*ngFor="let article of results; trackBy: trackByFn"
[article]="article">
</app-article-preview>
<div class="app-article-preview"
[hidden]="!loading">
Loading articles...
</div>
<div class="app-article-preview"
[hidden]="loading || results.length">
No articles are here... yet.
</div>
<nav [hidden]="loading || totalPages.length <= 1">
<ul class="pagination">
<li class="page-item"
[ngClass]="{'active': pageNumber === currentPage}"
*ngFor="let pageNumber of totalPages; trackBy: trackByFn"
(click)="setPageTo(pageNumber)">
<a class="page-link" >{{ pageNumber }}</a>
</li>
</ul>
</nav>
| +---- article-list.component.ts
import { Component, Input, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { Article, ArticleListConfig, ArticlesService } from '../../core';
@Component({
selector: 'app-article-list',
styleUrls: ['article-list.component.css'],
templateUrl: './article-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ArticleListComponent {
constructor (
private articlesService: ArticlesService,
private cd: ChangeDetectorRef
) {}
@Input() limit: number;
@Input()
set config(config: ArticleListConfig) {
if (config) {
this.query = config;
this.currentPage = 1;
this.runQuery();
}
}
query: ArticleListConfig;
results: Article[];
loading = false;
currentPage = 1;
totalPages: Array<number> = [1];
setPageTo(pageNumber) {
this.currentPage = pageNumber;
this.runQuery();
}
trackByFn(index, item) {
return index;
}
runQuery() {
this.loading = true;
this.results = [];
// Create limit and offset filter (if necessary)
if (this.limit) {
this.query.filters.limit = this.limit;
this.query.filters.offset = (this.limit * (this.currentPage - 1));
}
this.articlesService.query(this.query)
.subscribe(data => {
this.loading = false;
this.results = data.articles;
// Used from http://www.jstips.co/en/create-range-0...n-easily-using-one-line/
this.totalPages = Array.from(new Array(Math.ceil(data.articlesCount / this.limit)), (val, index) => index + 1);
this.cd.markForCheck();
});
}
}
| +---- article-meta.component.html
<div class="article-meta">
<a [routerLink]="['/profile', article.author.username]">
<img [src]="article.author.image || 'https://static.productionready.io/images/smiley-cyrus.jpg'" alt="author image" />
</a>
<div class="info">
<a class="author"
[routerLink]="['/profile', article.author.username]">
{{ article.author.username }}
</a>
<span class="date">
{{ article.createdAt | date: 'longDate' }}
</span>
</div>
<ng-content></ng-content>
</div>
| +---- article-meta.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Article } from '../../core';
@Component({
selector: 'app-article-meta',
templateUrl: './article-meta.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ArticleMetaComponent {
@Input() article: Article;
}
| +---- article-preview.component.html
<div class="article-preview">
<app-article-meta [article]="article">
<app-favorite-button
[article]="article"
(toggle)="onToggleFavorite($event)"
class="pull-xs-right">
{{article.favoritesCount}}
</app-favorite-button>
</app-article-meta>
<a [routerLink]="['/article', article.slug]" class="preview-link">
<h1>{{ article.title }}</h1>
<p>{{ article.description }}</p>
<span>Read more...</span>
<ul class="tag-list">
<li class="tag-default tag-pill tag-outline"
*ngFor="let tag of article.tagList; trackBy: trackByFn">
{{ tag }}
</li>
</ul>
</a>
</div>
| +---- article-preview.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Article } from '../../core';
@Component({
selector: 'app-article-preview',
templateUrl: './article-preview.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ArticlePreviewComponent {
@Input() article: Article;
trackByFn(index, item) {
return index;
}
onToggleFavorite(favorited: boolean) {
this.article['favorited'] = favorited;
if (favorited) {
this.article['favoritesCount']++;
} else {
this.article['favoritesCount']--;
}
}
}
| +---- index.ts
export * from './article-list.component';
export * from './article-meta.component';
export * from './article-preview.component';
| +---- buttons
| +---- favorite-button.component.html
<button class="btn btn-sm"
[ngClass]="{ 'disabled' : isSubmitting,
'btn-outline-primary': !article.favorited,
'btn-primary': article.favorited }"
(click)="toggleFavorite()">
<i class="ion-heart"></i> <ng-content></ng-content>
</button>
| +---- favorite-button.component.ts
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { Router } from '@angular/router';
import { Article, ArticlesService, UserService } from '../../core';
import { of } from 'rxjs';
import { concatMap , tap } from 'rxjs/operators';
@Component({
selector: 'app-favorite-button',
templateUrl: './favorite-button.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FavoriteButtonComponent {
constructor(
private articlesService: ArticlesService,
private router: Router,
private userService: UserService,
private cd: ChangeDetectorRef
) {}
@Input() article: Article;
@Output() toggle = new EventEmitter<boolean>();
isSubmitting = false;
toggleFavorite() {
this.isSubmitting = true;
this.userService.isAuthenticated.pipe(concatMap(
(authenticated) => {
// Not authenticated? Push to login screen
if (!authenticated) {
this.router.navigateByUrl('/login');
return of(null);
}
// Favorite the article if it isn't favorited yet
if (!this.article.favorited) {
return this.articlesService.favorite(this.article.slug)
.pipe(tap(
data => {
this.isSubmitting = false;
this.toggle.emit(true);
},
err => this.isSubmitting = false
));
// Otherwise, unfavorite the article
} else {
return this.articlesService.unfavorite(this.article.slug)
.pipe(tap(
data => {
this.isSubmitting = false;
this.toggle.emit(false);
},
err => this.isSubmitting = false
));
}
}
)).subscribe(() => {
this.cd.markForCheck();
});
}
}
| +---- follow-button.component.html
<button
class="btn btn-sm action-btn"
[ngClass]="{ 'disabled': isSubmitting,
'btn-outline-secondary': !profile.following,
'btn-secondary': profile.following }"
(click)="toggleFollowing()">
<i class="ion-plus-round"></i>
{{ profile.following ? 'Unfollow' : 'Follow' }} {{ profile.username }}
</button>
| +---- follow-button.component.ts
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { Router } from '@angular/router';
import { Profile, ProfilesService, UserService } from '../../core';
import { concatMap , tap } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({
selector: 'app-follow-button',
templateUrl: './follow-button.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FollowButtonComponent {
constructor(
private profilesService: ProfilesService,
private router: Router,
private userService: UserService,
private cd: ChangeDetectorRef
) {}
@Input() profile: Profile;
@Output() toggle = new EventEmitter<boolean>();
isSubmitting = false;
toggleFollowing() {
this.isSubmitting = true;
// TODO: remove nested subscribes, use mergeMap
this.userService.isAuthenticated.pipe(concatMap(
(authenticated) => {
// Not authenticated? Push to login screen
if (!authenticated) {
this.router.navigateByUrl('/login');
return of(null);
}
// Follow this profile if we aren't already
if (!this.profile.following) {
return this.profilesService.follow(this.profile.username)
.pipe(tap(
data => {
this.isSubmitting = false;
this.toggle.emit(true);
},
err => this.isSubmitting = false
));
// Otherwise, unfollow this profile
} else {
return this.profilesService.unfollow(this.profile.username)
.pipe(tap(
data => {
this.isSubmitting = false;
this.toggle.emit(false);
},
err => this.isSubmitting = false
));
}
}
)).subscribe(() => {
this.cd.markForCheck();
});
}
}
| +---- index.ts
export * from './favorite-button.component';
export * from './follow-button.component';
| +---- index.ts
export * from './article-helpers';
export * from './buttons';
export * from './layout';
export * from './list-errors.component';
export * from './shared.module';
export * from './show-authed.directive';
| +---- layout
| +---- footer.component.html
<footer>
<div class="container">
<a class="logo-font" routerLink="/">conduit</a>
<span class="attribution">
© {{ today | date: 'yyyy' }}.
An interactive learning project from <a href="https://thinkster.io">Thinkster</a>.
Code licensed under MIT.
</span>
</div>
</footer>
| +---- footer.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-layout-footer',
templateUrl: './footer.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FooterComponent {
today: number = Date.now();
}
| +---- header.component.html
<nav class="navbar navbar-light">
<div class="container">
<a class="navbar-brand" routerLink="/">conduit</a>
<!-- Show this for logged out users -->
<ul *appShowAuthed="false"
class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<a class="nav-link"
routerLink="/">
Home
</a>
</li>
<li class="nav-item">
<a class="nav-link"
routerLink="/login"
routerLinkActive="active">
Sign in
</a>
</li>
<li class="nav-item">
<a class="nav-link"
routerLink="/register"
routerLinkActive="active">
Sign up
</a>
</li>
</ul>
<!-- Show this for logged in users -->
<ul *appShowAuthed="true"
class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<a class="nav-link"
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">
Home
</a>
</li>
<li class="nav-item">
<a class="nav-link"
routerLink="/editor"
routerLinkActive="active">
<i class="ion-compose"></i> New Article
</a>
</li>
<li class="nav-item">
<a class="nav-link"
routerLink="/settings"
routerLinkActive="active">
<i class="ion-gear-a"></i> Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link"
[routerLink]="['/profile', currentUser.username]"
routerLinkActive="active">
<img [src]="currentUser.image || 'https://static.productionready.io/images/smiley-cyrus.jpg'" *ngIf="currentUser.image" class="user-pic" alt="user image" />
{{ currentUser.username }}
</a>
</li>
</ul>
</div>
</nav>
| +---- header.component.ts
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { User, UserService } from '../../core';
@Component({
selector: 'app-layout-header',
templateUrl: './header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderComponent implements OnInit {
constructor(
private userService: UserService,
private cd: ChangeDetectorRef
) {}
currentUser: User;
ngOnInit() {
this.userService.currentUser.subscribe(
(userData) => {
this.currentUser = userData;
this.cd.markForCheck();
}
);
}
}
| +---- index.ts
export * from './footer.component';
export * from './header.component';
| +---- list-errors.component.html
<ul class="error-messages" *ngIf="errorList">
<li *ngFor="let error of errorList; trackBy: trackByFn">
{{ error }}
</li>
</ul>
| +---- list-errors.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Errors } from '../core';
@Component({
selector: 'app-list-errors',
templateUrl: './list-errors.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListErrorsComponent {
formattedErrors: Array<string> = [];
@Input()
set errors(errorList: Errors) {
this.formattedErrors = Object.keys(errorList.errors || {})
.map(key => `${key} ${errorList.errors[key]}`);
}
get errorList() { return this.formattedErrors; }
trackByFn(index, item) {
return index;
}
}
| +---- shared.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { ArticleListComponent, ArticleMetaComponent, ArticlePreviewComponent } from './article-helpers';
import { FavoriteButtonComponent, FollowButtonComponent } from './buttons';
import { ListErrorsComponent } from './list-errors.component';
import { ShowAuthedDirective } from './show-authed.directive';
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
HttpClientModule,
RouterModule
],
declarations: [
ArticleListComponent,
ArticleMetaComponent,
ArticlePreviewComponent,
FavoriteButtonComponent,
FollowButtonComponent,
ListErrorsComponent,
ShowAuthedDirective
],
exports: [
ArticleListComponent,
ArticleMetaComponent,
ArticlePreviewComponent,
CommonModule,
FavoriteButtonComponent,
FollowButtonComponent,
FormsModule,
ReactiveFormsModule,
HttpClientModule,
ListErrorsComponent,
RouterModule,
ShowAuthedDirective
]
})
export class SharedModule {}
| +---- show-authed.directive.ts
import {
Directive,
Input,
OnInit,
TemplateRef,
ViewContainerRef
} from '@angular/core';
import { UserService } from '../core';
@Directive({ selector: '[appShowAuthed]' })
export class ShowAuthedDirective implements OnInit {
constructor(
private templateRef: TemplateRef<any>,
private userService: UserService,
private viewContainer: ViewContainerRef
) {}
condition: boolean;
ngOnInit() {
this.userService.isAuthenticated.subscribe(
(isAuthenticated) => {
if (isAuthenticated && this.condition || !isAuthenticated && !this.condition) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
);
}
@Input() set appShowAuthed(condition: boolean) {
this.condition = condition;
}
}
+---- assets
| +---- icons
+---- environments
| +---- environment.prod.ts
export const environment = {
production: true,
api_url: 'https://api.realworld.io/api'
};
| +---- environment.ts
// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `angular-cli.json`.
export const environment = {
production: false,
api_url: 'https://api.realworld.io/api'
};
+---- index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="Description" content="Angular realworld example">
<title>Conduit</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
<link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic&display=swap" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="//demo.productionready.io/main.css">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#1976d2">
</head>
<body>
<app-root>Loading...</app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>
</html>
+---- main.ts
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
const bootstrapPromise = platformBrowserDynamic().bootstrapModule(AppModule);
// Logging bootstrap information
bootstrapPromise.then(success => console.log(`Bootstrap success`))
.catch(err => console.error(err));
+---- polyfills.ts
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for the Reflect API. */
// import 'core-js/es6/reflect';
/** Evergreen browsers require these. **/
// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
// import 'core-js/es7/reflect';
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
+---- styles.css
/* You can add global styles to this file, and also import other style files */
+---- test.ts
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare const __karma__: any;
declare const require: any;
// Prevent Karma from running prematurely.
__karma__.loaded = function () {};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(), {
teardown: { destroyAfterEach: false }
}
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();
+---- tsconfig.app.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"types": []
},
"files": [
"main.ts",
"polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}
+---- tsconfig.spec.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"baseUrl": "./",
"types": [
"jasmine",
"node"
]
},
"files": [
"test.ts",
"polyfills.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}
+---- typings.d.ts
/* SystemJS module definition */
declare var module: NodeModule;
interface NodeModule {
id: string;
}
tsconfig.json
{
"compileOnSave": false,
"compilerOptions": {
"importHelpers": true,
"downlevelIteration": true,
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"module": "es2020",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2015",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2018",
"dom"
]
}
}
Back to Main Page