AI Implementation feature(869): Admin Area Blog and Blog Page (#58)

This commit was merged in pull request #58.
This commit is contained in:
2026-07-23 10:17:55 +00:00
parent d468cca766
commit 294873240b
32 changed files with 1798 additions and 117 deletions
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MarkdownService } from '../../core/services/markdown.service';
import { PublicBlogPost } from '../../core/services/blog.service';
@Component({
selector: 'app-blog-presenter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.blog-item { display: block; margin: 0 0 16px 0; }
.blog-title { margin: 0 0 4px 0; font-size: 18px; }
.blog-date { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 8px; }
.blog-body { font-size: 14px; }
`],
template: `
<article class="blog-item" [attr.data-testid]="'blog-presenter-' + post().id">
<h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id">
{{ post().title }}
</h2>
<time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id">
{{ post().publishedAt | date: 'medium' }}
</time>
<div
class="blog-body"
[attr.data-testid]="'blog-presenter-body-' + post().id"
[innerHTML]="renderedHtml()"
></div>
</article>
`,
})
export class BlogPresenterComponent {
readonly post = input.required<PublicBlogPost>();
private readonly markdown = new MarkdownService();
renderedHtml(): string {
return this.markdown.render(this.post()?.bodyMd ?? '');
}
}
+50 -4
View File
@@ -1,14 +1,60 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BlogApiService, PublicBlogPost } from '../../core/services/blog.service';
import { BlogPresenterComponent } from './blog-presenter.component';
import { deriveBlogListState } from './blog.pure';
@Component({
selector: 'app-blog-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, BlogPresenterComponent],
styles: [`
.blog-page { display: flex; flex-direction: column; gap: 12px; }
.blog-loading, .blog-error { padding: 8px; }
.blog-error { color: var(--color-danger, #f00); }
.blog-empty { opacity: 0.7; }
.blog-list { display: flex; flex-direction: column; }
`],
template: `
<section class="page-blog">
<section class="blog-page" data-testid="blog-page">
<h2>Blog</h2>
<p>Blog posts will appear here.</p>
@if (state().kind === 'loading') {
<p class="blog-loading" data-testid="blog-loading">Loading blog posts…</p>
} @else if (state().kind === 'error') {
<p class="blog-error" data-testid="blog-error">{{ state().message }}</p>
} @else if (state().kind === 'empty') {
<p class="blog-empty" data-testid="blog-empty">No announcements yet.</p>
} @else {
<div class="blog-list" data-testid="blog-list">
@for (post of posts(); track post.id) {
<app-blog-presenter [post]="post" />
}
</div>
}
</section>
`,
})
export class BlogPage {}
export class BlogPage implements OnInit {
private readonly blog = inject(BlogApiService);
readonly posts = signal<PublicBlogPost[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly state = computed(() =>
deriveBlogListState({ loading: this.loading(), error: this.error(), count: this.posts().length }),
);
async ngOnInit(): Promise<void> {
try {
const list = await this.blog.listPublished();
this.posts.set(list);
} catch (e: any) {
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load blog posts');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,17 @@
export type BlogListKind = 'loading' | 'error' | 'empty' | 'list';
export interface BlogListState {
kind: BlogListKind;
message?: string;
}
export function deriveBlogListState(input: {
loading: boolean;
error: string | null;
count: number;
}): BlogListState {
if (input.loading) return { kind: 'loading' };
if (input.error) return { kind: 'error', message: input.error };
if (input.count === 0) return { kind: 'empty' };
return { kind: 'list' };
}