create-momentum-app 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/index.cjs +13 -0
  2. package/package.json +1 -1
  3. package/templates/analog/package.json.tmpl +7 -1
  4. package/templates/analog/src/app/app.config.ts +2 -0
  5. package/templates/analog/src/app/pages/(home).page.ts +151 -0
  6. package/templates/analog/src/app/pages/blocks/hero-block.component.ts +41 -0
  7. package/templates/analog/src/app/pages/blocks/image-text-block.component.ts +56 -0
  8. package/templates/analog/src/app/pages/blocks/text-block.component.ts +28 -0
  9. package/templates/analog/src/app/pages/post-block-providers.ts +17 -0
  10. package/templates/analog/src/app/pages/post-detail.resolver.ts +31 -0
  11. package/templates/analog/src/app/pages/posts/[slug].page.ts +145 -0
  12. package/templates/analog/src/app/pages/posts.page.ts +130 -0
  13. package/templates/analog/src/collections/posts.collection.ts +89 -0
  14. package/templates/analog/src/generated/.gitkeep +0 -0
  15. package/templates/analog/src/momentum.config.ts.tmpl +16 -2
  16. package/templates/angular/package.json.tmpl +7 -1
  17. package/templates/angular/src/app/app.config.ts +2 -0
  18. package/templates/angular/src/app/app.routes.ts +11 -0
  19. package/templates/angular/src/app/pages/blocks/hero-block.component.ts +41 -0
  20. package/templates/angular/src/app/pages/blocks/image-text-block.component.ts +56 -0
  21. package/templates/angular/src/app/pages/blocks/text-block.component.ts +28 -0
  22. package/templates/angular/src/app/pages/post-block-providers.ts +17 -0
  23. package/templates/angular/src/app/pages/post-detail.resolver.ts +31 -0
  24. package/templates/angular/src/app/pages/post-detail.ts +139 -0
  25. package/templates/angular/src/app/pages/posts.ts +130 -0
  26. package/templates/angular/src/app/pages/welcome.ts +62 -10
  27. package/templates/angular/src/collections/posts.collection.ts +89 -0
  28. package/templates/angular/src/generated/.gitkeep +0 -0
  29. package/templates/angular/src/momentum.config.ts.tmpl +16 -2
  30. package/templates/shared/.claude/CLAUDE.md +29 -12
  31. package/templates/shared/.claude/agents.md +140 -0
  32. package/templates/shared/.claude/skills/add-plugin/SKILL.md +136 -0
  33. package/templates/shared/.claude/skills/collection/SKILL.md +2 -2
  34. package/templates/analog/src/collections/posts.ts +0 -16
  35. package/templates/angular/src/collections/posts.ts +0 -16
package/index.cjs CHANGED
@@ -284,6 +284,19 @@ The database is automatically created on first run - no setup required.`
284
284
  shell: true
285
285
  });
286
286
  console.log();
287
+ console.log(import_picocolors.default.dim("Generating types and admin config..."));
288
+ try {
289
+ (0, import_node_child_process.execFileSync)("npm", ["run", "generate"], {
290
+ cwd: projectDir,
291
+ stdio: "inherit",
292
+ shell: true
293
+ });
294
+ console.log(import_picocolors.default.green("\u2713 Types and admin config generated"));
295
+ console.log();
296
+ } catch {
297
+ console.log(import_picocolors.default.yellow("\u26A0\uFE0F Code generation failed. Run `npm run generate` manually."));
298
+ console.log();
299
+ }
287
300
  }
288
301
  console.log(import_picocolors.default.green(import_picocolors.default.bold("Done!")));
289
302
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-momentum-app",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Create a new Momentum CMS application",
5
5
  "license": "MIT",
6
6
  "author": "Momentum CMS Contributors",
@@ -7,7 +7,11 @@
7
7
  "dev": "vite",
8
8
  "build": "vite build",
9
9
  "start": "node dist/analog/server/index.mjs",
10
- "generate": "tsx node_modules/@momentumcms/core/generators/generator.cjs src/momentum.config.ts --types src/generated/momentum.types.ts --config src/generated/momentum.config.ts"
10
+ "generate": "tsx node_modules/@momentumcms/core/src/generators/generator.cjs src/momentum.config.ts --types src/generated/momentum.types.ts --config src/generated/momentum.config.ts",
11
+ "migrate:generate": "tsx node_modules/@momentumcms/migrations/src/cli/generate.cjs src/momentum.config.ts",
12
+ "migrate:run": "tsx node_modules/@momentumcms/migrations/src/cli/run.cjs src/momentum.config.ts",
13
+ "migrate:status": "tsx node_modules/@momentumcms/migrations/src/cli/status.cjs src/momentum.config.ts",
14
+ "migrate:rollback": "tsx node_modules/@momentumcms/migrations/src/cli/rollback.cjs src/momentum.config.ts"
11
15
  },
12
16
  "dependencies": {
13
17
  "@analogjs/platform": "^2.2.3",
@@ -29,7 +33,9 @@
29
33
  "@momentumcms/admin": "^{{packageVersion}}",
30
34
  "@momentumcms/storage": "^{{packageVersion}}",
31
35
  "@momentumcms/logger": "^{{packageVersion}}",
36
+ "@momentumcms/migrations": "^{{packageVersion}}",
32
37
  "@momentumcms/plugins-core": "^{{packageVersion}}",
38
+ "@momentumcms/plugins-seo": "^{{packageVersion}}",
33
39
  "@ng-icons/core": "^33.0.0",
34
40
  "@ng-icons/heroicons": "^33.0.0",
35
41
  "@tiptap/core": "^3.0.0",
@@ -8,6 +8,7 @@ import {
8
8
  provideMomentumFieldRenderers,
9
9
  } from '@momentumcms/admin';
10
10
  import { adminConfig } from '../generated/momentum.config';
11
+ import { providePostBlocks } from './pages/post-block-providers';
11
12
 
12
13
  const adminRoutes = momentumAdminRoutes(adminConfig);
13
14
 
@@ -21,5 +22,6 @@ export const appConfig: ApplicationConfig = {
21
22
  ),
22
23
  provideClientHydration(withEventReplay()),
23
24
  provideMomentumFieldRenderers(),
25
+ ...providePostBlocks(),
24
26
  ],
25
27
  };
@@ -0,0 +1,151 @@
1
+ import { ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { RouterLink } from '@angular/router';
3
+
4
+ @Component({
5
+ selector: 'mcms-welcome-page',
6
+ imports: [RouterLink],
7
+ changeDetection: ChangeDetectionStrategy.OnPush,
8
+ host: {
9
+ class: 'flex min-h-screen flex-col bg-background text-foreground',
10
+ },
11
+ template: `
12
+ <header class="border-b border-border px-6 py-4">
13
+ <div class="mx-auto flex max-w-4xl items-center justify-between">
14
+ <h1 class="text-xl font-bold tracking-tight">Momentum CMS</h1>
15
+ <nav class="flex items-center gap-4">
16
+ <a
17
+ routerLink="/posts"
18
+ class="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
19
+ >
20
+ Posts
21
+ </a>
22
+ <a
23
+ href="https://github.com/DonaldMurillo/momentum-cms"
24
+ target="_blank"
25
+ rel="noopener noreferrer"
26
+ class="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
27
+ >
28
+ GitHub
29
+ </a>
30
+ <a
31
+ href="https://github.com/DonaldMurillo/momentum-cms/tree/main/docs"
32
+ target="_blank"
33
+ rel="noopener noreferrer"
34
+ class="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
35
+ >
36
+ Docs
37
+ </a>
38
+ <a
39
+ routerLink="/admin"
40
+ class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
41
+ >
42
+ Go to Admin
43
+ </a>
44
+ </nav>
45
+ </div>
46
+ </header>
47
+
48
+ <main class="flex-1 px-6 py-12">
49
+ <div class="mx-auto max-w-4xl space-y-12">
50
+ <section class="space-y-4 text-center">
51
+ <h2 class="text-4xl font-bold tracking-tight">Welcome to Momentum CMS</h2>
52
+ <p class="mx-auto max-w-2xl text-lg text-muted-foreground">
53
+ Your Angular-powered headless CMS is ready. Define collections in TypeScript, get an
54
+ auto-generated Admin UI, REST API, and database schema.
55
+ </p>
56
+ </section>
57
+
58
+ <section class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
59
+ <div class="rounded-lg border border-border bg-card p-6 shadow-sm">
60
+ <h3 class="mb-2 text-lg font-semibold text-card-foreground">1. Create Admin Account</h3>
61
+ <p class="mb-4 text-sm text-muted-foreground">
62
+ Head to the admin panel to create your first admin account and start managing content.
63
+ </p>
64
+ <a routerLink="/admin" class="text-sm font-medium text-primary hover:underline">
65
+ Open Admin Panel &rarr;
66
+ </a>
67
+ </div>
68
+
69
+ <div class="rounded-lg border border-border bg-card p-6 shadow-sm">
70
+ <h3 class="mb-2 text-lg font-semibold text-card-foreground">2. Define Collections</h3>
71
+ <p class="mb-4 text-sm text-muted-foreground">
72
+ Edit <code class="rounded bg-muted px-1.5 py-0.5 text-xs">src/collections/</code> to
73
+ define your content types with fields, hooks, and access control.
74
+ </p>
75
+ <code class="block rounded bg-muted px-3 py-2 text-xs text-muted-foreground">
76
+ text('title', &#123; required: true &#125;)
77
+ </code>
78
+ </div>
79
+
80
+ <div class="rounded-lg border border-border bg-card p-6 shadow-sm">
81
+ <h3 class="mb-2 text-lg font-semibold text-card-foreground">3. Generate Types</h3>
82
+ <p class="mb-4 text-sm text-muted-foreground">
83
+ Run the type generator to create TypeScript interfaces from your collection
84
+ definitions.
85
+ </p>
86
+ <code class="block rounded bg-muted px-3 py-2 text-xs text-muted-foreground">
87
+ npm run generate
88
+ </code>
89
+ </div>
90
+ </section>
91
+
92
+ <section class="rounded-lg border border-border bg-card p-6 shadow-sm">
93
+ <h3 class="mb-3 text-lg font-semibold text-card-foreground">Quick Reference</h3>
94
+ <div class="grid gap-3 sm:grid-cols-2">
95
+ <div>
96
+ <p class="text-sm font-medium text-card-foreground">Dev Server</p>
97
+ <code class="text-sm text-muted-foreground">npm run dev</code>
98
+ </div>
99
+ <div>
100
+ <p class="text-sm font-medium text-card-foreground">Production Build</p>
101
+ <code class="text-sm text-muted-foreground">npm run build</code>
102
+ </div>
103
+ <div>
104
+ <p class="text-sm font-medium text-card-foreground">Config File</p>
105
+ <code class="text-sm text-muted-foreground">src/momentum.config.ts</code>
106
+ </div>
107
+ <div>
108
+ <p class="text-sm font-medium text-card-foreground">REST API</p>
109
+ <code class="text-sm text-muted-foreground">/api/collections/:slug</code>
110
+ </div>
111
+ </div>
112
+ </section>
113
+ </div>
114
+ </main>
115
+
116
+ <footer class="border-t border-border px-6 py-6">
117
+ <div
118
+ class="mx-auto flex max-w-4xl flex-col items-center gap-4 sm:flex-row sm:justify-between"
119
+ >
120
+ <p class="text-sm text-muted-foreground">
121
+ Powered by Momentum CMS &mdash; Built with Angular, Drizzle ORM, and Better Auth
122
+ </p>
123
+ <nav class="flex items-center gap-4">
124
+ <a
125
+ routerLink="/posts"
126
+ class="text-sm text-muted-foreground transition-colors hover:text-foreground"
127
+ >
128
+ Posts
129
+ </a>
130
+ <a
131
+ href="https://github.com/DonaldMurillo/momentum-cms"
132
+ target="_blank"
133
+ rel="noopener noreferrer"
134
+ class="text-sm text-muted-foreground transition-colors hover:text-foreground"
135
+ >
136
+ GitHub
137
+ </a>
138
+ <a
139
+ href="https://github.com/DonaldMurillo/momentum-cms/tree/main/docs"
140
+ target="_blank"
141
+ rel="noopener noreferrer"
142
+ class="text-sm text-muted-foreground transition-colors hover:text-foreground"
143
+ >
144
+ Documentation
145
+ </a>
146
+ </nav>
147
+ </div>
148
+ </footer>
149
+ `,
150
+ })
151
+ export default class WelcomePage {}
@@ -0,0 +1,41 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'app-hero-block',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ host: {
7
+ class: 'block',
8
+ '[attr.data-testid]': '"block-hero"',
9
+ },
10
+ template: `
11
+ <section class="bg-primary text-primary-foreground py-12 px-4 md:py-20 md:px-8 text-center">
12
+ <div class="mx-auto max-w-4xl">
13
+ <h1 class="text-3xl md:text-4xl lg:text-5xl font-bold mb-4" data-testid="hero-heading">
14
+ {{ heading() }}
15
+ </h1>
16
+ @if (subheading()) {
17
+ <p class="text-lg md:text-xl text-primary-foreground mb-8" data-testid="hero-subheading">
18
+ {{ subheading() }}
19
+ </p>
20
+ }
21
+ @if (ctaText()) {
22
+ <a
23
+ class="inline-block bg-primary-foreground text-primary font-semibold px-6 py-3 rounded-lg hover:bg-primary-foreground/90 transition-colors"
24
+ [href]="ctaLink() || '#'"
25
+ data-testid="hero-cta"
26
+ >
27
+ {{ ctaText() }}
28
+ </a>
29
+ }
30
+ </div>
31
+ </section>
32
+ `,
33
+ })
34
+ export class HeroBlockComponent {
35
+ readonly data = input.required<Record<string, unknown>>();
36
+
37
+ readonly heading = computed((): string => String(this.data()['heading'] ?? ''));
38
+ readonly subheading = computed((): string => String(this.data()['subheading'] ?? ''));
39
+ readonly ctaText = computed((): string => String(this.data()['ctaText'] ?? ''));
40
+ readonly ctaLink = computed((): string => String(this.data()['ctaLink'] ?? ''));
41
+ }
@@ -0,0 +1,56 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'app-image-text-block',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ host: {
7
+ class: 'block',
8
+ '[attr.data-testid]': '"block-imageText"',
9
+ },
10
+ template: `
11
+ <section class="py-8 px-4 md:py-16 md:px-8">
12
+ <div
13
+ class="mx-auto max-w-6xl flex flex-col gap-8 md:gap-12 items-center"
14
+ [class.md:flex-row]="!reversed()"
15
+ [class.md:flex-row-reverse]="reversed()"
16
+ >
17
+ <!-- Image -->
18
+ <div class="w-full md:w-1/2" data-testid="image-text-image">
19
+ @if (imageUrl()) {
20
+ <img
21
+ [src]="imageUrl()"
22
+ [alt]="imageAlt() || heading()"
23
+ class="w-full h-auto rounded-lg object-cover bg-muted"
24
+ />
25
+ } @else {
26
+ <div class="w-full aspect-video rounded-lg bg-muted flex items-center justify-center">
27
+ <span class="text-muted-foreground text-sm">No image</span>
28
+ </div>
29
+ }
30
+ </div>
31
+
32
+ <!-- Text -->
33
+ <div class="w-full md:w-1/2">
34
+ <h2
35
+ class="text-2xl md:text-3xl font-bold text-foreground mb-4"
36
+ data-testid="image-text-heading"
37
+ >
38
+ {{ heading() }}
39
+ </h2>
40
+ <p class="text-lg text-muted-foreground leading-relaxed" data-testid="image-text-body">
41
+ {{ body() }}
42
+ </p>
43
+ </div>
44
+ </div>
45
+ </section>
46
+ `,
47
+ })
48
+ export class ImageTextBlockComponent {
49
+ readonly data = input.required<Record<string, unknown>>();
50
+
51
+ readonly heading = computed((): string => String(this.data()['heading'] ?? ''));
52
+ readonly body = computed((): string => String(this.data()['body'] ?? ''));
53
+ readonly imageUrl = computed((): string => String(this.data()['imageUrl'] ?? ''));
54
+ readonly imageAlt = computed((): string => String(this.data()['imageAlt'] ?? ''));
55
+ readonly reversed = computed((): boolean => this.data()['imagePosition'] === 'right');
56
+ }
@@ -0,0 +1,28 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'app-text-block',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ host: {
7
+ class: 'block',
8
+ '[attr.data-testid]': '"block-textBlock"',
9
+ },
10
+ template: `
11
+ <section class="py-8 px-4 md:py-12 md:px-8 max-w-3xl mx-auto">
12
+ @if (heading()) {
13
+ <h2 class="text-2xl font-bold text-foreground mb-4" data-testid="text-heading">
14
+ {{ heading() }}
15
+ </h2>
16
+ }
17
+ <p class="text-lg text-muted-foreground leading-relaxed" data-testid="text-body">
18
+ {{ body() }}
19
+ </p>
20
+ </section>
21
+ `,
22
+ })
23
+ export class TextBlockComponent {
24
+ readonly data = input.required<Record<string, unknown>>();
25
+
26
+ readonly heading = computed((): string => String(this.data()['heading'] ?? ''));
27
+ readonly body = computed((): string => String(this.data()['body'] ?? ''));
28
+ }
@@ -0,0 +1,17 @@
1
+ import type { Provider } from '@angular/core';
2
+ import { provideBlockComponents } from '@momentumcms/ui';
3
+ import { HeroBlockComponent } from './blocks/hero-block.component';
4
+ import { TextBlockComponent } from './blocks/text-block.component';
5
+ import { ImageTextBlockComponent } from './blocks/image-text-block.component';
6
+
7
+ /**
8
+ * Provide all post block components for the block renderer.
9
+ * Add to the app's root providers.
10
+ */
11
+ export function providePostBlocks(): Provider[] {
12
+ return provideBlockComponents({
13
+ hero: HeroBlockComponent,
14
+ textBlock: TextBlockComponent,
15
+ imageText: ImageTextBlockComponent,
16
+ });
17
+ }
@@ -0,0 +1,31 @@
1
+ import type { ResolveFn } from '@angular/router';
2
+ import { injectMomentumAPI, type FindResult } from '@momentumcms/admin';
3
+
4
+ /**
5
+ * Resolver for the post detail route.
6
+ *
7
+ * Fetches the post by slug before the component renders. This ensures:
8
+ * - SSR renders the full post content (not just "Loading...")
9
+ * - The admin preview iframe (with scripts disabled) shows the real post
10
+ */
11
+ export const postDetailResolver: ResolveFn<FindResult<Record<string, unknown>>> = (route) => {
12
+ const api = injectMomentumAPI();
13
+ const slug = typeof route.params['slug'] === 'string' ? route.params['slug'] : undefined;
14
+
15
+ if (!slug) {
16
+ return {
17
+ docs: [],
18
+ totalDocs: 0,
19
+ page: 1,
20
+ totalPages: 0,
21
+ limit: 1,
22
+ hasNextPage: false,
23
+ hasPrevPage: false,
24
+ };
25
+ }
26
+
27
+ return api.collection('posts').find({
28
+ where: { slug: { equals: slug } },
29
+ limit: 1,
30
+ });
31
+ };
@@ -0,0 +1,145 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
2
+ import { Meta, Title } from '@angular/platform-browser';
3
+ import { ActivatedRoute, RouterLink } from '@angular/router';
4
+ import { toSignal } from '@angular/core/rxjs-interop';
5
+ import { map } from 'rxjs';
6
+ import { injectMomentumAPI, type FindResult } from '@momentumcms/admin';
7
+ import { BlockRendererComponent } from '@momentumcms/ui';
8
+ import type { RouteMeta } from '@analogjs/router';
9
+ import { postDetailResolver } from '../post-detail.resolver';
10
+
11
+ export const routeMeta: RouteMeta = {
12
+ resolve: { postData: postDetailResolver },
13
+ };
14
+
15
+ interface PostDetail {
16
+ id: string;
17
+ title: string;
18
+ blocks: Array<Record<string, unknown>>;
19
+ }
20
+
21
+ @Component({
22
+ selector: 'app-post-detail',
23
+ imports: [RouterLink, BlockRendererComponent],
24
+ changeDetection: ChangeDetectionStrategy.OnPush,
25
+ host: {
26
+ class: 'block mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 md:py-12',
27
+ },
28
+ template: `
29
+ <a
30
+ routerLink="/posts"
31
+ class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors"
32
+ data-testid="post-back-link"
33
+ >
34
+ &larr; Back to Posts
35
+ </a>
36
+
37
+ @if (loading()) {
38
+ <p class="text-muted-foreground" data-testid="post-loading">Loading post...</p>
39
+ } @else if (error()) {
40
+ <div class="text-center py-12" data-testid="post-error">
41
+ <h1 class="text-2xl font-bold text-foreground mb-2">Post not found</h1>
42
+ <p class="text-muted-foreground">The post you are looking for does not exist.</p>
43
+ </div>
44
+ } @else if (post(); as p) {
45
+ <article data-testid="post-detail">
46
+ <h1
47
+ class="text-3xl md:text-4xl font-bold text-foreground mb-6"
48
+ data-testid="post-detail-title"
49
+ >
50
+ {{ p.title }}
51
+ </h1>
52
+
53
+ <div data-testid="post-blocks">
54
+ <mcms-block-renderer [blocks]="p.blocks" />
55
+ </div>
56
+ </article>
57
+ }
58
+ `,
59
+ })
60
+ export default class PostDetailComponent {
61
+ private readonly route = inject(ActivatedRoute);
62
+ private readonly api = injectMomentumAPI();
63
+ private readonly titleService = inject(Title);
64
+ private readonly metaService = inject(Meta);
65
+
66
+ readonly post = signal<PostDetail | null>(null);
67
+ readonly loading = signal(true);
68
+ readonly error = signal(false);
69
+
70
+ readonly blocks = computed((): Array<Record<string, unknown>> => this.post()?.blocks ?? []);
71
+
72
+ /**
73
+ * Resolved post data from the route resolver (SSR-safe).
74
+ */
75
+ private readonly resolvedPost = toSignal(
76
+ this.route.data.pipe(
77
+ map((data): Record<string, unknown> | null => {
78
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- route resolver returns FindResult
79
+ const result = data['postData'] as FindResult<Record<string, unknown>> | undefined;
80
+ if (!result || !result.docs[0]) return null;
81
+ return result.docs[0];
82
+ }),
83
+ ),
84
+ );
85
+
86
+ constructor() {
87
+ const resolvedDoc = this.resolvedPost();
88
+
89
+ if (resolvedDoc) {
90
+ this.populateFromDoc(resolvedDoc);
91
+ } else {
92
+ const slug: unknown = this.route.snapshot.params['slug'];
93
+ if (typeof slug === 'string') {
94
+ void this.loadPost(slug);
95
+ } else {
96
+ this.loading.set(false);
97
+ this.error.set(true);
98
+ }
99
+ }
100
+ }
101
+
102
+ private populateFromDoc(doc: Record<string, unknown>): void {
103
+ const title = String(doc['title'] ?? '');
104
+ const rawBlocks = doc['pageContent'];
105
+ const blockList = Array.isArray(rawBlocks)
106
+ ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- blocks from API are Record arrays
107
+ (rawBlocks as Array<Record<string, unknown>>)
108
+ : [];
109
+
110
+ this.post.set({
111
+ id: String(doc['id'] ?? ''),
112
+ title,
113
+ blocks: blockList,
114
+ });
115
+
116
+ this.titleService.setTitle(`${title} | Momentum CMS`);
117
+ this.metaService.updateTag({
118
+ name: 'description',
119
+ content: `Read "${title}" on Momentum CMS.`,
120
+ });
121
+ this.metaService.updateTag({ property: 'og:title', content: title });
122
+ this.metaService.updateTag({ property: 'og:type', content: 'article' });
123
+ this.loading.set(false);
124
+ }
125
+
126
+ private async loadPost(slug: string): Promise<void> {
127
+ try {
128
+ const result = await this.api
129
+ .collection<Record<string, unknown>>('posts')
130
+ .find({ where: { slug: { equals: slug } }, limit: 1 });
131
+
132
+ const doc = result.docs[0];
133
+ if (!doc) {
134
+ this.error.set(true);
135
+ this.loading.set(false);
136
+ return;
137
+ }
138
+
139
+ this.populateFromDoc(doc);
140
+ } catch {
141
+ this.error.set(true);
142
+ this.loading.set(false);
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,130 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
2
+ import { Meta, Title } from '@angular/platform-browser';
3
+ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
4
+ import { injectMomentumAPI } from '@momentumcms/admin';
5
+
6
+ interface PostDisplay {
7
+ id: string;
8
+ slug: string;
9
+ title: string;
10
+ }
11
+
12
+ @Component({
13
+ selector: 'app-posts-page',
14
+ imports: [RouterLink],
15
+ changeDetection: ChangeDetectionStrategy.OnPush,
16
+ host: {
17
+ class: 'block mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 md:py-12',
18
+ },
19
+ template: `
20
+ <h1 class="text-3xl md:text-4xl font-bold text-foreground mb-2" data-testid="posts-title">
21
+ Posts
22
+ </h1>
23
+ <p class="text-lg text-muted-foreground mb-8">Browse the latest posts.</p>
24
+
25
+ <!-- Search -->
26
+ <div class="mb-8">
27
+ <input
28
+ type="text"
29
+ class="w-full md:w-96 px-4 py-2 rounded-lg border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
30
+ placeholder="Search posts..."
31
+ [value]="searchQuery()"
32
+ (input)="onSearchInput($event)"
33
+ data-testid="posts-search"
34
+ />
35
+ </div>
36
+
37
+ <!-- Posts grid -->
38
+ @if (loading()) {
39
+ <p class="text-muted-foreground" data-testid="posts-loading">Loading posts...</p>
40
+ } @else if (error()) {
41
+ <div class="text-center py-12" data-testid="posts-error">
42
+ <p class="text-lg font-semibold text-foreground">Something went wrong</p>
43
+ <p class="text-muted-foreground mt-1">Failed to load posts. Please try again later.</p>
44
+ </div>
45
+ } @else if (filteredPosts().length === 0) {
46
+ <p class="text-muted-foreground" data-testid="posts-empty">No posts found.</p>
47
+ } @else {
48
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" data-testid="posts-grid">
49
+ @for (post of filteredPosts(); track post.id) {
50
+ <a
51
+ [routerLink]="['/posts', post.slug]"
52
+ class="bg-card border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow block"
53
+ data-testid="post-card"
54
+ >
55
+ <div class="p-6">
56
+ <h2 class="text-lg font-semibold text-card-foreground mb-2" data-testid="post-title">
57
+ {{ post.title }}
58
+ </h2>
59
+ </div>
60
+ </a>
61
+ }
62
+ </div>
63
+ }
64
+ `,
65
+ })
66
+ export default class PostsPageComponent {
67
+ private readonly api = injectMomentumAPI();
68
+ private readonly route = inject(ActivatedRoute);
69
+ private readonly router = inject(Router);
70
+
71
+ readonly posts = signal<PostDisplay[]>([]);
72
+ readonly loading = signal(true);
73
+ readonly error = signal(false);
74
+ readonly searchQuery = signal('');
75
+
76
+ private readonly titleService = inject(Title);
77
+ private readonly metaService = inject(Meta);
78
+
79
+ readonly filteredPosts = computed((): PostDisplay[] => {
80
+ const query = this.searchQuery().toLowerCase();
81
+ if (!query) return this.posts();
82
+ return this.posts().filter((p) => p.title.toLowerCase().includes(query));
83
+ });
84
+
85
+ constructor() {
86
+ const params = this.route.snapshot.queryParams;
87
+ const initialSearch = params['search'];
88
+ if (typeof initialSearch === 'string' && initialSearch) {
89
+ this.searchQuery.set(initialSearch);
90
+ }
91
+ this.titleService.setTitle('Posts | Momentum CMS');
92
+ this.metaService.updateTag({
93
+ name: 'description',
94
+ content: 'Browse the latest posts from Momentum CMS.',
95
+ });
96
+ void this.loadPosts();
97
+ }
98
+
99
+ onSearchInput(event: Event): void {
100
+ const target = event.target;
101
+ if (target instanceof HTMLInputElement) {
102
+ this.searchQuery.set(target.value);
103
+ void this.router.navigate([], {
104
+ queryParams: { search: target.value || null },
105
+ queryParamsHandling: 'merge',
106
+ replaceUrl: true,
107
+ });
108
+ }
109
+ }
110
+
111
+ private async loadPosts(): Promise<void> {
112
+ try {
113
+ const result = await this.api
114
+ .collection<Record<string, unknown>>('posts')
115
+ .find({ limit: 100, sort: '-createdAt' });
116
+
117
+ this.posts.set(
118
+ result.docs.map((doc) => ({
119
+ id: String(doc['id'] ?? ''),
120
+ slug: String(doc['slug'] ?? ''),
121
+ title: String(doc['title'] ?? ''),
122
+ })),
123
+ );
124
+ } catch {
125
+ this.error.set(true);
126
+ } finally {
127
+ this.loading.set(false);
128
+ }
129
+ }
130
+ }