Athena β€” mahmoud-consultancy/archive/old-docs/implementation/FRONTEND_IMPLEMENTATION_GUIDE.md


title: Frontend Implementation Guide - Knowledge Base date: 2025-10-15 status: Planning tags: [frontend, angular, components, knowledge-base, sprint5]

Frontend Implementation Guide - Knowledge Base

Purpose: Complete component specifications, code examples, and implementation steps for Sprint 5 Knowledge Base frontend development.

Timeline: Week 2 of Sprint 5 (Nov 4-8, 2025)


πŸ“‹ Table of Contents

  1. Technology Stack
  2. Project Structure
  3. Models & Services
  4. Public Components
  5. Admin Components
  6. Routing Configuration
  7. Styling Guidelines
  8. Implementation Checklist

πŸ› οΈ Technology Stack

Core Technologies

  • Angular: 18+ (Standalone Components)
  • TypeScript: 5+
  • SCSS: Component-scoped styles
  • RxJS: Reactive state management

Third-Party Libraries

  • ngx-markdown: Markdown rendering (install: npm install ngx-markdown marked)
  • ngx-markdown-editor: Admin markdown editor (install: npm install ngx-markdown-editor)
  • @fortawesome/angular-fontawesome: Icons (already installed)

Install Dependencies

cd frontend/recruitment-portal
npm install ngx-markdown marked ngx-markdown-editor --save

πŸ“ Project Structure

frontend/recruitment-portal/src/app/
β”œβ”€β”€ models/
β”‚   β”œβ”€β”€ article.model.ts              # Article interface
β”‚   β”œβ”€β”€ article-category.enum.ts      # Category enum
β”‚   └── article-rating.model.ts       # Rating interface
β”œβ”€β”€ services/
β”‚   β”œβ”€β”€ kb.service.ts                 # Knowledge Base API service
β”‚   └── kb-analytics.service.ts       # Analytics tracking
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ kb/                           # Public KB components
β”‚   β”‚   β”œβ”€β”€ kb-home/
β”‚   β”‚   β”œβ”€β”€ article-list/
β”‚   β”‚   β”œβ”€β”€ article-detail/
β”‚   β”‚   β”œβ”€β”€ search-results/
β”‚   β”‚   └── widgets/
β”‚   β”‚       β”œβ”€β”€ rating-widget/
β”‚   β”‚       β”œβ”€β”€ bookmark-button/
β”‚   β”‚       β”œβ”€β”€ share-buttons/
β”‚   β”‚       └── related-articles/
β”‚   └── admin/
β”‚       └── kb/                       # Admin KB components
β”‚           β”œβ”€β”€ kb-list/
β”‚           β”œβ”€β”€ kb-editor/
β”‚           └── kb-analytics/

🎯 Models & Services

1. Article Model

File: frontend/recruitment-portal/src/app/models/article.model.ts

export interface Article {
  id: number;
  title: string;
  slug: string;
  summary: string;
  content: string; // Markdown
  category: ArticleCategory;
  author: string;
  publishedAt: Date;
  updatedAt: Date;
  published: boolean;
  viewCount: number;
  avgRating: number;
  ratingCount: number;
  readingTime: number; // minutes
  tags: string[];
  metaDescription: string;
  featuredImage?: string;
  relatedArticles?: Article[];
}

export interface ArticleRating {
  id: number;
  articleId: number;
  userId: number;
  rating: number; // 1-5
  createdAt: Date;
}

export interface ArticleBookmark {
  id: number;
  articleId: number;
  userId: number;
  createdAt: Date;
}

export interface ArticleSearchResult {
  articles: Article[];
  totalElements: number;
  totalPages: number;
  currentPage: number;
}

2. Article Category Enum

File: frontend/recruitment-portal/src/app/models/article-category.enum.ts

export enum ArticleCategory {
  DETACHERING_INFO = 'DETACHERING_INFO',
  BUREAU_COMPARISON = 'BUREAU_COMPARISON',
  CV_TIPS = 'CV_TIPS',
  MARKET_INFO = 'MARKET_INFO',
  FAQ = 'FAQ',
  SUCCESS_STORIES = 'SUCCESS_STORIES'
}

export const CATEGORY_LABELS: Record<ArticleCategory, string> = {
  [ArticleCategory.DETACHERING_INFO]: 'Over Detachering & Interim',
  [ArticleCategory.BUREAU_COMPARISON]: 'InterimPlaza vs Andere Bureaus',
  [ArticleCategory.CV_TIPS]: 'CV & Sollicitatietips',
  [ArticleCategory.MARKET_INFO]: 'Marktinformatie',
  [ArticleCategory.FAQ]: 'Veelgestelde Vragen',
  [ArticleCategory.SUCCESS_STORIES]: 'Succesverhalen'
};

export const CATEGORY_ICONS: Record<ArticleCategory, string> = {
  [ArticleCategory.DETACHERING_INFO]: 'info-circle',
  [ArticleCategory.BUREAU_COMPARISON]: 'balance-scale',
  [ArticleCategory.CV_TIPS]: 'file-alt',
  [ArticleCategory.MARKET_INFO]: 'chart-line',
  [ArticleCategory.FAQ]: 'question-circle',
  [ArticleCategory.SUCCESS_STORIES]: 'trophy'
};

3. Knowledge Base Service

File: frontend/recruitment-portal/src/app/services/kb.service.ts

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { Article, ArticleSearchResult, ArticleRating, ArticleBookmark } from '../models/article.model';
import { ArticleCategory } from '../models/article-category.enum';

@Injectable({
  providedIn: 'root'
})
export class KbService {
  private http = inject(HttpClient);
  private apiUrl = `${environment.apiUrl}/api/kb`;
  private adminApiUrl = `${environment.apiUrl}/api/admin/kb`;

  // Public APIs

  getArticles(page: number = 0, size: number = 10, category?: ArticleCategory): Observable<ArticleSearchResult> {
    let params = new HttpParams()
      .set('page', page.toString())
      .set('size', size.toString());

    if (category) {
      params = params.set('category', category);
    }

    return this.http.get<ArticleSearchResult>(`${this.apiUrl}/articles`, { params });
  }

  getArticleBySlug(slug: string): Observable<Article> {
    return this.http.get<Article>(`${this.apiUrl}/articles/${slug}`);
  }

  getArticlesByCategory(category: ArticleCategory, page: number = 0, size: number = 10): Observable<ArticleSearchResult> {
    const params = new HttpParams()
      .set('page', page.toString())
      .set('size', size.toString());

    return this.http.get<ArticleSearchResult>(`${this.apiUrl}/articles/category/${category}`, { params });
  }

  searchArticles(query: string, page: number = 0, size: number = 10): Observable<ArticleSearchResult> {
    const params = new HttpParams()
      .set('q', query)
      .set('page', page.toString())
      .set('size', size.toString());

    return this.http.get<ArticleSearchResult>(`${this.apiUrl}/articles/search`, { params });
  }

  getCategories(): Observable<{ category: ArticleCategory; count: number }[]> {
    return this.http.get<{ category: ArticleCategory; count: number }[]>(`${this.apiUrl}/categories`);
  }

  trackView(articleId: number): Observable<void> {
    return this.http.post<void>(`${this.apiUrl}/articles/${articleId}/view`, {});
  }

  // Authenticated APIs

  rateArticle(articleId: number, rating: number): Observable<ArticleRating> {
    return this.http.post<ArticleRating>(`${this.apiUrl}/articles/${articleId}/rate`, { rating });
  }

  bookmarkArticle(articleId: number): Observable<ArticleBookmark> {
    return this.http.post<ArticleBookmark>(`${this.apiUrl}/articles/${articleId}/bookmark`, {});
  }

  getBookmarks(): Observable<ArticleBookmark[]> {
    return this.http.get<ArticleBookmark[]>(`${this.apiUrl}/bookmarks`);
  }

  removeBookmark(bookmarkId: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/bookmarks/${bookmarkId}`);
  }

  // Admin APIs

  createArticle(article: Partial<Article>): Observable<Article> {
    return this.http.post<Article>(`${this.adminApiUrl}/articles`, article);
  }

  updateArticle(id: number, article: Partial<Article>): Observable<Article> {
    return this.http.put<Article>(`${this.adminApiUrl}/articles/${id}`, article);
  }

  deleteArticle(id: number): Observable<void> {
    return this.http.delete<void>(`${this.adminApiUrl}/articles/${id}`);
  }

  publishArticle(id: number): Observable<Article> {
    return this.http.post<Article>(`${this.adminApiUrl}/articles/${id}/publish`, {});
  }

  getAnalytics(): Observable<any> {
    return this.http.get<any>(`${this.adminApiUrl}/analytics`);
  }
}

🌐 Public Components

1. KB Home Component

File: frontend/recruitment-portal/src/app/components/kb/kb-home/kb-home.component.ts

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { KbService } from '../../../services/kb.service';
import { Article } from '../../../models/article.model';
import { ArticleCategory, CATEGORY_LABELS, CATEGORY_ICONS } from '../../../models/article-category.enum';

@Component({
  selector: 'app-kb-home',
  standalone: true,
  imports: [CommonModule, RouterModule, FormsModule],
  templateUrl: './kb-home.component.html',
  styleUrls: ['./kb-home.component.scss']
})
export class KbHomeComponent implements OnInit {
  private kbService = inject(KbService);
  private router = inject(Router);

  categories: { category: ArticleCategory; label: string; icon: string; count: number }[] = [];
  featuredArticles: Article[] = [];
  popularArticles: Article[] = [];
  searchQuery: string = '';
  loading: boolean = true;
  error: string | null = null;

  ngOnInit(): void {
    this.loadCategories();
    this.loadFeaturedArticles();
    this.loadPopularArticles();
  }

  loadCategories(): void {
    this.kbService.getCategories().subscribe({
      next: (data) => {
        this.categories = data.map(item => ({
          category: item.category,
          label: CATEGORY_LABELS[item.category],
          icon: CATEGORY_ICONS[item.category],
          count: item.count
        }));
      },
      error: (err) => {
        console.error('Failed to load categories', err);
        this.error = 'Kon categorieΓ«n niet laden.';
      }
    });
  }

  loadFeaturedArticles(): void {
    this.kbService.getArticles(0, 3).subscribe({
      next: (result) => {
        this.featuredArticles = result.articles;
      },
      error: (err) => {
        console.error('Failed to load featured articles', err);
      }
    });
  }

  loadPopularArticles(): void {
    this.kbService.getArticles(0, 5).subscribe({
      next: (result) => {
        this.popularArticles = result.articles;
        this.loading = false;
      },
      error: (err) => {
        console.error('Failed to load popular articles', err);
        this.loading = false;
      }
    });
  }

  onSearch(): void {
    if (this.searchQuery.trim()) {
      this.router.navigate(['/kennisbank/zoeken'], { queryParams: { q: this.searchQuery } });
    }
  }

  navigateToCategory(category: ArticleCategory): void {
    this.router.navigate(['/kennisbank/categorie', category.toLowerCase()]);
  }
}

Template: kb-home.component.html

<div class="kb-home">
  <div class="kb-header">
    <h1>Kennisbank</h1>
    <p class="subtitle">Alles wat je moet weten over interim, detachering, en carrière in IT</p>

    <div class="search-bar">
      <input
        type="text"
        [(ngModel)]="searchQuery"
        (keyup.enter)="onSearch()"
        placeholder="Zoek artikelen..."
        class="search-input">
      <button (click)="onSearch()" class="btn btn-primary">
        <i class="fas fa-search"></i> Zoeken
      </button>
    </div>
  </div>

  <div class="categories-grid" *ngIf="!loading">
    <div
      *ngFor="let cat of categories"
      class="category-card"
      (click)="navigateToCategory(cat.category)">
      <i class="fas fa-{{cat.icon}}"></i>
      <h3>{{cat.label}}</h3>
      <p class="article-count">{{cat.count}} artikelen</p>
    </div>
  </div>

  <div class="featured-articles" *ngIf="featuredArticles.length > 0">
    <h2>Uitgelichte Artikelen</h2>
    <div class="articles-grid">
      <div *ngFor="let article of featuredArticles" class="article-card">
        <img *ngIf="article.featuredImage" [src]="article.featuredImage" [alt]="article.title">
        <div class="article-content">
          <h3>
            <a [routerLink]="['/kennisbank', article.slug]">{{article.title}}</a>
          </h3>
          <p class="summary">{{article.summary}}</p>
          <div class="article-meta">
            <span class="reading-time">
              <i class="fas fa-clock"></i> {{article.readingTime}} min
            </span>
            <span class="views">
              <i class="fas fa-eye"></i> {{article.viewCount}}
            </span>
            <span class="rating" *ngIf="article.avgRating > 0">
              <i class="fas fa-star"></i> {{article.avgRating | number:'1.1-1'}}
            </span>
          </div>
        </div>
      </div>
    </div>
  </div>

  <div class="popular-articles" *ngIf="popularArticles.length > 0">
    <h2>Populaire Artikelen</h2>
    <ul class="article-list">
      <li *ngFor="let article of popularArticles">
        <a [routerLink]="['/kennisbank', article.slug]">
          <h4>{{article.title}}</h4>
          <p>{{article.summary}}</p>
        </a>
      </li>
    </ul>
  </div>

  <div class="loading-spinner" *ngIf="loading">
    <i class="fas fa-spinner fa-spin"></i> Laden...
  </div>

  <div class="error-message" *ngIf="error">
    <i class="fas fa-exclamation-circle"></i> {{error}}
  </div>
</div>

Styles: kb-home.component.scss

@import '../../../../styles/shared/buttons';
@import '../../../../styles/shared/cards';

.kb-home {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;

  .kb-header {
    text-align: center;
    margin-bottom: 3rem;

    h1 {
      font-size: 2.5rem;
      color: #2c3e50;
      margin-bottom: 0.5rem;
    }

    .subtitle {
      font-size: 1.125rem;
      color: #7f8c8d;
      margin-bottom: 2rem;
    }

    .search-bar {
      display: flex;
      gap: 1rem;
      max-width: 600px;
      margin: 0 auto;

      .search-input {
        flex: 1;
        padding: 0.75rem 1rem;
        border: 2px solid #ddd;
        border-radius: 8px;
        font-size: 1rem;

        &:focus {
          outline: none;
          border-color: #0d6efd;
        }
      }
    }
  }

  .categories-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: 1.5rem;
    margin-bottom: 3rem;

    .category-card {
      background: white;
      padding: 2rem;
      border-radius: 12px;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      text-align: center;
      cursor: pointer;
      transition: all 0.3s ease;

      &:hover {
        transform: translateY(-5px);
        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
      }

      i {
        font-size: 3rem;
        color: #0d6efd;
        margin-bottom: 1rem;
      }

      h3 {
        font-size: 1.25rem;
        margin-bottom: 0.5rem;
        color: #2c3e50;
      }

      .article-count {
        color: #7f8c8d;
        font-size: 0.875rem;
      }
    }
  }

  .featured-articles {
    margin-bottom: 3rem;

    h2 {
      font-size: 2rem;
      margin-bottom: 1.5rem;
      color: #2c3e50;
    }

    .articles-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
      gap: 2rem;

      .article-card {
        background: white;
        border-radius: 12px;
        overflow: hidden;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        transition: transform 0.3s ease;

        &:hover {
          transform: translateY(-5px);
        }

        img {
          width: 100%;
          height: 200px;
          object-fit: cover;
        }

        .article-content {
          padding: 1.5rem;

          h3 {
            margin-bottom: 0.75rem;

            a {
              color: #2c3e50;
              text-decoration: none;

              &:hover {
                color: #0d6efd;
              }
            }
          }

          .summary {
            color: #7f8c8d;
            margin-bottom: 1rem;
            line-height: 1.6;
          }

          .article-meta {
            display: flex;
            gap: 1rem;
            font-size: 0.875rem;
            color: #95a5a6;

            span {
              display: flex;
              align-items: center;
              gap: 0.25rem;

              i {
                color: #bdc3c7;
              }
            }

            .rating {
              color: #f39c12;

              i {
                color: #f39c12;
              }
            }
          }
        }
      }
    }
  }

  .popular-articles {
    h2 {
      font-size: 2rem;
      margin-bottom: 1.5rem;
      color: #2c3e50;
    }

    .article-list {
      list-style: none;
      padding: 0;

      li {
        background: white;
        margin-bottom: 1rem;
        border-radius: 8px;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);

        a {
          display: block;
          padding: 1.5rem;
          text-decoration: none;
          color: inherit;
          transition: background 0.2s ease;

          &:hover {
            background: #f8f9fa;
          }

          h4 {
            color: #2c3e50;
            margin-bottom: 0.5rem;
          }

          p {
            color: #7f8c8d;
            font-size: 0.875rem;
            margin: 0;
          }
        }
      }
    }
  }

  .loading-spinner,
  .error-message {
    text-align: center;
    padding: 2rem;
    font-size: 1.125rem;
  }

  .error-message {
    color: #e74c3c;
  }
}

@media (max-width: 768px) {
  .kb-home {
    padding: 1rem;

    .kb-header {
      h1 {
        font-size: 2rem;
      }

      .search-bar {
        flex-direction: column;
      }
    }

    .categories-grid {
      grid-template-columns: 1fr;
    }

    .featured-articles .articles-grid {
      grid-template-columns: 1fr;
    }
  }
}

2. Article Detail Component

File: frontend/recruitment-portal/src/app/components/kb/article-detail/article-detail.component.ts

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MarkdownModule } from 'ngx-markdown';
import { KbService } from '../../../services/kb.service';
import { AuthService } from '../../../services/auth.service';
import { Article } from '../../../models/article.model';
import { RatingWidgetComponent } from '../widgets/rating-widget/rating-widget.component';
import { BookmarkButtonComponent } from '../widgets/bookmark-button/bookmark-button.component';
import { ShareButtonsComponent } from '../widgets/share-buttons/share-buttons.component';
import { RelatedArticlesComponent } from '../widgets/related-articles/related-articles.component';

@Component({
  selector: 'app-article-detail',
  standalone: true,
  imports: [
    CommonModule,
    RouterModule,
    MarkdownModule,
    RatingWidgetComponent,
    BookmarkButtonComponent,
    ShareButtonsComponent,
    RelatedArticlesComponent
  ],
  templateUrl: './article-detail.component.html',
  styleUrls: ['./article-detail.component.scss']
})
export class ArticleDetailComponent implements OnInit {
  private route = inject(ActivatedRoute);
  private kbService = inject(KbService);
  private authService = inject(AuthService);

  article: Article | null = null;
  loading: boolean = true;
  error: string | null = null;
  isAuthenticated: boolean = false;

  ngOnInit(): void {
    this.isAuthenticated = this.authService.isAuthenticated();

    this.route.paramMap.subscribe(params => {
      const slug = params.get('slug');
      if (slug) {
        this.loadArticle(slug);
      }
    });
  }

  loadArticle(slug: string): void {
    this.loading = true;
    this.error = null;

    this.kbService.getArticleBySlug(slug).subscribe({
      next: (article) => {
        this.article = article;
        this.loading = false;

        // Track view
        this.kbService.trackView(article.id).subscribe();

        // Update page title
        document.title = `${article.title} - InterimPlaza Kennisbank`;
      },
      error: (err) => {
        console.error('Failed to load article', err);
        this.error = 'Artikel niet gevonden.';
        this.loading = false;
      }
    });
  }

  onRated(rating: number): void {
    if (!this.article || !this.isAuthenticated) return;

    this.kbService.rateArticle(this.article.id, rating).subscribe({
      next: () => {
        console.log('Article rated successfully');
        // Reload article to get updated rating
        if (this.article) {
          this.loadArticle(this.article.slug);
        }
      },
      error: (err) => {
        console.error('Failed to rate article', err);
      }
    });
  }

  onBookmarked(): void {
    if (!this.article || !this.isAuthenticated) return;

    this.kbService.bookmarkArticle(this.article.id).subscribe({
      next: () => {
        console.log('Article bookmarked successfully');
      },
      error: (err) => {
        console.error('Failed to bookmark article', err);
      }
    });
  }
}

Template: article-detail.component.html

<div class="article-detail" *ngIf="!loading && article">
  <div class="article-header">
    <div class="breadcrumb">
      <a routerLink="/kennisbank">Kennisbank</a> /
      <span>{{article.category}}</span> /
      <span>{{article.title}}</span>
    </div>

    <h1>{{article.title}}</h1>

    <div class="article-meta">
      <span class="author">
        <i class="fas fa-user"></i> {{article.author}}
      </span>
      <span class="date">
        <i class="fas fa-calendar"></i> {{article.publishedAt | date:'dd MMMM yyyy'}}
      </span>
      <span class="reading-time">
        <i class="fas fa-clock"></i> {{article.readingTime}} min leestijd
      </span>
      <span class="views">
        <i class="fas fa-eye"></i> {{article.viewCount}} weergaven
      </span>
    </div>

    <img *ngIf="article.featuredImage"
         [src]="article.featuredImage"
         [alt]="article.title"
         class="featured-image">
  </div>

  <div class="article-content">
    <div class="content-main">
      <markdown [data]="article.content"></markdown>

      <div class="article-footer">
        <div class="tags" *ngIf="article.tags && article.tags.length > 0">
          <span class="tag" *ngFor="let tag of article.tags">
            <i class="fas fa-tag"></i> {{tag}}
          </span>
        </div>

        <div class="interactions">
          <app-rating-widget
            *ngIf="isAuthenticated"
            [articleId]="article.id"
            [currentRating]="article.avgRating"
            (rated)="onRated($event)">
          </app-rating-widget>

          <app-bookmark-button
            *ngIf="isAuthenticated"
            [articleId]="article.id"
            (bookmarked)="onBookmarked()">
          </app-bookmark-button>

          <app-share-buttons
            [url]="'https://interimplaza.nl/kennisbank/' + article.slug"
            [title]="article.title">
          </app-share-buttons>
        </div>
      </div>
    </div>

    <div class="content-sidebar">
      <app-related-articles
        *ngIf="article.relatedArticles && article.relatedArticles.length > 0"
        [articles]="article.relatedArticles">
      </app-related-articles>
    </div>
  </div>
</div>

<div class="loading-spinner" *ngIf="loading">
  <i class="fas fa-spinner fa-spin"></i> Laden...
</div>

<div class="error-message" *ngIf="error">
  <i class="fas fa-exclamation-circle"></i> {{error}}
  <a routerLink="/kennisbank" class="btn btn-primary">Terug naar Kennisbank</a>
</div>

3. Rating Widget Component

File: frontend/recruitment-portal/src/app/components/kb/widgets/rating-widget/rating-widget.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-rating-widget',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="rating-widget">
      <div class="stars">
        <i *ngFor="let star of [1,2,3,4,5]"
           class="fas fa-star"
           [class.filled]="star <= selectedRating"
           [class.hover]="star <= hoverRating"
           (mouseenter)="hoverRating = star"
           (mouseleave)="hoverRating = 0"
           (click)="rate(star)">
        </i>
      </div>
      <span class="rating-text" *ngIf="currentRating > 0">
        {{currentRating | number:'1.1-1'}} / 5.0
      </span>
    </div>
  `,
  styles: [`
    .rating-widget {
      display: flex;
      align-items: center;
      gap: 0.5rem;

      .stars {
        display: flex;
        gap: 0.25rem;

        i {
          font-size: 1.5rem;
          color: #ddd;
          cursor: pointer;
          transition: color 0.2s ease;

          &.filled, &.hover {
            color: #f39c12;
          }
        }
      }

      .rating-text {
        font-size: 0.875rem;
        color: #7f8c8d;
      }
    }
  `]
})
export class RatingWidgetComponent {
  @Input() articleId!: number;
  @Input() currentRating: number = 0;
  @Output() rated = new EventEmitter<number>();

  selectedRating: number = 0;
  hoverRating: number = 0;

  rate(rating: number): void {
    this.selectedRating = rating;
    this.rated.emit(rating);
  }
}

πŸ‘¨β€πŸ’Ό Admin Components

1. Admin KB Editor Component

File: frontend/recruitment-portal/src/app/components/admin/kb/kb-editor/kb-editor.component.ts

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MarkdownModule } from 'ngx-markdown';
import { KbService } from '../../../../services/kb.service';
import { Article } from '../../../../models/article.model';
import { ArticleCategory, CATEGORY_LABELS } from '../../../../models/article-category.enum';

@Component({
  selector: 'app-kb-editor',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, MarkdownModule],
  templateUrl: './kb-editor.component.html',
  styleUrls: ['./kb-editor.component.scss']
})
export class KbEditorComponent implements OnInit {
  private fb = inject(FormBuilder);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private kbService = inject(KbService);

  articleForm: FormGroup;
  categories = Object.values(ArticleCategory);
  categoryLabels = CATEGORY_LABELS;
  editMode: boolean = false;
  articleId: number | null = null;
  previewMode: boolean = false;
  saving: boolean = false;

  constructor() {
    this.articleForm = this.fb.group({
      title: ['', [Validators.required, Validators.maxLength(200)]],
      summary: ['', [Validators.required, Validators.maxLength(500)]],
      content: ['', Validators.required],
      category: ['', Validators.required],
      tags: [''],
      metaDescription: ['', Validators.maxLength(160)],
      featuredImage: [''],
      published: [false]
    });
  }

  ngOnInit(): void {
    this.route.paramMap.subscribe(params => {
      const id = params.get('id');
      if (id) {
        this.editMode = true;
        this.articleId = parseInt(id, 10);
        this.loadArticle(this.articleId);
      }
    });
  }

  loadArticle(id: number): void {
    // Load article for editing
    // Implementation depends on admin API
  }

  onSubmit(): void {
    if (this.articleForm.invalid) return;

    this.saving = true;
    const formValue = this.articleForm.value;

    // Convert tags string to array
    const tags = formValue.tags
      ? formValue.tags.split(',').map((t: string) => t.trim()).filter((t: string) => t)
      : [];

    const articleData = {
      ...formValue,
      tags
    };

    const operation = this.editMode && this.articleId
      ? this.kbService.updateArticle(this.articleId, articleData)
      : this.kbService.createArticle(articleData);

    operation.subscribe({
      next: (article) => {
        console.log('Article saved successfully');
        this.saving = false;
        this.router.navigate(['/admin/kennisbank']);
      },
      error: (err) => {
        console.error('Failed to save article', err);
        this.saving = false;
      }
    });
  }

  togglePreview(): void {
    this.previewMode = !this.previewMode;
  }

  get contentValue(): string {
    return this.articleForm.get('content')?.value || '';
  }
}

πŸ›£οΈ Routing Configuration

File: frontend/recruitment-portal/src/app/app.routes.ts

Add these routes:

import { Routes } from '@angular/router';
import { KbHomeComponent } from './components/kb/kb-home/kb-home.component';
import { ArticleListComponent } from './components/kb/article-list/article-list.component';
import { ArticleDetailComponent } from './components/kb/article-detail/article-detail.component';
import { SearchResultsComponent } from './components/kb/search-results/search-results.component';
import { KbEditorComponent } from './components/admin/kb/kb-editor/kb-editor.component';
import { KbListComponent } from './components/admin/kb/kb-list/kb-list.component';
import { authGuard } from './guards/auth.guard';
import { adminGuard } from './guards/admin.guard';

export const routes: Routes = [
  // ... existing routes

  // Public KB routes
  {
    path: 'kennisbank',
    component: KbHomeComponent
  },
  {
    path: 'kennisbank/categorie/:category',
    component: ArticleListComponent
  },
  {
    path: 'kennisbank/zoeken',
    component: SearchResultsComponent
  },
  {
    path: 'kennisbank/:slug',
    component: ArticleDetailComponent
  },

  // Admin KB routes
  {
    path: 'admin/kennisbank',
    component: KbListComponent,
    canActivate: [authGuard, adminGuard]
  },
  {
    path: 'admin/kennisbank/nieuw',
    component: KbEditorComponent,
    canActivate: [authGuard, adminGuard]
  },
  {
    path: 'admin/kennisbank/bewerken/:id',
    component: KbEditorComponent,
    canActivate: [authGuard, adminGuard]
  }
];

🎨 Styling Guidelines

1. Use Shared Styles

Always import shared stylesheets:

@import '../../../styles/shared/buttons';
@import '../../../styles/shared/forms';
@import '../../../styles/shared/cards';

2. Mobile-First Approach

// Mobile (default)
.component {
  padding: 1rem;
}

// Tablet and up
@media (min-width: 768px) {
  .component {
    padding: 2rem;
  }
}

// Desktop
@media (min-width: 1024px) {
  .component {
    padding: 3rem;
  }
}

3. Color Palette

$primary-color: #0d6efd;
$secondary-color: #6c757d;
$success-color: #28a745;
$danger-color: #dc3545;
$warning-color: #ffc107;
$info-color: #17a2b8;

$text-primary: #2c3e50;
$text-secondary: #7f8c8d;
$text-muted: #95a5a6;

$bg-light: #f8f9fa;
$bg-white: #ffffff;
$border-color: #dee2e6;

βœ… Implementation Checklist

Day 1-2: Models & Services

  • [ ] Create article.model.ts with all interfaces
  • [ ] Create article-category.enum.ts with labels and icons
  • [ ] Create kb.service.ts with all API methods
  • [ ] Install ngx-markdown and marked packages
  • [ ] Configure markdown module in app.config.ts

Day 3-4: Public Components

  • [ ] Create KB Home component with search and categories
  • [ ] Create Article List component with filtering
  • [ ] Create Article Detail component with markdown rendering
  • [ ] Create Search Results component
  • [ ] Create Rating Widget component
  • [ ] Create Bookmark Button component
  • [ ] Create Share Buttons component
  • [ ] Create Related Articles component

Day 5: Admin Components

  • [ ] Create Admin KB List component
  • [ ] Create Admin KB Editor component with markdown editor
  • [ ] Create Admin KB Analytics component
  • [ ] Add KB routes to app.routes.ts
  • [ ] Add KB link to main navigation

Testing

  • [ ] Test all public routes (home, list, detail, search)
  • [ ] Test admin routes (list, create, edit)
  • [ ] Test authentication flows (login required for rating/bookmark)
  • [ ] Test mobile responsiveness (320px to 1920px)
  • [ ] Test markdown rendering (headers, lists, code blocks, images)
  • [ ] Test search functionality
  • [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)

Last Updated: 2025-10-15 Status: Ready for Implementation Estimated Time: 20 hours (Week 2 of Sprint 5)

Reacties

Nog geen reacties