اذهب إلى المحتوى

بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - إضافة التدوينات وعرضها


أسامة دمراني

تعرفنا في المقال السابق على إطار العمل Angular وإمكانياته المختلفة، وكذلك على قاعدة البيانات Firestore المقدمة من جوجل، وكذلك على منصة Firebase التي تدعمها جوجل أيضًا والتي سننشر عليها تطبيقنا وهيأنا مشروعنا وأنشأنا ما يلزمه لبدء العمل عليه، أما في هذا الفصل فسنكمل العمل وسنتعلم كيفية بناء مدونة كتطبيق على استخدام إطار عمل Angular في إنشاء تطبيقات وحيدة الصفحة وتخصيصها وإضافة الوظائف اللازمة لها، وكذلك الصلاحيات التي تكون للمستخدمين للتعامل مع تلك الوظائف من خلال أزرار أو غيرها، ثم نشر تطبيق المدونة على منصة Firebase.

هذا المقال جزء من سلسلة عن بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore:

إنشاء الصفحة الرئيسية

سنبني الهيكل الأساسي للصفحة الرئيسية والتي فيها شريط للتنقل يحوي الروابط الأساسية في التطبيق.

نشغِّل الأمر التالي في الطرفية لتوليد مكوِّن شريط التنقل navigation bar:

ng g c components/nav-bar

ثم نفتح الملف nav-bar.component.html ونستبدل الشيفرة التالية بالموجودة هناك:

<mat-toolbar class="nav-bar mat-elevation-z2"></mat-toolbar>

كذلك، نضيف تنسيق شريط التنقل في الملف src/app/components/nav-bar/nav-bar.component.scss كما يلي:

.nav-bar {
 background-color: #1565C0;
 color: #FFFFFF;
 position: fixed;
 top: 0;
 z-index: 99;
}

button:focus {
 outline: none;
 border: 0;
}

لإنشاء مكون HomeComponent ليمثل الصفحة الرئيسية، نشغِّل الأمر التالي:

ng g c components/home

لن نضيف أي شيفرة أخرى هنا إلى HomeComponent، لكن سنعود إليها في الجزء الثالث والأخير من السلسلة.

إضافة وحدة التوجيه Router Module

سنضيف وحدة التوجيه RouterModule في الملف app.module.ts كما يلي:

import { RouterModule } from '@angular/router';

@NgModule({
 ...
 imports: [
  ...
  RouterModule.forRoot([
   { path: '', component: HomeComponent, pathMatch: 'full' },
   { path: '**', component: HomeComponent }
  ]),
 ],
})

المسار الفارغ هنا يمثل المسار الافتراضي للتطبيق، فإذا كان المسار في الرابط فارغًا سيعرض التطبيق صفحة HomeComponent، أما المسار فهو محرف بدل wildcard، بمعنى أن الموجِّه سيختار هذا الاتجاه إذا كان الرابط المطلوب لا يطابق أي اتجاه route معرَّف في الإعدادات من قبل.

ومن المهم الانتباه إلى الترتيب الذي نعرِّف به الاتجاهات للتطبيق، فالموجِّه يستخدم سياسة أول تطابق ناجح عند مطابقة الاتجاهات، ولهذا يجب أن توضع الاتجاهات الأكثر دقة وتخصصًا فوق العامة منها والأقل تخصصًا، أما الاتجاه البدل wildcade route فيجب وضعه فوق النوع الثاني، أي الاتجاهات العامة، لأنه يطابق كل رابط، ويجب ألا يتم اختياره إلا إذا لم تطابَق اتجاهات أخرى قبله.

يجب الآن تحديث AppComponent لإضافة مكون الموجه، لذا افتح الملف app.component.html واستبدل بمحتوياته الشيفرة التالية:

<app-nav-bar></app-nav-bar>
<div class="container">
  <router-outlet></router-outlet>
</div>

الوسم <router-outlet> في الشيفرة أعلاه هو موضع مؤقت placeholder يملؤه Angular ديناميكيًا بالمكون وفقًا للحالة الراهنة للموجِّه.

أضف التنسيقات التالية إلى الملف src/styles.scss:

body {
 background-color: #fafafa;
}
.container {
 padding-top: 60px;
}

إضافة وحدة الاستمارات

سنضيف وحدة الاستمارات forms module في الملف app.module.ts كما هو موضح أدناه، كي نستطيع استخدام استمارات قالبية template-driven في تطبيقنا:

import { FormsModule } from '@angular/forms';

@NgModule({
 ...
 imports: [
  ...
  FormsModule,
 ],
})

إنشاء نموذج البيانات وخدمة المدونة

لكل تدوينة هيكل أساسي فيه بيانات التدوينة، ويعتمد التطبيق على هذا الهيكل بما أنه التطبيق هو مدونة بالأصل، لذا سننشئ نموذج بيانات التدوينة ثم سننشئ خدمة المدونة لمعالجة العمليات المطبقة على التدوينة في قاعدة البيانات من إنشاء وتعديل وحذف.

أنشئ مجلدًا جديدًا باسم models داخل المجلد src/app، ثم أنشئ ملفًا باسم post.ts داخل هذا المجلد الجديد، والصق فيه الشيفرة التالية:

export class Post {
 postId: string;
 title: string;
 content: string;
 author: string;
 createdDate: any;

  constructor() {
  this.content = '';
 }
}

لقد هيأنا بهذا خاصية المحتوى لتكون سلسلة نصية فارغة في منشئ الصنف، وذلك لضمان أن المحتوى لن يظهر في صورة غير معرّفة undefined عند ربطه بحقل من حقول الاستمارة.

أنشئ خدمة جديدة لمعالجة عمليات قاعدة البيانات، باستخدام الأمر التالي:

ng g s services/blog

افتح الملف blog.service.ts وأضف تعريفات الاستيراد التالية في أعلاه:

import { Post } from '../models/post';
import { AngularFirestore } from '@angular/fire/firestore';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';

ثم احقن AngularFirestore في المنشئ كما يلي:

constructor(private db: AngularFirestore) { }

سنضيف الآن التابع من أجل إنشاء تدوينة جديدة، من خلال وضع تعريف التابع في ملف blog.service.ts:

createPost(post: Post) {
 const postData = JSON.parse(JSON.stringify(post));
 return this.db.collection('blogs').add(postData);
}

سيقبل هذا التابع معامِلًا من النوع Post، وسنحلل المعامِل إلى كائن JSON، ونضيفه إلى تجميعة blogs في قاعدة بياناتنا وفقًا لحالة التجميعة، فيضاف إليها إذا كانت التجميعة موجودة مسبقًا، أما إذا لم تكن موجودة فسينشئها تابع الإضافة ثم يضيف الكائن الجديد إليها.

إضافة محرر المدونة

سيعتمد محرر المدونة على حزمة CKEditor لإضافة التدوينة الجديدة وتعديلها، وهذه الحزمة عبارة عن محرر نصوص غنية rich text مكتوب بجافاسكربت وذي معمارية مبنية على الوحدات، وتوفر خصائصه وواجهته الواضحة تجربة استخدام ممتازة لإنشاء محتوى دلالي semantic بأسلوب "ما تراه هو ما تحصل عليه WYSIWYG"، وهو متوافق مع جميع متصفحات الويب الحديثة.

نفذ الأمر التالي لتثبيت مكون محرر CKEditor لإطار Angular:

npm install --save @ckeditor/ckeditor5-angular

ثم شغِّل الأمر التالي لتثبيت إحدى البنيات الرسمية، وهي المحرر التقليدي هنا classic editor:

npm install --save @ckeditor/ckeditor5-build-classic

واستورد CKEditorModule إلى الملف app.module.ts كما يلي:

import { CKEditorModule } from '@ckeditor/ckeditor5-angular';
@NgModule( {
 imports: [
  ...
  CKEditorModule,
 ],
})

سننشئ مكونًا جديدًا لإضافة التدوينة وتحريرها، نفِّذ الأمر التالي:

ng g c components/blog-editor

إضافة وجهة لصفحة إنشاء التدوينة

أضف وجهة route للمكون BlogEditor في الملف app.module.ts كما يلي:

RouterModule.forRoot([
 ...
 { path: 'addpost', component: BlogEditorComponent },
 ...
])

إضافة المحرر إلى التدوينة

افتح الملف blog-editor.component.ts وأضف تعريفات الاستيراد التالية:

import * as ClassicEditor from '@ckeditor/ckeditor5-build-classic';
import { Post } from 'src/app/models/post';
import { DatePipe } from '@angular/common';
import { BlogService } from 'src/app/services/blog.service';
import { Router, ActivatedRoute } from '@angular/router';

كذلك، سنهييء بعض الخصائص لهذا المكون، فأضف الشيفرة التالية إلى صنف BlogEditorComponent:

public Editor = ClassicEditor;
ckeConfig: any;
postData = new Post();
formTitle = 'Add';
postId = '';

سننشئ تابعًا لتعريف الإعدادات الخاصة بمحرر التدوينات، فأضف تعريف التابع setEditorConfig في الصنف BlogEditorComponent كما يلي:

setEditorConfig() {
 this.ckeConfig = {
 removePlugins: ['ImageUpload', 'MediaEmbed'],
 heading: {
   options: [
    { model: 'paragraph', title: 'Paragraph', class: 'ckheading_paragraph' },
    { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ckheading_heading1' },
    { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ckheading_heading2' },
    { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ckheading_heading3' },
    { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ckheading_heading4' },
    { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ckheading_heading5' },
    { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ckheading_heading6' },
    { model: 'Formatted', view: 'pre', title: 'Formatted' },
   ]
  }
 };
}

يًستخدم التابع لضبط إعدادات المحرر التقليدي، وسنزيل إضافتي ImageUpload و MediaEmbed من المحرر بما أننا لن نستخدم وظائفهما في التطبيق، وسنضبط خيارات التنسيق في المحرر لتشمل الترويسات والفقرات والنصوص المنسَّقة formatted text.

نستدعي هذا التابع داخل تابع ngOnInit كما يلي:

ngOnInit() {
 this.setEditorConfig();
}

تحديث قالب التدوينة

افتح الملف blog-editor.component.html وضع الشيفرة التالية فيه:

<h1>{{formTitle}} Post</h1>
 <hr />
 <form #myForm="ngForm" (ngSubmit)="myForm.form.valid && saveBlogPost()"
accept-charset="UTF-8" novalidate>
  <input type="text" class="blogHeader" placeholder="Add title..."
class="form-control" name="postTitle"
 [(ngModel)]="postData.title" #postTitle="ngModel" required />
  <span class="text-danger" *ngIf="myForm.submitted &&
postTitle.errors?.required">
 Title is required
  </span>
  <br />
  <div class="form-group">
   <ckeditor name="myckeditor" [config]="ckeConfig"
[(ngModel)]="postData.content" #myckeditor="ngModel"
 debounce="300" [editor]="Editor"></ckeditor>
  </div>
  <div class="form-group">
   <button type="submit" mat-raised-button
color="primary">Save</button>
   <button type=" button" mat-raised-button color="warn"
(click)="cancel()">CANCEL</button>
  </div>
</form>

لقد عرّفنا هنا استمارة قالبية نستدعي منها التابع saveBlogPost على عملية الإرسال الناجحة لبيانات الاستمارة، وتحتوي الاستمارة على حقلين، أحدهما يقبل عنوان التدوينة وهو حقل إجباري، وأما الآخر فيقبل محتواها، ويحتوي على مكون محرر CKEditor، حيث تُستخدم خاصية المحرر لتعيين نوعه.

ونستخدم هنا النسخة التقليدية من محرر CKEditor، وسنربط خاصية الإعداد config لمكون ckeditor مع المتغير ckeConfig الذي أعددناه في القسم السابق. كذلك سنربط محتوى المكون ckeditor مع خاصية المحتوى للكائن postData الذي يحمل النوع Post.

كذلك، سيعيد المكون ckeditor المحتوى بتنسيق HTML وليس بتنسيق نص عادي، وسنحفظ كل تدوينة في صورة HTML في قاعدة البيانات، لنضمن عدم فقد تنسيق المحتوى أثناء الحفظ، وكذلك أثناء جلبه من قاعدة البيانات.

أخيرًا، أضف التنسيق لمحرر التدوينة في الملف styles.scss كما يلي:

.ck-editor__editable {
 max-height: 350px;
 min-height: 350px;
}

pre {
 display: block;
 padding: 9.5px;
 margin: 0 0 10px;
 font-size: 13px;
 line-height: 1.42857143;
 color: #333;
 word-break: break-all;
 word-wrap: break-word;
 background-color: #f5f5f5;
 border: 1px solid #ccc;
 border-radius: 4px;
}

blockquote {
 display: block;
 padding: 10px 20px;
 margin: 0 0 20px;
 font-size: 17.5px;
 border-left: 5px solid #eee;
}

.spacer {
 flex: 1 1 auto;
}

img{
 max-width: 100%;
}

إضافة تدوينة جديدة

سننفذ الآن خاصية إضافة التدوينة الجديدة في تطبيقنا، فافتح الملف blog-editor.component.ts وأضف تعريفات الخدمات التالية إلى المنشئ:

constructor(private route: ActivatedRoute,
 private datePipe: DatePipe,
 private blogService: BlogService,
 private router: Router) { }

ثم أضف مزود DataPipe في قسم المزخرِف ‎@Component كما يلي:

@Component({
 ... providers: [DatePipe] 
}

وكذلك تعريف التابع saveBlogPost كما يلي:

saveBlogPost() {
 this.postData.createdDate = this.datePipe.transform(Date.now(), 'MMdd-yyyy HH:mm');
 this.blogService.createPost(this.postData).then(
  () => {
  this.router.navigate(['/']);
  }
 );
}

أضف الآن التاريخ الحالي في كائن postData على أنه تاريخ إنشاء التدوينة، ثم استدع التابع createPost من BlogService لإضافة تدوينة جديدة إلى قاعدة بياناتنا، وسيُستدعى هذا التابع عند النقر على زر Save. سنضيف أيضًا تعريف التابع التالي لتابع cancel الذي يُستدعى عند النقر على زر Cancel:

cancel() {
 this.router.navigate(['/']);
}

إضافة أزرار إلى شريط التنقل

سنضيف في شريط التنقل زرًا للانتقال إلى محرر التدوينة وكذلك إلى الصفحة الرئيسية، من خلال وضع الشيفرة التالية في العنصر <mat-toolbar> داخل الملف nav-bar.component.html:

<button mat-button [routerLink]='[""]'> My blog </button>
<button mat-button [routerLinkActive]='["link-active"]'
[routerLink]='["/addpost"]'>
 Add Post
</button>
<span class="spacer"></span>

بما أن الخادم يعمل فستحدِّث الصفحة نفسها كلما حدث تغيير جديد، فافتح المتصفح وانقر على زر AddPost في شريط التنقل لينتقل التطبيق إلى صفحة إضافة التدوينة، ثم أضف تدوينة جديدة وانقر زر الحفظ لحفظ التدوينة في قاعدة البيانات، انظر الصورة التالية:

addpost.png

ستضاف التدوينة إلى قاعدة بيانات Firestore الخاصة بنا بمجرد النقر على زر Save الموضح في الصورة أعلاه، ثم تنتقل الصفحة إلى الصفحة الرئيسية للتطبيق.

فإذا أردنا هنا أن نتحقق من إضافة البيانات بنجاح إلى قاعدة البيانات، فإننا نفتح طرفية Firebase وننتقل إلى صفحة اللوحة العامة للمشروع ثم اضغط على الرابط Database في القائمة اليسرى، لنرى حينئذ سجلًا يُظهر التدوينة التي نشرناها، وسيحتوي حقل content على محتوى التدوينة بتنسيق HTML. انظر الصورة التالية:

dbcheck.png

إنشاء أنابيب مخصصة لمعالجة البيانات

سنضيف نوعين من الأنابيب المخصصة custom pipes في تطبيقنا:

  • Excerpt: يعرض هذا النوع ملخصًا للمقالة على بطاقة التدوينة.
  • Slug: يعرض هذا النوع الاسم اللطيف slug الموجود في رابط التدوينة.
اقتباس

الاسم اللطيف هو الاسم المناسب ليكون رابطًا صالحًا مثلًا عنوان "تعلم البرمجة بجافاسكربت" غير مناسب ليكون رابطًا لأنه يحوي فراغات بينما "تعلم-البرمجة-بجافاسكربت" هو slug لأنه مناسب لأن يكون رابطًا ويضاف في نهاية العنوان مثل ‪academy.hsoub.com/programming/تعلم-البرمجة-بجافاسكربت

شغِّل الأمر التالي لتوليد أنبوب الملخص excerpt pipe:

ng g p customPipes/excerpt

ثم افتح excerpt.pipe.ts واستبدل الشيفرة التالية بتابع التحويل transform الذي في الملف:

transform(content: string) {
 const postSummary = content.replace(/(<([^>]+)>)/ig, '');
 if (postSummary.length > 300) {
  return postSummary.substr(0, 300) + ' [...]';
 } else {
  return postSummary;
 }
}

سيقبل هذا الأنبوب محتويات التدوينة كسلسلة نصية ويعيد أول 300 محرف لتشكل ملخص تلك التدوينة، وبما أن محتوى التدوينة يكون بتنسيق HTML، فسنزيل كل وسوم HTML قبل استخراج الملخص.

شغٍّل الأمر التالي لتوليد أنبوب Slug:

ng g p customPipes/slug

ثم افتح ملف slug.pipe.ts واستبدل الشيفرة التالية بتابع التحويل الذي فيه:

transform(title: string) {
 const urlSlug = title.trim().toLowerCase().replace(/ /g, '-');
 return urlSlug;
}

سيقبل هذا الأنبوب عنوان التدوينة ويعيده ليكون هو الاسم اللطيف slug في الرابط، وسنستبدل محرف المسافة البيضاء التي بين الكلمات في العنوان بمحرف - لإنشاء الاسم اللطيف للرابط.

عرض التدوينات

سنحتاج بعد إضافة كل تدوينة على حدى إلى عرض هذه التدوينة في مكان ما، لذا سنعرض كل التدوينات في الصفحة الرئيسية على شكل بطاقات يمكن الضغط عليها لعرض التدوينة في صفحة منفصلة. سنجلب أولًا التدوينات من قاعدة الباينات ونعرضها في الصفحة الرئيسية في مكون خاص يدعى BlogCardComponent.

جلب التدوينات من قاعدة البيانات

يُستخدم التابع getAllPosts لجلب جميع التدوينات الخاصة بالمدونة من قاعدة البيانات، وسنضيف ذلك التابع في الملف blog.service.ts.

فيما يلي التعريف الخاص بتابع getAllPosts:

getAllPosts(): Observable<Post[]> { const blogs = this.db.collection<Post>('blogs', refgetAllPosts(): Observable<Post[]> {
 const blogs = this.db.collection<Post>('blogs', ref =>
ref.orderBy('createdDate', 'desc'))
 .snapshotChanges().pipe(
  map(actions => {
   return actions.map(
    c => ({
     postId: c.payload.doc.id,
     ...c.payload.doc.data()
   }));
  }));
 return blogs;
}

سنجلب التدوينة وفق تاريخ إنشائها تنازليًا، لنضمن أن التدوينة الأخيرة تكون في الأعلى دومًا.

إنشاء مكون قائمة التدوينات BlogCardComponent

نفِّذ الأمر التالي لإنشاء مكون بطاقة التدوينة Blog Card Component:

ng g c components/blog-card

افتح الملف blog-card.component.ts وأضف تعريفات الاستيراد التالية:

import { OnDestroy } from '@angular/core';
import { BlogService } from 'src/app/services/blog.service';
import { Post } from 'src/app/models/post';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

ثم احقن خدمة المدونة blog service في منشئ الصنف BlogCardComponent كما يلي:

constructor(private blogService: BlogService) { }

وأضف خاصية تحتفظ بالتدوينة الحالية:

blogPost: Post[] = [];
private unsubscribe$ = new Subject<void>();

سننشئ الآن تابعًا ليجلب التدوينة ويستدعيها داخل ngOnInit الملف blog-card.component.ts:

ngOnInit() {
 this.getBlogPosts();
}

getBlogPosts() {
 this.blogService.getAllPosts()
  .pipe(takeUntil(this.unsubscribe$))
  .subscribe(result => {
   this.blogPost = result;
  });
}

بعد ذلك نطبق الواجهة OnDestroy على الصنف BlogCardComponent، ثم نكمل تنفيذ الاشتراك unsubscribe$‎ داخل التابع ngOnDestroy، كما هو موضح في الشيفرة أدناه:

export class BlogCardComponent implements OnInit, OnDestroy {
...

 ngOnDestroy() {
  this.unsubscribe$.next();
  this.unsubscribe$.complete();
 }
}

افتح الملف blog-card.component.html واستبدل الشيفرة التالية بالشيفرة الموجودة في الملف:

<ng-template #emptyblog>
 <div class="spinner-container">
  <mat-spinner></mat-spinner>
 </div>
</ng-template>
<ng-container *ngIf="blogPost && blogPost.length>0; else emptyblog">
 <div *ngFor="let post of blogPost">
  <mat-card class="blog-card mat-elevation-z2">
   <mat-card-content>
    <a class="blog-title" [routerLink]="['/blog/',
post.postId, post.title | slug]">
     <h2>{{ post.title}} </h2>
    </a>
   </mat-card-content>
   <mat-card-content>
    <div [innerHTML]="post.content | excerpt"></div>
   </mat-card-content>
   <mat-divider></mat-divider>
   <mat-card-actions align="end">
    <ng-container>
     <button mat-raised-button color="accent"
[routerLink]="['/editpost',post.postId]">Edit</button>
     <button mat-raised-button color="warn"
(click)="delete(post.postId)">Delete</button>
    </ng-container>
    <span class="spacer"></span>
    <button mat-raised-button color="primary"
[routerLink]="['/blog/', post.postId, post.title | slug]">Read
 More</button>
   </mat-card-actions>
  </mat-card>
 </div>
 <mat-divider></mat-divider>
</ng-container>

سنعرض عنوان التدوينة والملخص في المكوِّن mat-card، وهو مكون في Angular Material يُستخدم لعرض المحتوى في هيئة بطاقات، ونذكِّر هنا أننا استخدمنا أنبوب الاسم اللطيف slug لإنشاء رابط التدوينة، وأنبوب الملخص excerpt للحصول على ملخص لكل تدوينة.

وستحتوي بطاقة كل تدوينة على أزرار لتحريرها وحذفها، ويستطيع أي مستخدم عند هذه النقطة أن يصل إلى هذه الأزرار، بيد أن هذا سلوك خاطئ وسنغيره لاحقًا بإضافة أذونات authorizations إلى التطبيق كي لا يستطيع أحد أن يعدّل أو يحذف من التدوينات إلا من لديه صلاحيات المدير admin فقط.

جدير بالذكر هنا أننا قد نحصل على خطأ في هذا الملف، ذلك أننا أضفنا زر الحذف Delete وعرَّفنا تابعًا للحذف عند وقوع حدث النقر على الزر، غير أننا لم نضف أي تابع حذف بعد، وعليه فسنحصل على خطأ وقت التصريف compile-time error. ولحل هذه المشكلة، نضيف الشيفرة التالية في الملف blog-card.component.ts:

delete(postId: string) { 
 // هنا يوضع تعريف التابع، وسنكتبه لاحقًا
}

عرَّفنا في هذه الشيفرة تابعًا فارغًا، وسنضيف منطق الحذف إليه في الجزء الأخير من الكتاب.

افتح الملف blog-card.component.scss واستبدل الشيفرة التالية بالموجودة في الملف:

.blog-card {
 margin-bottom: 15px;
}

.blog-title {
 text-decoration: none;
}

a:hover {
 color: indianred;
}

.spinner-container{
 display: flex;
 align-items: center;
 justify-content: center;
 height: 100%;
}

إضافة قائمة التدوينات إلى الصفحة الرئيسية

بما أننا سنعرض بطاقات التدوينات على الصفحة الرئيسية، فسنحتاج إلى إضافة مكون بطاقة التدوينة إلى مكون الصفحة الرئيسية home، فافتح الملف home.component.html واستبدل شيفرة HTML التالية بالموجودة في الملف:

<div class="row left-panel">
 <div class="col-md-9">
  <app-blog-card></app-blog-card>
 </div>
</div>

ثم افتح الملف home.component.scss وأضف تعريف التنسيق التالي داخله:

.left-panel {
 margin-top: 15px;
}

هنا نكون قد أضفنا بطاقة التدوينة إلى الصفحة الرئيسية، فافتح المتصفح لترى التدوينة معروضة على الصفحة الرئيسية، وهي نفس التدوينة التي أضفناها في نقطة التحقق الأولى من قبل، وستحتوي البطاقة أيضًا على أزرار التحرير Edit والحذف Delete وقراءة المزيد Read More، غير أنها جميعًا لا تعمل إلى الآن، وإنما سنضيف منطق هذه الأزرار في الجزء الأخير من الكتاب، انظر الصورة التالية:

chkpt2.png

عرض تدوينة واحدة

بعد أن عرضنا قائمة بكل التدوينات في الصفحة الرئيسية، نحتاج إلى عرض كل تدوينة على حدى في صفحة منفصلة وذلك لمن يريد قراءة التدوينة، وهنا سننشئ المكون BlogComponent لعرض كل تدوينة معتمدين على مكتبات خطوط وأيقونات لتنسيق المكون.

إضافة مكتبة الخط Awesome

سنضيف مكتبات الخط Awesome وأيقونات السمة Material إلى التطبيق، ونستخدم تجميعات الأيقونات التي توفرها تلك المكتبة في تطبيقنا، فأضف الأسطر التالية في الملف index.html:

<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
<link rel="stylesheet" type="text/css"
 href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/fontawesome.min.css" />

قراءة التدوينة

شغِّل الأمر التالي لإنشاء مكون تدوينة، من أجل إضافة خاصية قراءة التدوينات للتطبيق:

ng g c components/blog

أضف رابط الموجِّه router لهذا المكون في ملف app.module.ts كما يلي:

{ path: 'blog/:id/:slug', component: BlogComponent },

لقد عرَّفنا معامليْن في هذا الاتجاه، الأول هو id، وهو المعرِّف الفريد لكل تدوينة، أما الآخر فهو الاسم اللطيف slug الذي يُنشأ من عنوان التدوينة نفسها.

أضف تعريف التابع التالي في ملف blog.services.ts، سيجلب هذا التابع تفاصيل التدوينة بناءً على المعرِّف الموجود في تجميعات blogs:

getPostbyId(id: string): Observable<Post> {
 const blogDetails = this.db.doc<Post>('blogs/' + id).valueChanges();
 return blogDetails;
}

افتح الملف blog.component.ts واستورد التعريفات كما هو موضح أدناه:

import { OnDestroy } from '@angular/core';
import { Post } from 'src/app/models/post';
import { ActivatedRoute } from '@angular/router';
import { BlogService } from 'src/app/services/blog.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

سنحدِّث الصنف BlogComponent كما يلي:

export class BlogComponent implements OnInit, OnDestroy {

 postData: Post = new Post();
 postId;
 private unsubscribe$ = new Subject<void>();

 constructor(private route: ActivatedRoute,
  private blogService: BlogService) {
  if (this.route.snapshot.params['id']) {
   this.postId = this.route.snapshot.paramMap.get('id');
  }
 }

ngOnInit() {
 this.blogService.getPostbyId(this.postId)
  .pipe(takeUntil(this.unsubscribe$))
  .subscribe(
   (result: Post) => {
    this.postData = result;
   }
  );
}

 ngOnDestroy() {
  this.unsubscribe$.next();
  this.unsubscribe$.complete();
 }
}

داخل تابع الخطاف ngOnInit، سنستدعي التابع getPostbyID الخاص بـ BlogService، ونمرر postID كمعامِل لجلب تفاصيل تدوينة ما، وسنحصل على المعرِّف من الرابط في منشئ الصنف، أما داخل التابع ngOnDestroy فسنكمل اشتراك unsubscribe$‎.

افتح الملف blog.component.html واستبدل الشيفرة التالية بالموجودة فيه:

<div class="docs-example-viewer-wrapper">
 <h1 class="entry-title">{{postData.title}}</h1>
 <mat-card-subtitle class="blog-info">
  <i class="fa fa-calendar" aria-hidden="true"></i>
{{postData.createdDate | date:'longDate'}}
 </mat-card-subtitle>
 <mat-divider></mat-divider>
 <div class="docs-example-viewer-body">
  <div [innerHTML]="postData.content">
  </div>
 </div>
</div>

يُستخدم هذا المكون لعرض التدوينة كاملة، وسنعرض تاريخ إنشاء التدوينة وأيقونة التقويم calendar باستخدام الخط awesome، وسنربط محتوى التدوينة بالخاصية innerHTML للعنصر <div> بما أن محتوى التدوينة يخزَّن ويُجلب بتنسيق HTML، وذلك لضمان أن التدوينة ستُعرض كنص عادي على الشاشة.

أخيرًا، سنضيف التنسيق إلى BlogComponent، فافتح الملف blog.component.scss وضع تعريفات التنسيق التالية فيه:

.docs-example-viewer-body {
 padding: 20px;
 align-content: center;
 align-items: center;
 font-size: 14px;
}

.docs-example-viewer-wrapper {
 border: 1px solid rgba(0, 0, 0, .03);
 box-shadow: 0 1px 1px rgba(0, 0, 0, .24), 0 0 2px rgba(0, 0, 0, .12);
 border-radius: 4px;
 margin: 1em auto;
 background-color: #FFFFFF;
}

.entry-title {
 margin: 20px;
}

.blog-info {
 margin: 15px 20px 10px;
 align-content: center;
 align-items: center;
 .fa-user {
 margin-left: 10px;
 }
}

تجربة عرض التدوينة

افتح المتصفح واضغط على زر Read More في بطاقة التدوينة لتنتقل إلى صفحة جديدة تعرض التدوينة كاملة، ويكون تاريخ إنشاؤها معروضًا تحت عنوانها مباشرة. كذلك، إذا نظرنا إلى رابط الصفحة سنجد أنه يحتوي على معرِّف التدوينة وعنوانها في صورة اسم لطيف slug، انظر الصورة:

firstpost.png

خاتمة

تعلمنا في هذا الجزء من مشروع إنشاء مدونة في Angular وقاعدة بيانات Firsstore كيفية إضافة محرر للمدونة ثم إضافة تدوينات جديدة وعرضها وعرض كل التدوينات في الواجهة الرئيسية ثم عرض كل تدوينة على حدة، أما في الجزء الثاني فسنتعلم كيفية التعديل على التدوينات وحذفها.

ترجمة -وبتصرف- لفصول من كتاب Build a full stack web application using angular and firebase لصاحبه Ankit Sharma.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...