diff --git a/.gitignore b/.gitignore index 02a9545..b3d1f47 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ typings/ npm-debug.log dist/ -*.pem \ No newline at end of file +*.pem +*.rdb \ No newline at end of file diff --git a/src/app/_models/Article.ts b/src/app/_models/Article.ts index 589c469..4d75afe 100644 --- a/src/app/_models/Article.ts +++ b/src/app/_models/Article.ts @@ -1,9 +1,10 @@ +import { Author } from './Author'; export class Article { _id: number; title: string; description: string; - datePosted: string; + datePosted: Date; text: string; - author: string; + author: Author; tags: Array; } diff --git a/src/app/_models/ArticleList.ts b/src/app/_models/ArticleList.ts new file mode 100644 index 0000000..d45bd0a --- /dev/null +++ b/src/app/_models/ArticleList.ts @@ -0,0 +1,5 @@ +export class ArticleList { + _id: number; + title: string; + tags: Array; +} diff --git a/src/app/_services/article.service.ts b/src/app/_services/article.service.ts index 0f14e5a..0ef4ac4 100644 --- a/src/app/_services/article.service.ts +++ b/src/app/_services/article.service.ts @@ -7,12 +7,13 @@ import 'rxjs/add/operator/map'; import { AuthenticationService } from '../_services/authentication.service'; import { Article } from '../_models/Article'; +import { ArticleList } from '../_models/ArticleList'; import { environment } from '../../environments/environment'; @Injectable() export class ArticleService { - private editorUrl = environment.URL + '/blog/'; + private blogUrl = environment.URL + '/blog/'; private title = ''; private id: string; @@ -35,13 +36,19 @@ export class ArticleService { const options = new RequestOptions({ headers }); - return this.http.get(this.editorUrl, options) + return this.http.get(this.blogUrl, options) .map(this.extractData) .catch(this.handleError); } getArticle(id: number): Observable
{ - return this.http.get(this.editorUrl + id) + return this.http.get(this.blogUrl + id) + .map(this.extractData) + .catch(this.handleError); + } + + getArticlesByTitle(title: string): Observable { + return this.http.get(this.blogUrl + 'title/' + title) .map(this.extractData) .catch(this.handleError); } diff --git a/src/app/_services/authentication.service.ts b/src/app/_services/authentication.service.ts index 6ffd22c..dd87b6b 100644 --- a/src/app/_services/authentication.service.ts +++ b/src/app/_services/authentication.service.ts @@ -74,7 +74,7 @@ export class AuthenticationService { reject(`Error ${error}`); }); } else { - reject('No token available'); + reject(); } }) } diff --git a/src/app/_services/editor.service.ts b/src/app/_services/editor.service.ts index bcb2345..fe1567b 100644 --- a/src/app/_services/editor.service.ts +++ b/src/app/_services/editor.service.ts @@ -58,26 +58,32 @@ export class EditorService { .catch(this.handleError); } - saveArticle(edits: string, tags: string[]): Observable { + saveArticle(edits: string, title: string, description: string, tags: string[], coverPhoto?: FormData): Observable { const headers = new Headers(); - headers.append('Content-Type', 'application/json'); headers.append('Authorization', 'Bearer ' + this.auth.token); + const options = new RequestOptions({ headers }); + const author = JSON.parse(localStorage.getItem('currentUser')); const post = { text: edits, - title: this.title, - description: this.description, + title: title, + description: description, tags, author: author.username }; - const options = new RequestOptions({ headers }); - - return this.http.put(this.editorUrl + this.id, post, options) - .map(this.extractData) - .catch(this.handleError); + if (coverPhoto) { + return Observable.forkJoin( + this.http.put(this.editorUrl + this.id, post, options).map(this.extractData).catch(this.handleError), + this.http.post(this.editorUrl + this.id, coverPhoto, options).map(this.extractData).catch(this.handleError) + ); + } else { + return Observable.forkJoin( + this.http.put(this.editorUrl + this.id, post, options).map(this.extractData).catch(this.handleError) + ); + } } deleteArticle(article): Observable { diff --git a/src/app/_services/tags.service.ts b/src/app/_services/tags.service.ts new file mode 100644 index 0000000..25137cf --- /dev/null +++ b/src/app/_services/tags.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { Http, Response, RequestOptions, Headers } from '@angular/http'; + +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/operator/map'; + +import { AuthenticationService } from '../_services/authentication.service'; +import { Article } from '../_models/Article'; +import { environment } from '../../environments/environment'; + +@Injectable() +export class TagService { + + private tagUrl = environment.URL + '/tags/'; + + constructor( + private http: Http, + private auth: AuthenticationService + ) { } + + getAllTags(): Observable> { + return this.http.get(this.tagUrl) + .map(this.extractData) + .catch(this.handleError); + } + + getArticlesByTag(tag: string): Observable
{ + return this.http.get(this.tagUrl + tag) + .map(this.extractData) + .catch(this.handleError); + } + + private extractData(res: Response) { + const body = res.json(); + return body.data || { }; + } + + private handleError (error: Response | any) { + // In a real world app, you might use a remote logging infrastructure + let errMsg: string; + if (error instanceof Response) { + const body = error.json() || ''; + const err = body.error || JSON.stringify(body); + errMsg = `${error.status} - ${error.statusText || ''} ${err}`; + } else { + errMsg = error.message ? error.message : error.toString(); + } + console.error(errMsg); + return Observable.throw(errMsg); + } + +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 553db31..197eba0 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -18,6 +18,7 @@ import {Router} from './app.routing'; import { EditorComponent } from './editor/editor.component'; import { UserArticlesComponent } from './user-articles/user-articles.component'; import { CreateArticleModalComponent } from './create-article-modal/create-article-modal.component'; +import { TagComponent } from './articles/tag/tag.component'; import { AuthGuard } from './_guards/auth.guard'; import { AuthenticationService } from './_services/authentication.service'; @@ -25,6 +26,8 @@ import { EditorService } from './_services/editor.service'; import { ArticleService } from './_services/article.service'; import { AuthorService } from './_services/author.service'; import { ImagesService } from './_services/images.service'; +import { TagService } from './_services/tags.service'; + import { FileValidator } from './_directives/fileValidator.directive'; import { FileValueAccessor } from './_directives/fileValueAccessor.directive'; @@ -49,7 +52,8 @@ import { MaterialModule } from './material.module'; DeleteArticleModalComponent, SettingsModalComponent, FileValidator, - FileValueAccessor + FileValueAccessor, + TagComponent ], imports: [ BrowserAnimationsModule, @@ -78,6 +82,7 @@ import { MaterialModule } from './material.module'; ArticleService, AuthorService, ImagesService, + TagService, BaseRequestOptions ], bootstrap: [ diff --git a/src/app/articles/articles.component.html b/src/app/articles/articles.component.html index da7577d..798f785 100644 --- a/src/app/articles/articles.component.html +++ b/src/app/articles/articles.component.html @@ -1,19 +1,87 @@
- - - - - {{article?.title}} - - -

- {{article?.description}} -

-
- - - -
-
-
+
+
+
+
+ + + + {{article?.title}} + + + By {{article?.author?.name}}, {{article?.datePosted | date:'longDate'}} + + + + + {{article?.description}} + + + + + +
+
+ + + Search Articles By Title + + + + + + + {{ article.title }} + + + + + + + + About + + +

+ This site was developed by Sam Pastoriza + and James Edwards. + We wanted a blog that was self sustaining and easily modifiable. + We are using the Angular framework developed by Google and a combination of + Angular Material and Bulma to style and layout the site respectively. An express based + backend application utilizes MongoDb and Redis to store data on users and articles. The + blog is completely open-sourced on GitHub and contributers are more than welcome. The end goal + is a fully functional blog mainly covering technical topics, but can be reused + as a base for other blogs. +

+
+ + + + developer_board + Blog + + + developer_board + Backend Api + + + +
+ + + Tags + + + + + + +
+
+
+
\ No newline at end of file diff --git a/src/app/articles/articles.component.scss b/src/app/articles/articles.component.scss index e6ef60a..264594f 100644 --- a/src/app/articles/articles.component.scss +++ b/src/app/articles/articles.component.scss @@ -1,4 +1,26 @@ .articles { margin-left: 3vw; margin-top: 2vh; +} + +.repo:hover { + background-color: #bdbdbd; +} + +.card-sections { + margin-bottom: 20px; +} + +.title-search { + width: 100%; + max-height: 100px; +} + +.article-option { + font-size: 10pt; +} + +.cover-photo { + max-width: 100%; + height: auto; } \ No newline at end of file diff --git a/src/app/articles/articles.component.ts b/src/app/articles/articles.component.ts index ebf23bc..d6f977f 100644 --- a/src/app/articles/articles.component.ts +++ b/src/app/articles/articles.component.ts @@ -1,7 +1,12 @@ import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; + import { ArticleService } from '../_services/article.service'; import { Router } from '@angular/router'; +import { TagService } from '../_services/tags.service'; +import { TagComponent } from './tag/tag.component'; +import { ArticleList } from '../_models/ArticleList'; @Component({ selector: 'app-articles', @@ -11,14 +16,29 @@ import { Router } from '@angular/router'; export class ArticlesComponent implements OnInit { public articles; + public tags: Promise>; + public tagData: Object; + public maxSize: Number + public filteredArticles: Observable; constructor( private articleService: ArticleService, + private tagService: TagService, private router: Router ) { } ngOnInit() { this.articles = this.articleService.getAllArticles(); + this.tagService.getAllTags() + .subscribe((tags) => { + this.tagData = tags; + this.tags = Promise.resolve(Object.keys(tags)); + this.maxSize = Object.keys(tags).map((tag) => { + return parseInt(tags[tag], 10); + }).reduce((accumulator, currentValue) => { + return Math.max(accumulator, currentValue); + }, 0); + }); } selectedArticle(e) { @@ -26,4 +46,16 @@ export class ArticlesComponent implements OnInit { this.router.navigate(['article', e._id]); } + getArticlesByTag(tag: string) { + this.articles = this.tagService.getArticlesByTag(tag); + } + + filterArticles(title: string) { + this.filteredArticles = this.articleService.getArticlesByTitle(title); + } + + articleSelected(article: ArticleList) { + this.router.navigate(['article', article._id]); + } + } diff --git a/src/app/articles/tag/tag.component.html b/src/app/articles/tag/tag.component.html new file mode 100644 index 0000000..a01dc08 --- /dev/null +++ b/src/app/articles/tag/tag.component.html @@ -0,0 +1 @@ + {{tag}} \ No newline at end of file diff --git a/src/app/articles/tag/tag.component.scss b/src/app/articles/tag/tag.component.scss new file mode 100644 index 0000000..7604f1e --- /dev/null +++ b/src/app/articles/tag/tag.component.scss @@ -0,0 +1,3 @@ +:host { + color: blue; +} \ No newline at end of file diff --git a/src/app/articles/tag/tag.component.ts b/src/app/articles/tag/tag.component.ts new file mode 100644 index 0000000..3daa93a --- /dev/null +++ b/src/app/articles/tag/tag.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, HostBinding, OnInit } from '@angular/core'; + +import { Router } from '@angular/router'; + +const MAX_SIZE = 30; + +@Component({ + selector: 'tag', + templateUrl: './tag.component.html', + styleUrls: ['./tag.component.scss'] +}) +export class TagComponent implements OnInit { + @Input() tag: string; + @Input() fontSize: number; + @Input() maxSize: number; + + @HostBinding('style.font-size.pt') size: number; + + ngOnInit() { + this.size = (this.fontSize / this.maxSize) * MAX_SIZE; + } +} diff --git a/src/app/editor/editor.component.html b/src/app/editor/editor.component.html index 6d2b123..c7dddb9 100644 --- a/src/app/editor/editor.component.html +++ b/src/app/editor/editor.component.html @@ -1,33 +1,43 @@
-
- - - - Article Title is required - - - - - Article Description is required - - - - - {{ tag }} - cancel - - - - - - - - {{ tag }} - - - -
-
-
-
+
+
+
+ + + + Article Title is required + + + + + Article Description is required + + + + + {{ tag }} + cancel + + + + + + + + {{ tag }} + + + + +
+ +
+
+
+
+
diff --git a/src/app/editor/editor.component.scss b/src/app/editor/editor.component.scss index 9c20c25..14eec0c 100644 --- a/src/app/editor/editor.component.scss +++ b/src/app/editor/editor.component.scss @@ -1,9 +1,7 @@ .editor { display: flex; justify-content: center; - align-items: center; - flex-direction: column; - margin-top: 7vh; + flex-direction: row; } .save { @@ -18,7 +16,7 @@ } .form-container { - text-align: center; + text-align: left; } .editor-form { @@ -26,23 +24,23 @@ margin-right: 1vw; } -.vertical-spacer { - width: 100%; - display: block; - clear: all; - padding-top: 2em; -} - -.vertical-half-spacer { - width: 100%; - display: block; - clear: all; - padding-top: 1em; +.btn-file { + position: relative; + overflow: hidden; } -.vertical-quarter-spacer { - width: 100%; +.btn-file input[type=file] { + position: absolute; + top: 0; + right: 0; + min-width: 100%; + min-height: 100%; + font-size: 100px; + text-align: right; + filter: alpha(opacity=0); + opacity: 0; + outline: none; + background: white; + cursor: inherit; display: block; - clear: all; - padding-top: 0.5em; } \ No newline at end of file diff --git a/src/app/editor/editor.component.ts b/src/app/editor/editor.component.ts index d1f4ab4..6b35583 100644 --- a/src/app/editor/editor.component.ts +++ b/src/app/editor/editor.component.ts @@ -12,8 +12,7 @@ import { ImagesService } from '../_services/images.service'; import initializeFroalaGistPlugin from '../_plugins/gist.plugin' import { environment } from '../../environments/environment'; - -declare var $: any; +import { FileValidator } from '../_directives/fileValidator.directive'; @Component({ selector: 'app-editor', @@ -74,7 +73,8 @@ export class EditorComponent implements OnInit { this.formGroup = this.fb.group({ 'articleTitle': new FormControl('', Validators.required), 'articleDescription': new FormControl('', Validators.required), - 'tags': new FormControl('') + 'tags': new FormControl(''), + 'coverPhoto': new FormControl('', [FileValidator.validate]) }); } @@ -108,7 +108,8 @@ export class EditorComponent implements OnInit { this.formGroup.setValue({ 'articleTitle': article.title, 'articleDescription': article.description, - 'tags': '' + 'tags': '', + 'coverPhoto': {} }); if (article.tags instanceof Array) { this.selectedTags = new Set(article.tags); @@ -126,25 +127,35 @@ export class EditorComponent implements OnInit { if (isFormValid) { const articleTitle = formValue['articleTitle']; const articleDescription = formValue['articleDescription']; + const coverPhoto = formValue['coverPhoto']; const tags = Array.from(this.selectedTags); - this.editorService.setArticleTitle(articleTitle); - this.editorService.setArticleDescription(articleDescription); - - this.editorService.saveArticle(this.content, tags) + if (coverPhoto.target) { + const formData = new FormData(); + const file = coverPhoto.target.files[0]; + formData.append('coverPhoto', file); + this.editorService.saveArticle(this.content, articleTitle, articleDescription, tags, formData) .subscribe(result => { - if (result['text'] === this.content) { - this.snackBar.open('Successfully saved article', '', { - duration: 4000 - }); - } else { - console.error('Failed to save article, please try again'); - } + this.snackBar.open('Successfully saved article', '', { + duration: 4000 + }); }, error => { this.snackBar.open('There was an error while attempting to save this article', '', { duration: 4000 }); }); + } else { + this.editorService.saveArticle(this.content, articleTitle, articleDescription, tags) + .subscribe(result => { + this.snackBar.open('Successfully saved article', '', { + duration: 4000 + }); + }, error => { + this.snackBar.open('There was an error while attempting to save this article', '', { + duration: 4000 + }); + }); + } } else { console.error('Form is not valid', formValue); } diff --git a/src/app/material.module.ts b/src/app/material.module.ts index ab3f9fd..6966462 100644 --- a/src/app/material.module.ts +++ b/src/app/material.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { MatAutocompleteModule, MatGridListModule, MatDialogModule, MatInputModule, MatSelectModule, MatMenuModule, MatSidenavModule, MatToolbarModule, MatListModule, MatCardModule, MatButtonModule, MatIconModule, MatSnackBarModule, MatTableModule, - NoConflictStyleCompatibilityMode, MatChipsModule } from '@angular/material'; + NoConflictStyleCompatibilityMode, MatChipsModule, MatProgressSpinnerModule } from '@angular/material'; @NgModule({ imports: [ @@ -21,6 +21,7 @@ import { MatAutocompleteModule, MatGridListModule, MatDialogModule, MatInputModu MatSnackBarModule, MatTableModule, MatChipsModule, + MatProgressSpinnerModule, NoConflictStyleCompatibilityMode ], exports: [ @@ -39,6 +40,7 @@ import { MatAutocompleteModule, MatGridListModule, MatDialogModule, MatInputModu MatSnackBarModule, MatTableModule, MatChipsModule, + MatProgressSpinnerModule, NoConflictStyleCompatibilityMode ] }) diff --git a/src/app/nav-bar/nav-bar.component.html b/src/app/nav-bar/nav-bar.component.html index 308982a..82fc1da 100755 --- a/src/app/nav-bar/nav-bar.component.html +++ b/src/app/nav-bar/nav-bar.component.html @@ -5,22 +5,22 @@ {{title}}
- Login - Register - Your Articles - - - - - + + + +
diff --git a/src/app/nav-bar/nav-bar.component.scss b/src/app/nav-bar/nav-bar.component.scss index 8c8b341..b202be0 100755 --- a/src/app/nav-bar/nav-bar.component.scss +++ b/src/app/nav-bar/nav-bar.component.scss @@ -9,8 +9,9 @@ } #profile-picture { - padding-inline-start: 5px; + margin-left: 5px; width: 30px; height: 30px; object-fit: contain; + border-radius: 15px; } diff --git a/src/app/nav-bar/nav-bar.component.ts b/src/app/nav-bar/nav-bar.component.ts index 8bdf83b..582ad41 100755 --- a/src/app/nav-bar/nav-bar.component.ts +++ b/src/app/nav-bar/nav-bar.component.ts @@ -45,7 +45,9 @@ export class NavBarComponent implements OnInit { this.image = this.authorService.getProfilePicture(); }) .catch(error => { - console.error('Error', error); + if (error) { + console.error('Error', error); + } this.logout(); }); } diff --git a/src/app/settings-modal/settings-modal.component.html b/src/app/settings-modal/settings-modal.component.html index 00f9ab2..f6b7ff2 100644 --- a/src/app/settings-modal/settings-modal.component.html +++ b/src/app/settings-modal/settings-modal.component.html @@ -1,8 +1,13 @@ - +

User Settings

-
+
+ +
+ Saving User Settings... +
+ @@ -12,10 +17,11 @@ Email is required - + diff --git a/src/app/settings-modal/settings-modal.component.scss b/src/app/settings-modal/settings-modal.component.scss index 59d8078..c853947 100644 --- a/src/app/settings-modal/settings-modal.component.scss +++ b/src/app/settings-modal/settings-modal.component.scss @@ -23,11 +23,17 @@ min-width: 100%; min-height: 100%; font-size: 100px; - text-align: right; filter: alpha(opacity=0); opacity: 0; outline: none; background: white; cursor: inherit; display: block; +} + +.spinner { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } \ No newline at end of file diff --git a/src/app/settings-modal/settings-modal.component.ts b/src/app/settings-modal/settings-modal.component.ts index 21b79d2..6ad3396 100644 --- a/src/app/settings-modal/settings-modal.component.ts +++ b/src/app/settings-modal/settings-modal.component.ts @@ -16,6 +16,7 @@ export class SettingsModalComponent implements OnInit { settingsGroup: FormGroup; fileContent: any; username: string; + public saveInProgress: boolean; constructor( fb: FormBuilder, @@ -42,11 +43,13 @@ export class SettingsModalComponent implements OnInit { this.username = author.username; }, error => { console.error('Error', error); - }) + }); + this.saveInProgress = false; } saveSettings(formValue: any, isFormValid: boolean) { if (isFormValid) { + this.saveInProgress = true; const name = formValue['name']; const email = formValue['email']; const profilePicture = formValue['profilePicture']; @@ -56,11 +59,13 @@ export class SettingsModalComponent implements OnInit { formData.append('profilePicture', file); this.authorService.updateUserSettings(this.username, name, email, formData) .subscribe(result => { + this.saveInProgress = false; this.snackBar.open('Updated user settings', '', { duration: 4000 }); this.dialogRef.close({name, image: result.image || ''}); }, error => { + this.saveInProgress = false; console.error('Error', error); this.snackBar.open(`Error updating user settings ${error}`, '', { duration: 4000 @@ -69,11 +74,13 @@ export class SettingsModalComponent implements OnInit { } else { this.authorService.updateUserSettings(this.username, name, email) .subscribe(result => { + this.saveInProgress = false; this.snackBar.open('Updated user settings', '', { duration: 4000 }); this.dialogRef.close({name}); }, error => { + this.saveInProgress = false; console.error('Error', error); this.snackBar.open(`Error updating user settings ${error}`, '', { duration: 4000 diff --git a/src/index.html b/src/index.html index 93e2c0a..bd44546 100755 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,7 @@ + diff --git a/src/styles.css b/src/styles.css index 90d4ee0..42c1c23 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1 +1,21 @@ /* You can add global styles to this file, and also import other style files */ +.vertical-spacer { + width: 100%; + display: block; + clear: all; + padding-top: 2em; +} + +.vertical-half-spacer { + width: 100%; + display: block; + clear: all; + padding-top: 1em; +} + +.vertical-quarter-spacer { + width: 100%; + display: block; + clear: all; + padding-top: 0.5em; +} \ No newline at end of file