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
+5
View File
@@ -64,6 +64,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () =>
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
},
{
path: 'blog',
loadComponent: () =>
import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent),
},
],
},
],
@@ -0,0 +1,74 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export type BlogStatus = 'draft' | 'published';
export interface AdminBlogPost {
id: string;
title: string;
bodyMd: string;
status: BlogStatus;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
}
export interface CreatePostBody {
title: string;
bodyMd: string;
status?: BlogStatus;
}
export interface UpdatePostBody {
title?: string;
bodyMd?: string;
status?: BlogStatus;
}
export interface PublicBlogPost {
id: string;
title: string;
publishedAt: string;
bodyMd: string;
}
@Injectable({ providedIn: 'root' })
export class BlogApiService {
private readonly http = inject(HttpClient);
async listAllPosts(): Promise<AdminBlogPost[]> {
return firstValueFrom(
this.http.get<AdminBlogPost[]>('/api/v1/admin/blog/posts', { withCredentials: true }),
);
}
async createPost(body: CreatePostBody): Promise<AdminBlogPost> {
return firstValueFrom(
this.http.post<AdminBlogPost>('/api/v1/admin/blog/posts', body, { withCredentials: true }),
);
}
async updatePost(id: string, body: UpdatePostBody): Promise<AdminBlogPost> {
return firstValueFrom(
this.http.put<AdminBlogPost>(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, body, {
withCredentials: true,
}),
);
}
async deletePost(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
);
}
async listPublished(): Promise<PublicBlogPost[]> {
const res = await firstValueFrom(
this.http.get<{ posts: PublicBlogPost[] }>('/api/v1/blog/posts', { withCredentials: true }),
);
return res.posts ?? [];
}
}
@@ -13,7 +13,7 @@ const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
];
@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminBlogPost } from '../../../core/services/blog.service';
@Component({
selector: 'app-blog-delete-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
`],
template: `
@if (open()) {
<div class="modal-backdrop" data-testid="post-delete-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()" data-testid="post-delete-modal">
<h3>Delete post</h3>
@if (post(); as p) {
<p data-testid="post-delete-name">
Delete <b>{{ p.title }}</b>? This cannot be undone.
</p>
}
@if (errorMessage()) {
<p class="error" data-testid="post-delete-error">{{ errorMessage() }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="post-delete-cancel">Cancel</button>
<button type="button" [disabled]="deleting()" (click)="onConfirm()" data-testid="post-delete-ok">Delete</button>
</div>
</div>
</div>
}
`,
})
export class BlogDeleteModalComponent {
readonly open = input(false);
readonly post = input<AdminBlogPost | null>(null);
readonly errorMessage = input<string | null>(null);
readonly deleting = input(false);
readonly cancel = output<void>();
readonly confirm = output<void>();
onCancel(): void {
this.cancel.emit();
}
onConfirm(): void {
this.confirm.emit();
}
}
@@ -0,0 +1,223 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MarkdownService } from '../../../core/services/markdown.service';
import { AdminBlogPost } from '../../../core/services/blog.service';
import {
PostFormGroup,
buildPostBody,
syncPostForm,
validatePostForm,
} from './blog-form.pure';
export type PostFormMode = 'create' | 'edit';
export interface PostFormSubmitPayload {
mode: PostFormMode;
title: string;
bodyMd: string;
action: 'draft' | 'publish';
}
@Component({
selector: 'app-blog-form-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
styles: [`
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 480px; max-width: 90vw; max-height: 90vh; overflow: auto; }
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: var(--radius-sm, 4px); background: var(--color-surface, #fafafa); min-height: 200px; max-height: 320px; overflow: auto; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.field-error { color: var(--color-danger, #f00); font-size: 12px; }
.editing-meta { font-size: 12px; opacity: 0.7; }
`],
template: `
@if (open()) {
<div class="modal-backdrop" data-testid="post-form-backdrop" (click)="onCancel()">
<form
class="modal"
[formGroup]="form"
(click)="$event.stopPropagation()"
(submit)="$event.preventDefault()"
data-testid="post-form-modal"
>
<h3>{{ mode() === 'create' ? 'New post' : 'Edit post' }}</h3>
@if (post(); as p) {
@if (mode() === 'edit') {
<p class="editing-meta">
Created: {{ p.createdAt | date: 'medium' }} ·
Updated: {{ p.updatedAt | date: 'medium' }}
@if (p.publishedAt) {
· Published: {{ p.publishedAt | date: 'medium' }}
}
</p>
}
}
<div class="row">
<label for="pf-title">Title (required)</label>
<input
id="pf-title"
type="text"
formControlName="title"
data-testid="pf-title"
[attr.aria-invalid]="titleError() ? 'true' : null"
[attr.aria-describedby]="titleError() ? 'pf-title-error' : null"
/>
@if (titleError()) {
<span class="field-error" id="pf-title-error" data-testid="pf-title-error">{{ titleError() }}</span>
}
</div>
<div class="layout">
<div class="row">
<label for="pf-body">Markdown body</label>
<textarea
id="pf-body"
rows="14"
formControlName="bodyMd"
data-testid="pf-body"
(input)="onBodyInput()"
></textarea>
@if (bodyError()) {
<span class="field-error" data-testid="pf-body-error">{{ bodyError() }}</span>
}
</div>
<div class="row">
<label>Live preview</label>
<div class="preview" data-testid="pf-preview" [innerHTML]="previewHtml()"></div>
</div>
</div>
@if (errorMessage()) {
<p class="field-error" data-testid="pf-error" style="margin-top: 8px;">{{ errorMessage() }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="pf-cancel">Cancel</button>
<button
type="button"
[disabled]="!canSaveDraft()"
(click)="onSubmit('draft')"
data-testid="pf-save-draft"
>Save draft</button>
<button
type="button"
[disabled]="!canPublish()"
(click)="onSubmit('publish')"
data-testid="pf-publish"
>Publish</button>
</div>
</form>
</div>
}
`,
})
export class BlogFormModalComponent {
private readonly fb = inject(FormBuilder);
private readonly markdown = inject(MarkdownService);
private readonly destroyRef = inject(DestroyRef);
private readonly cdr = inject(ChangeDetectorRef);
readonly open = input(false);
readonly mode = input<PostFormMode>('create');
readonly post = input<AdminBlogPost | null>(null);
readonly errorMessage = input<string | null>(null);
readonly saving = input(false);
readonly cancel = output<void>();
readonly submit = output<PostFormSubmitPayload>();
readonly previewHtml = signal<string>('');
readonly titleTouched = signal(false);
readonly bodyTouched = signal(false);
readonly form: PostFormGroup = this.fb.nonNullable.group({
title: this.fb.nonNullable.control(''),
bodyMd: this.fb.nonNullable.control(''),
});
constructor() {
effect(
() => {
syncPostForm(this.form, this.mode(), this.post());
this.titleTouched.set(false);
this.bodyTouched.set(false);
this.previewHtml.set(this.markdown.render(this.form.controls.bodyMd.value ?? ''));
this.cdr.markForCheck();
},
{ allowSignalWrites: true },
);
this.form.controls.title.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.titleTouched.set(true);
});
this.form.controls.bodyMd.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.bodyTouched.set(true);
this.previewHtml.set(this.markdown.render(v ?? ''));
});
}
onBodyInput(): void {
// valueChanges already triggers the preview; this is here as an explicit
// hook in case the template wiring changes.
}
titleError(): string | null {
const v = this.form.controls.title.value ?? '';
if (!this.titleTouched() && v.length === 0) return null;
const result = validatePostForm({ title: v, bodyMd: this.form.controls.bodyMd.value ?? '' });
return result.errors.title ?? null;
}
bodyError(): string | null {
const v = this.form.controls.bodyMd.value ?? '';
if (!this.bodyTouched() && v.length === 0) return null;
const result = validatePostForm({ title: this.form.controls.title.value ?? '', bodyMd: v });
return result.errors.bodyMd ?? null;
}
canSaveDraft(): boolean {
if (this.saving()) return false;
const v = this.form.getRawValue();
const result = validatePostForm({ title: v.title ?? '', bodyMd: v.bodyMd ?? '' });
return result.ok;
}
canPublish(): boolean {
return this.canSaveDraft();
}
onCancel(): void {
this.cancel.emit();
}
onSubmit(action: 'draft' | 'publish'): void {
this.titleTouched.set(true);
this.bodyTouched.set(true);
const raw = this.form.getRawValue();
const result = validatePostForm({ title: raw.title ?? '', bodyMd: raw.bodyMd ?? '' });
if (!result.ok) return;
const cleaned = buildPostBody({ title: raw.title ?? '', bodyMd: raw.bodyMd ?? '' });
this.submit.emit({ mode: this.mode(), ...cleaned, action });
}
}
@@ -0,0 +1,58 @@
import { FormControl, FormGroup } from '@angular/forms';
import { AdminBlogPost, BlogStatus } from '../../../core/services/blog.service';
export type PostFormGroup = FormGroup<{
title: FormControl<string>;
bodyMd: FormControl<string>;
}>;
export const MAX_TITLE_LENGTH = 200;
export const MAX_BODY_LENGTH = 200_000;
export function validatePostForm(value: { title: string; bodyMd: string }): {
ok: boolean;
errors: { title?: string; bodyMd?: string };
} {
const errors: { title?: string; bodyMd?: string } = {};
const title = (value.title ?? '').trim();
if (title.length === 0) errors.title = 'Title is required';
else if (title.length > MAX_TITLE_LENGTH) errors.title = `Title must be at most ${MAX_TITLE_LENGTH} characters`;
const body = value.bodyMd ?? '';
if (body.length > MAX_BODY_LENGTH) errors.bodyMd = `Body must be at most ${MAX_BODY_LENGTH} characters`;
return { ok: Object.keys(errors).length === 0, errors };
}
export function syncPostForm(
form: PostFormGroup,
mode: 'create' | 'edit',
post: AdminBlogPost | null,
): void {
if (mode === 'edit' && post) {
form.controls.title.setValue(post.title ?? '', { emitEvent: false });
form.controls.bodyMd.setValue(post.bodyMd ?? '', { emitEvent: false });
} else {
form.controls.title.setValue('', { emitEvent: false });
form.controls.bodyMd.setValue('', { emitEvent: false });
}
form.controls.title.markAsUntouched();
form.controls.title.markAsPristine();
form.controls.bodyMd.markAsUntouched();
form.controls.bodyMd.markAsPristine();
}
export function buildPostBody(formValue: { title: string; bodyMd: string }): {
title: string;
bodyMd: string;
} {
return { title: formValue.title.trim(), bodyMd: formValue.bodyMd ?? '' };
}
export function statusBadgeClass(status: BlogStatus): string {
return status === 'published' ? 'badge-published' : 'badge-draft';
}
export function statusLabel(status: BlogStatus): string {
return status === 'published' ? 'Published' : 'Draft';
}
@@ -0,0 +1,253 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { BlogApiService, AdminBlogPost } from '../../../core/services/blog.service';
import { BlogFormModalComponent, PostFormSubmitPayload } from './blog-form-modal.component';
import { BlogDeleteModalComponent } from './blog-delete-modal.component';
import { statusBadgeClass, statusLabel } from './blog-form.pure';
type ModalMode = 'create' | 'edit' | null;
@Component({
selector: 'app-admin-blog',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, BlogFormModalComponent, BlogDeleteModalComponent],
styles: [`
.admin-blog { display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; }
.toolbar h2 { margin: 0; }
.status-banner { padding: 8px; border-radius: var(--radius-sm, 4px); }
.status-banner.success { background: #ecfdf5; color: #065f46; }
.status-banner.error { background: #fee2e2; color: #991b1b; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; vertical-align: top; }
.badge { padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.badge-draft { background: var(--color-secondary, #ddd); color: #444; }
.badge-published { background: #d1fae5; color: #065f46; }
.row-actions button { margin-left: 4px; }
.empty { text-align: center; padding: 16px; opacity: 0.7; }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
`],
template: `
<section class="admin-blog" data-testid="admin-blog">
<div class="toolbar">
<h2>Blog</h2>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="blog-add">
+ New post
</button>
</div>
@if (statusMessage()) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="blog-status">
{{ statusMessage() }}
</p>
}
@if (loadError()) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="blog-error">
{{ loadError() }}
</p>
}
@if (loading()) {
<p data-testid="blog-loading">Loading posts…</p>
} @else if (posts().length === 0) {
<p class="empty" data-testid="blog-empty">No posts yet.</p>
} @else {
<table class="table" data-testid="blog-table">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (p of posts(); track p.id) {
<tr [attr.data-testid]="'blog-row-' + p.id">
<td>{{ p.title }}</td>
<td>
<span class="badge" [class]="statusBadgeClass(p.status)" [attr.data-testid]="'blog-status-' + p.id">
{{ statusLabel(p.status) }}
</span>
</td>
<td>{{ p.createdAt | date: 'medium' }}</td>
<td>{{ p.updatedAt | date: 'medium' }}</td>
<td>
@if (p.publishedAt) {
{{ p.publishedAt | date: 'medium' }}
} @else {
}
</td>
<td class="row-actions">
<button type="button" (click)="openEdit(p)" [disabled]="actionInFlight()" [attr.data-testid]="'blog-edit-' + p.id">✎</button>
<button type="button" (click)="openDelete(p)" [disabled]="actionInFlight()" [attr.data-testid]="'blog-delete-' + p.id">🗑</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-blog-form-modal
[open]="modalMode() !== null"
[mode]="modalMode() === 'edit' ? 'edit' : 'create'"
[post]="editing()"
[errorMessage]="formError()"
[saving]="saving()"
(cancel)="closeModal()"
(submit)="onFormSubmit($event)"
/>
<app-blog-delete-modal
[open]="deleteOpen()"
[post]="deleting()"
[errorMessage]="deleteError()"
[deleting]="deleting() !== null"
(cancel)="closeModal()"
(confirm)="onDeleteConfirm()"
/>
</section>
`,
})
export class AdminBlogComponent implements OnInit {
private readonly blog = inject(BlogApiService);
readonly posts = signal<AdminBlogPost[]>([]);
readonly loading = signal(true);
readonly actionInFlight = signal(false);
readonly loadError = signal<string | null>(null);
readonly statusMessage = signal<string | null>(null);
readonly modalMode = signal<ModalMode>(null);
readonly editing = signal<AdminBlogPost | null>(null);
readonly deleting = signal<AdminBlogPost | null>(null);
readonly deleteOpen = signal(false);
readonly formError = signal<string | null>(null);
readonly deleteError = signal<string | null>(null);
readonly saving = signal(false);
readonly statusBadgeClass = statusBadgeClass;
readonly statusLabel = statusLabel;
async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const list = await this.blog.listAllPosts();
this.posts.set(list);
} catch (e: any) {
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load posts');
} finally {
this.loading.set(false);
}
}
openCreate(): void {
this.editing.set(null);
this.deleting.set(null);
this.deleteOpen.set(false);
this.deleteError.set(null);
this.formError.set(null);
this.modalMode.set('create');
this.statusMessage.set(null);
}
openEdit(p: AdminBlogPost): void {
this.deleting.set(null);
this.deleteOpen.set(false);
this.editing.set(p);
this.deleteError.set(null);
this.formError.set(null);
this.modalMode.set('edit');
this.statusMessage.set(null);
}
openDelete(p: AdminBlogPost): void {
this.editing.set(null);
this.modalMode.set(null);
this.deleting.set(p);
this.deleteError.set(null);
this.deleteOpen.set(true);
this.statusMessage.set(null);
}
closeModal(): void {
this.modalMode.set(null);
this.deleteOpen.set(false);
this.editing.set(null);
this.deleting.set(null);
this.formError.set(null);
this.deleteError.set(null);
this.saving.set(false);
}
async onFormSubmit(payload: PostFormSubmitPayload): Promise<void> {
this.saving.set(true);
this.formError.set(null);
this.actionInFlight.set(true);
try {
const status = payload.action === 'publish' ? 'published' : 'draft';
if (payload.mode === 'create') {
const created = await this.blog.createPost({
title: payload.title,
bodyMd: payload.bodyMd,
status,
});
this.statusMessage.set(
status === 'published' ? 'Post published.' : 'Draft saved.',
);
this.closeModal();
await this.load();
// Highlight row briefly — no-op, but ensure we set posts above.
void created;
} else if (payload.mode === 'edit' && this.editing()) {
const id = this.editing()!.id;
await this.blog.updatePost(id, {
title: payload.title,
bodyMd: payload.bodyMd,
status,
});
this.statusMessage.set(
status === 'published' ? 'Post published.' : 'Draft saved.',
);
this.closeModal();
await this.load();
}
} catch (e: any) {
this.formError.set(e?.error?.message ?? e?.message ?? 'Failed to save');
} finally {
this.saving.set(false);
this.actionInFlight.set(false);
}
}
async onDeleteConfirm(): Promise<void> {
const p = this.deleting();
if (!p) return;
this.actionInFlight.set(true);
try {
await this.blog.deletePost(p.id);
this.statusMessage.set('Post deleted.');
this.closeModal();
await this.load();
} catch (e: any) {
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed to delete');
} finally {
this.actionInFlight.set(false);
}
}
}
@@ -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' };
}
@@ -64,6 +64,27 @@
line-height: 1.5;
}
.landing-blog app-blog-presenter .blog-item {
border-top: 1px solid var(--color-surface, #eee);
padding-top: 1rem;
}
.landing-blog app-blog-presenter .blog-title {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
}
.landing-blog app-blog-presenter .blog-date {
display: block;
color: var(--color-text-muted, #888);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.landing-blog app-blog-presenter .blog-body {
line-height: 1.5;
}
.landing-blog-empty {
text-align: center;
color: var(--color-text-muted, #888);
@@ -20,11 +20,7 @@
@if (landing.posts().length > 0) {
<section class="landing-blog" data-testid="landing-blog">
@for (post of landing.posts(); track post.id) {
<article class="landing-blog-item">
<h2 class="landing-blog-title">{{ post.title }}</h2>
<time class="landing-blog-date">{{ post.publishedAt | date: 'medium' }}</time>
<div class="landing-blog-body" [innerHTML]="markdown.render(post.bodyMd)"></div>
</article>
<app-blog-presenter [post]="post" />
}
</section>
} @else if (!landing.loading()) {
@@ -16,6 +16,7 @@ import { MarkdownService } from '../../core/services/markdown.service';
import { LandingService } from './landing.service';
import { buildLoginFailureMessage } from './login-modal.service';
import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
import { BlogPresenterComponent } from '../blog/blog-presenter.component';
type ModalMode = 'closed' | 'login' | 'register';
@@ -23,7 +24,7 @@ type ModalMode = 'closed' | 'login' | 'register';
selector: 'app-landing',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
imports: [CommonModule, ReactiveFormsModule, BlogPresenterComponent],
templateUrl: './landing.component.html',
styleUrls: ['./landing.component.css'],
})