create-ng-tailwind 3.1.0 → 4.1.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.
Potentially problematic release.
This version of create-ng-tailwind might be problematic. Click here for more details.
- package/CHANGELOG.md +96 -341
- package/README.md +111 -157
- package/lib/cli/index.js +74 -3
- package/lib/cli/interactive.js +26 -1
- package/lib/managers/ProjectManager.js +2 -5
- package/lib/templates/base/components.js +243 -0
- package/lib/templates/base/index.js +207 -0
- package/lib/templates/base/infrastructure.js +314 -0
- package/lib/templates/base/linting.js +359 -0
- package/lib/templates/base/pwa.js +103 -0
- package/lib/templates/base/services.js +362 -0
- package/lib/templates/blog/app.js +250 -0
- package/lib/templates/blog/components.js +360 -0
- package/lib/templates/blog/i18n.js +77 -0
- package/lib/templates/blog/index.js +126 -0
- package/lib/templates/blog/pages.js +554 -0
- package/lib/templates/blog/services.js +390 -0
- package/lib/templates/dashboard/app.js +320 -0
- package/lib/templates/dashboard/charts.js +305 -0
- package/lib/templates/dashboard/components.js +410 -0
- package/lib/templates/dashboard/i18n.js +340 -0
- package/lib/templates/dashboard/index.js +141 -0
- package/lib/templates/dashboard/layout.js +310 -0
- package/lib/templates/dashboard/pages.js +681 -0
- package/lib/templates/ecommerce/app.js +315 -0
- package/lib/templates/ecommerce/components.js +496 -0
- package/lib/templates/ecommerce/i18n.js +389 -0
- package/lib/templates/ecommerce/index.js +152 -0
- package/lib/templates/ecommerce/layout.js +270 -0
- package/lib/templates/ecommerce/pages.js +969 -0
- package/lib/templates/ecommerce/services.js +300 -0
- package/lib/templates/index.js +12 -0
- package/lib/templates/landing/index.js +1117 -0
- package/lib/templates/portfolio/index.js +1160 -0
- package/lib/templates/saas/index.js +1371 -0
- package/lib/templates/starter/app.js +364 -0
- package/lib/templates/starter/i18n.js +856 -0
- package/lib/templates/starter/index.js +52 -4060
- package/lib/templates/starter/layout.js +852 -0
- package/lib/templates/starter/pages.js +1241 -0
- package/lib/utils/nodeCompat.js +85 -0
- package/package.json +1 -1
- package/lib/templates/starter/features.js +0 -867
- package/lib/utils/ai-config.js +0 -641
- /package/lib/templates/{starter → base}/advanced-features.js +0 -0
- /package/lib/templates/{starter → base}/seo-assets.js +0 -0
- /package/lib/templates/{starter → base}/seo-features.js +0 -0
- /package/lib/templates/{starter → base}/ui-features.js +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const starter = require("../starter");
|
|
4
|
+
|
|
5
|
+
// Import modular components
|
|
6
|
+
const { createServices } = require("./services");
|
|
7
|
+
const { createComponents } = require("./components");
|
|
8
|
+
const { createPages } = require("./pages");
|
|
9
|
+
const { createI18n } = require("./i18n");
|
|
10
|
+
const { createRouting, createAppComponent, cleanupStarterLayout } = require("./app");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Blog/CMS Template
|
|
14
|
+
* Extends starter template with:
|
|
15
|
+
* - Blog posts listing with pagination
|
|
16
|
+
* - Post detail with markdown rendering
|
|
17
|
+
* - Categories and tags
|
|
18
|
+
* - Comments system
|
|
19
|
+
* - Author profiles
|
|
20
|
+
* - Search functionality
|
|
21
|
+
* - RSS feed support
|
|
22
|
+
*/
|
|
23
|
+
const blog = {
|
|
24
|
+
info: {
|
|
25
|
+
name: "Blog/CMS",
|
|
26
|
+
description: "Content management system with posts, categories, tags, and comments",
|
|
27
|
+
features: [
|
|
28
|
+
...starter.info.features,
|
|
29
|
+
"Blog posts with markdown support",
|
|
30
|
+
"Categories and tags system",
|
|
31
|
+
"Comments with replies",
|
|
32
|
+
"Author profiles",
|
|
33
|
+
"Search functionality",
|
|
34
|
+
"Reading time estimation",
|
|
35
|
+
"Related posts",
|
|
36
|
+
"Social sharing",
|
|
37
|
+
"Newsletter subscription",
|
|
38
|
+
"Blog-focused header with dynamic categories",
|
|
39
|
+
"Blog-focused footer with newsletter signup",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async apply(config, spinner) {
|
|
44
|
+
const chalk = require("chalk");
|
|
45
|
+
|
|
46
|
+
const completeStep = (message) => {
|
|
47
|
+
if (spinner) {
|
|
48
|
+
spinner.stop();
|
|
49
|
+
console.log(chalk.green(" ✔") + chalk.white(" " + message));
|
|
50
|
+
spinner.start();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Step 1: Apply starter template (infrastructure)
|
|
55
|
+
if (spinner) spinner.update("Setting up starter foundation...");
|
|
56
|
+
await starter.apply(config, null);
|
|
57
|
+
|
|
58
|
+
// Step 2: Create blog-specific directories
|
|
59
|
+
if (spinner) spinner.update("Setting up blog structure...");
|
|
60
|
+
await this.createDirectories(config);
|
|
61
|
+
|
|
62
|
+
// Step 3: Create blog services
|
|
63
|
+
await createServices(config);
|
|
64
|
+
|
|
65
|
+
// Step 4: Create blog components
|
|
66
|
+
await createComponents(config);
|
|
67
|
+
|
|
68
|
+
// Step 5: Create blog pages
|
|
69
|
+
await createPages(config);
|
|
70
|
+
|
|
71
|
+
// Step 6: Create routing
|
|
72
|
+
await createRouting(config);
|
|
73
|
+
|
|
74
|
+
// Step 7: Create app component (blog-specific header/footer)
|
|
75
|
+
await createAppComponent(config);
|
|
76
|
+
|
|
77
|
+
// Step 8: Clean up starter layout (blog has its own header/footer)
|
|
78
|
+
await cleanupStarterLayout(config);
|
|
79
|
+
|
|
80
|
+
// Step 9: Add i18n translations
|
|
81
|
+
await createI18n(config);
|
|
82
|
+
|
|
83
|
+
// Step 10: Format code
|
|
84
|
+
const base = require("../base");
|
|
85
|
+
await base.formatCode(config);
|
|
86
|
+
|
|
87
|
+
if (spinner) spinner.stop();
|
|
88
|
+
|
|
89
|
+
// Show summary
|
|
90
|
+
console.log("");
|
|
91
|
+
completeStep("Blog/CMS template created");
|
|
92
|
+
completeStep("Posts with markdown support");
|
|
93
|
+
completeStep("Categories and tags system");
|
|
94
|
+
completeStep("Comments with replies");
|
|
95
|
+
completeStep("Author profiles");
|
|
96
|
+
completeStep("Search functionality");
|
|
97
|
+
completeStep("Blog-focused header with dynamic categories");
|
|
98
|
+
completeStep("Blog-focused footer with newsletter signup");
|
|
99
|
+
console.log("");
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async createDirectories(config) {
|
|
103
|
+
const directories = [
|
|
104
|
+
"src/app/features/blog",
|
|
105
|
+
"src/app/features/blog/post-list",
|
|
106
|
+
"src/app/features/blog/post-detail",
|
|
107
|
+
"src/app/features/blog/category",
|
|
108
|
+
"src/app/features/blog/tag",
|
|
109
|
+
"src/app/features/blog/author",
|
|
110
|
+
"src/app/features/blog/search",
|
|
111
|
+
"src/app/shared/components/post-card",
|
|
112
|
+
"src/app/shared/components/comment",
|
|
113
|
+
"src/app/shared/components/tag-list",
|
|
114
|
+
"src/app/shared/components/share-buttons",
|
|
115
|
+
"src/app/shared/components/reading-time",
|
|
116
|
+
"src/app/shared/components/pagination",
|
|
117
|
+
"src/app/core/services",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const dir of directories) {
|
|
121
|
+
await fs.ensureDir(path.join(config.fullPath, dir));
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
module.exports = blog;
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create Blog Pages
|
|
6
|
+
*/
|
|
7
|
+
async function createPages(config) {
|
|
8
|
+
// Post List Page (Home)
|
|
9
|
+
const postList = `import { Component, inject, signal, computed } from '@angular/core';
|
|
10
|
+
import { RouterModule } from '@angular/router';
|
|
11
|
+
import { TranslateModule } from '@ngx-translate/core';
|
|
12
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
13
|
+
import { heroMagnifyingGlass } from '@ng-icons/heroicons/outline';
|
|
14
|
+
import { PostCardComponent } from '@shared/components/post-card/post-card.component';
|
|
15
|
+
import { PaginationComponent } from '@shared/components/pagination/pagination.component';
|
|
16
|
+
import { BlogService } from '@core/services/blog.service';
|
|
17
|
+
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'app-post-list',
|
|
20
|
+
standalone: true,
|
|
21
|
+
imports: [RouterModule, TranslateModule, NgIconComponent, PostCardComponent, PaginationComponent],
|
|
22
|
+
viewProviders: [provideIcons({ heroMagnifyingGlass })],
|
|
23
|
+
template: \`
|
|
24
|
+
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
25
|
+
<!-- Hero Section -->
|
|
26
|
+
<div class="mb-12 text-center">
|
|
27
|
+
<h1 class="text-4xl font-bold text-gray-900 sm:text-5xl">{{ 'blog.title' | translate }}</h1>
|
|
28
|
+
<p class="mt-4 text-lg text-gray-600">{{ 'blog.subtitle' | translate }}</p>
|
|
29
|
+
|
|
30
|
+
<!-- Search -->
|
|
31
|
+
<div class="mt-8 mx-auto max-w-md">
|
|
32
|
+
<div class="relative">
|
|
33
|
+
<ng-icon name="heroMagnifyingGlass" size="20" class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></ng-icon>
|
|
34
|
+
<input
|
|
35
|
+
type="text"
|
|
36
|
+
[placeholder]="'blog.searchPlaceholder' | translate"
|
|
37
|
+
(input)="onSearch($event)"
|
|
38
|
+
class="w-full rounded-full border border-gray-300 py-3 pl-12 pr-4 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Featured Posts -->
|
|
45
|
+
@if (!searchQuery() && currentPage() === 1 && blogService.featuredPosts().length > 0) {
|
|
46
|
+
<section class="mb-16">
|
|
47
|
+
<h2 class="mb-6 text-2xl font-bold text-gray-900">{{ 'blog.featured' | translate }}</h2>
|
|
48
|
+
<div class="grid gap-8 md:grid-cols-3">
|
|
49
|
+
@for (post of blogService.featuredPosts(); track post.id) {
|
|
50
|
+
<app-post-card [post]="post"></app-post-card>
|
|
51
|
+
}
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
<!-- Categories -->
|
|
57
|
+
<div class="mb-8 flex flex-wrap gap-2">
|
|
58
|
+
<button
|
|
59
|
+
(click)="selectedCategory.set('')"
|
|
60
|
+
class="rounded-full px-4 py-2 text-sm font-medium transition-colors"
|
|
61
|
+
[class.bg-primary-500]="!selectedCategory()"
|
|
62
|
+
[class.text-white]="!selectedCategory()"
|
|
63
|
+
[class.bg-gray-100]="selectedCategory()"
|
|
64
|
+
[class.text-gray-700]="selectedCategory()">
|
|
65
|
+
{{ 'blog.all' | translate }}
|
|
66
|
+
</button>
|
|
67
|
+
@for (cat of blogService.categories(); track cat) {
|
|
68
|
+
<button
|
|
69
|
+
(click)="selectedCategory.set(cat)"
|
|
70
|
+
class="rounded-full px-4 py-2 text-sm font-medium transition-colors"
|
|
71
|
+
[class.bg-primary-500]="selectedCategory() === cat"
|
|
72
|
+
[class.text-white]="selectedCategory() === cat"
|
|
73
|
+
[class.bg-gray-100]="selectedCategory() !== cat"
|
|
74
|
+
[class.text-gray-700]="selectedCategory() !== cat">
|
|
75
|
+
{{ cat }}
|
|
76
|
+
</button>
|
|
77
|
+
}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<!-- Posts Grid -->
|
|
81
|
+
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
82
|
+
@for (post of paginatedPosts(); track post.id) {
|
|
83
|
+
<app-post-card [post]="post"></app-post-card>
|
|
84
|
+
} @empty {
|
|
85
|
+
<div class="col-span-full py-12 text-center">
|
|
86
|
+
<p class="text-gray-500">{{ 'blog.noPostsFound' | translate }}</p>
|
|
87
|
+
</div>
|
|
88
|
+
}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Pagination -->
|
|
92
|
+
@if (totalPages() > 1) {
|
|
93
|
+
<div class="mt-12">
|
|
94
|
+
<app-pagination
|
|
95
|
+
[currentPage]="currentPage()"
|
|
96
|
+
[totalPages]="totalPages()"
|
|
97
|
+
(pageChange)="currentPage.set($event)">
|
|
98
|
+
</app-pagination>
|
|
99
|
+
</div>
|
|
100
|
+
}
|
|
101
|
+
</div>
|
|
102
|
+
\`,
|
|
103
|
+
})
|
|
104
|
+
export class PostListComponent {
|
|
105
|
+
blogService = inject(BlogService);
|
|
106
|
+
|
|
107
|
+
searchQuery = signal('');
|
|
108
|
+
selectedCategory = signal('');
|
|
109
|
+
currentPage = signal(1);
|
|
110
|
+
postsPerPage = 6;
|
|
111
|
+
|
|
112
|
+
filteredPosts = computed(() => {
|
|
113
|
+
let posts = this.blogService.posts();
|
|
114
|
+
|
|
115
|
+
if (this.searchQuery()) {
|
|
116
|
+
posts = this.blogService.searchPosts(this.searchQuery());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this.selectedCategory()) {
|
|
120
|
+
posts = posts.filter(p => p.category === this.selectedCategory());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return posts;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
totalPages = computed(() =>
|
|
127
|
+
Math.ceil(this.filteredPosts().length / this.postsPerPage)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
paginatedPosts = computed(() => {
|
|
131
|
+
const start = (this.currentPage() - 1) * this.postsPerPage;
|
|
132
|
+
return this.filteredPosts().slice(start, start + this.postsPerPage);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
onSearch(event: Event): void {
|
|
136
|
+
const value = (event.target as HTMLInputElement).value;
|
|
137
|
+
this.searchQuery.set(value);
|
|
138
|
+
this.currentPage.set(1);
|
|
139
|
+
}
|
|
140
|
+
}`;
|
|
141
|
+
|
|
142
|
+
await fs.writeFile(
|
|
143
|
+
path.join(config.fullPath, "src/app/features/blog/post-list/post-list.component.ts"),
|
|
144
|
+
postList
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Post Detail Page
|
|
148
|
+
const postDetail = `import { Component, inject, signal } from '@angular/core';
|
|
149
|
+
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|
150
|
+
import { DatePipe } from '@angular/common';
|
|
151
|
+
import { FormsModule } from '@angular/forms';
|
|
152
|
+
import { TranslateModule } from '@ngx-translate/core';
|
|
153
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
154
|
+
import { heroCalendar, heroClock, heroArrowLeft } from '@ng-icons/heroicons/outline';
|
|
155
|
+
import { PostCardComponent } from '@shared/components/post-card/post-card.component';
|
|
156
|
+
import { CommentComponent } from '@shared/components/comment/comment.component';
|
|
157
|
+
import { TagListComponent } from '@shared/components/tag-list/tag-list.component';
|
|
158
|
+
import { ShareButtonsComponent } from '@shared/components/share-buttons/share-buttons.component';
|
|
159
|
+
import { BlogService, Post, Comment } from '@core/services/blog.service';
|
|
160
|
+
|
|
161
|
+
@Component({
|
|
162
|
+
selector: 'app-post-detail',
|
|
163
|
+
standalone: true,
|
|
164
|
+
imports: [
|
|
165
|
+
RouterModule, DatePipe, FormsModule, TranslateModule, NgIconComponent,
|
|
166
|
+
PostCardComponent, CommentComponent, TagListComponent, ShareButtonsComponent
|
|
167
|
+
],
|
|
168
|
+
viewProviders: [provideIcons({ heroCalendar, heroClock, heroArrowLeft })],
|
|
169
|
+
template: \`
|
|
170
|
+
@if (post) {
|
|
171
|
+
<article class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
|
172
|
+
<!-- Back Link -->
|
|
173
|
+
<a routerLink="/" class="mb-8 inline-flex items-center gap-2 text-gray-600 hover:text-primary-600">
|
|
174
|
+
<ng-icon name="heroArrowLeft" size="20"></ng-icon>
|
|
175
|
+
{{ 'blog.backToBlog' | translate }}
|
|
176
|
+
</a>
|
|
177
|
+
|
|
178
|
+
<!-- Header -->
|
|
179
|
+
<header class="mb-8">
|
|
180
|
+
<div class="mb-4 flex items-center gap-4 text-sm text-gray-500">
|
|
181
|
+
<a [routerLink]="['/category', post.category]" class="text-primary-600 hover:text-primary-700 font-medium">
|
|
182
|
+
{{ post.category }}
|
|
183
|
+
</a>
|
|
184
|
+
<span class="flex items-center gap-1">
|
|
185
|
+
<ng-icon name="heroCalendar" size="16"></ng-icon>
|
|
186
|
+
{{ post.publishedAt | date:'mediumDate' }}
|
|
187
|
+
</span>
|
|
188
|
+
<span class="flex items-center gap-1">
|
|
189
|
+
<ng-icon name="heroClock" size="16"></ng-icon>
|
|
190
|
+
{{ post.readingTime }} min read
|
|
191
|
+
</span>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<h1 class="mb-6 text-4xl font-bold text-gray-900 sm:text-5xl">{{ post.title }}</h1>
|
|
195
|
+
|
|
196
|
+
<!-- Author -->
|
|
197
|
+
<div class="flex items-center gap-4">
|
|
198
|
+
<img [src]="post.author.avatar" [alt]="post.author.name" class="h-12 w-12 rounded-full object-cover" />
|
|
199
|
+
<div>
|
|
200
|
+
<a [routerLink]="['/author', post.author.id]" class="font-medium text-gray-900 hover:text-primary-600">
|
|
201
|
+
{{ post.author.name }}
|
|
202
|
+
</a>
|
|
203
|
+
<p class="text-sm text-gray-500">{{ post.author.bio }}</p>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</header>
|
|
207
|
+
|
|
208
|
+
<!-- Cover Image -->
|
|
209
|
+
<div class="mb-8 overflow-hidden rounded-2xl">
|
|
210
|
+
<img [src]="post.coverImage" [alt]="post.title" class="w-full" />
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Content -->
|
|
214
|
+
<div class="prose prose-lg max-w-none mb-8" [innerHTML]="renderedContent"></div>
|
|
215
|
+
|
|
216
|
+
<!-- Tags -->
|
|
217
|
+
<div class="mb-8">
|
|
218
|
+
<app-tag-list [tags]="post.tags"></app-tag-list>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<!-- Share -->
|
|
222
|
+
<div class="mb-12 border-t border-b border-gray-200 py-6">
|
|
223
|
+
<app-share-buttons [url]="currentUrl" [title]="post.title"></app-share-buttons>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- Comments Section -->
|
|
227
|
+
<section class="mb-12">
|
|
228
|
+
<h2 class="mb-6 text-2xl font-bold text-gray-900">
|
|
229
|
+
{{ 'blog.comments' | translate }} ({{ comments.length }})
|
|
230
|
+
</h2>
|
|
231
|
+
|
|
232
|
+
<!-- Comment Form -->
|
|
233
|
+
<div class="mb-8 rounded-xl border border-gray-200 bg-gray-50 p-6">
|
|
234
|
+
<h3 class="mb-4 font-semibold text-gray-900">{{ 'blog.leaveComment' | translate }}</h3>
|
|
235
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
236
|
+
<input
|
|
237
|
+
type="text"
|
|
238
|
+
[(ngModel)]="newComment.author"
|
|
239
|
+
[placeholder]="'blog.yourName' | translate"
|
|
240
|
+
class="rounded-lg border border-gray-300 px-4 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
241
|
+
/>
|
|
242
|
+
<input
|
|
243
|
+
type="email"
|
|
244
|
+
[(ngModel)]="newComment.email"
|
|
245
|
+
[placeholder]="'blog.yourEmail' | translate"
|
|
246
|
+
class="rounded-lg border border-gray-300 px-4 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
<textarea
|
|
250
|
+
[(ngModel)]="newComment.content"
|
|
251
|
+
[placeholder]="'blog.yourComment' | translate"
|
|
252
|
+
rows="4"
|
|
253
|
+
class="mt-4 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
254
|
+
></textarea>
|
|
255
|
+
<button
|
|
256
|
+
(click)="submitComment()"
|
|
257
|
+
class="mt-4 rounded-lg bg-primary-500 px-6 py-2 font-medium text-white hover:bg-primary-600">
|
|
258
|
+
{{ 'blog.postComment' | translate }}
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<!-- Comments List -->
|
|
263
|
+
<div class="space-y-4">
|
|
264
|
+
@for (comment of comments; track comment.id) {
|
|
265
|
+
<app-comment [comment]="comment"></app-comment>
|
|
266
|
+
} @empty {
|
|
267
|
+
<p class="text-gray-500">{{ 'blog.noComments' | translate }}</p>
|
|
268
|
+
}
|
|
269
|
+
</div>
|
|
270
|
+
</section>
|
|
271
|
+
|
|
272
|
+
<!-- Related Posts -->
|
|
273
|
+
@if (relatedPosts.length > 0) {
|
|
274
|
+
<section>
|
|
275
|
+
<h2 class="mb-6 text-2xl font-bold text-gray-900">{{ 'blog.relatedPosts' | translate }}</h2>
|
|
276
|
+
<div class="grid gap-8 md:grid-cols-3">
|
|
277
|
+
@for (relatedPost of relatedPosts; track relatedPost.id) {
|
|
278
|
+
<app-post-card [post]="relatedPost"></app-post-card>
|
|
279
|
+
}
|
|
280
|
+
</div>
|
|
281
|
+
</section>
|
|
282
|
+
}
|
|
283
|
+
</article>
|
|
284
|
+
}
|
|
285
|
+
\`,
|
|
286
|
+
})
|
|
287
|
+
export class PostDetailComponent {
|
|
288
|
+
private route = inject(ActivatedRoute);
|
|
289
|
+
private router = inject(Router);
|
|
290
|
+
private blogService = inject(BlogService);
|
|
291
|
+
|
|
292
|
+
post?: Post;
|
|
293
|
+
comments: Comment[] = [];
|
|
294
|
+
relatedPosts: Post[] = [];
|
|
295
|
+
currentUrl = '';
|
|
296
|
+
|
|
297
|
+
newComment = {
|
|
298
|
+
author: '',
|
|
299
|
+
email: '',
|
|
300
|
+
content: '',
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
get renderedContent(): string {
|
|
304
|
+
if (!this.post) return '';
|
|
305
|
+
return this.post.content
|
|
306
|
+
.replace(/^# (.*$)/gm, '<h1 class="text-3xl font-bold mt-8 mb-4">$1</h1>')
|
|
307
|
+
.replace(/^## (.*$)/gm, '<h2 class="text-2xl font-bold mt-6 mb-3">$1</h2>')
|
|
308
|
+
.replace(/^### (.*$)/gm, '<h3 class="text-xl font-bold mt-4 mb-2">$1</h3>')
|
|
309
|
+
.replace(/\\\`\\\`\\\`(\\w+)?\\n([\\s\\S]*?)\\\`\\\`\\\`/g, '<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4"><code>$2</code></pre>')
|
|
310
|
+
.replace(/\\\`([^\\\`]+)\\\`/g, '<code class="bg-gray-100 px-1 rounded text-primary-600">$1</code>')
|
|
311
|
+
.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
|
|
312
|
+
.replace(/\\*([^*]+)\\*/g, '<em>$1</em>')
|
|
313
|
+
.replace(/^(\\d+)\\. (.*$)/gm, '<li class="ml-6 list-decimal">$2</li>')
|
|
314
|
+
.replace(/^- (.*$)/gm, '<li class="ml-6 list-disc">$1</li>')
|
|
315
|
+
.replace(/\\n\\n/g, '</p><p class="my-4">');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
constructor() {
|
|
319
|
+
this.route.params.subscribe(params => {
|
|
320
|
+
const slug = params['slug'];
|
|
321
|
+
this.post = this.blogService.getPostBySlug(slug);
|
|
322
|
+
|
|
323
|
+
if (this.post) {
|
|
324
|
+
this.comments = this.blogService.getCommentsByPostId(this.post.id);
|
|
325
|
+
this.relatedPosts = this.blogService.getRelatedPosts(this.post);
|
|
326
|
+
this.currentUrl = window.location.href;
|
|
327
|
+
} else {
|
|
328
|
+
this.router.navigate(['/']);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
submitComment(): void {
|
|
334
|
+
if (this.post && this.newComment.author && this.newComment.email && this.newComment.content) {
|
|
335
|
+
this.blogService.addComment({
|
|
336
|
+
postId: this.post.id,
|
|
337
|
+
author: this.newComment.author,
|
|
338
|
+
email: this.newComment.email,
|
|
339
|
+
content: this.newComment.content,
|
|
340
|
+
});
|
|
341
|
+
this.comments = this.blogService.getCommentsByPostId(this.post.id);
|
|
342
|
+
this.newComment = { author: '', email: '', content: '' };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}`;
|
|
346
|
+
|
|
347
|
+
await fs.writeFile(
|
|
348
|
+
path.join(config.fullPath, "src/app/features/blog/post-detail/post-detail.component.ts"),
|
|
349
|
+
postDetail
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Category Page
|
|
353
|
+
const category = `import { Component, inject, computed } from '@angular/core';
|
|
354
|
+
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
355
|
+
import { TranslateModule } from '@ngx-translate/core';
|
|
356
|
+
import { PostCardComponent } from '@shared/components/post-card/post-card.component';
|
|
357
|
+
import { BlogService } from '@core/services/blog.service';
|
|
358
|
+
|
|
359
|
+
@Component({
|
|
360
|
+
selector: 'app-category',
|
|
361
|
+
standalone: true,
|
|
362
|
+
imports: [RouterModule, TranslateModule, PostCardComponent],
|
|
363
|
+
template: \`
|
|
364
|
+
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
365
|
+
<div class="mb-12">
|
|
366
|
+
<a routerLink="/" class="text-primary-600 hover:text-primary-700 mb-4 inline-block">
|
|
367
|
+
← {{ 'blog.backToBlog' | translate }}
|
|
368
|
+
</a>
|
|
369
|
+
<h1 class="text-4xl font-bold text-gray-900">{{ categoryName }}</h1>
|
|
370
|
+
<p class="mt-2 text-gray-600">{{ posts().length }} {{ 'blog.postsInCategory' | translate }}</p>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
374
|
+
@for (post of posts(); track post.id) {
|
|
375
|
+
<app-post-card [post]="post"></app-post-card>
|
|
376
|
+
} @empty {
|
|
377
|
+
<p class="col-span-full text-gray-500">{{ 'blog.noPostsInCategory' | translate }}</p>
|
|
378
|
+
}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
\`,
|
|
382
|
+
})
|
|
383
|
+
export class CategoryComponent {
|
|
384
|
+
private route = inject(ActivatedRoute);
|
|
385
|
+
private blogService = inject(BlogService);
|
|
386
|
+
|
|
387
|
+
categoryName = '';
|
|
388
|
+
|
|
389
|
+
posts = computed(() => this.blogService.getPostsByCategory(this.categoryName));
|
|
390
|
+
|
|
391
|
+
constructor() {
|
|
392
|
+
this.route.params.subscribe(params => {
|
|
393
|
+
this.categoryName = params['category'];
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}`;
|
|
397
|
+
|
|
398
|
+
await fs.writeFile(
|
|
399
|
+
path.join(config.fullPath, "src/app/features/blog/category/category.component.ts"),
|
|
400
|
+
category
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// Tag Page
|
|
404
|
+
const tag = `import { Component, inject, computed } from '@angular/core';
|
|
405
|
+
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
406
|
+
import { TranslateModule } from '@ngx-translate/core';
|
|
407
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
408
|
+
import { heroTag } from '@ng-icons/heroicons/outline';
|
|
409
|
+
import { PostCardComponent } from '@shared/components/post-card/post-card.component';
|
|
410
|
+
import { BlogService } from '@core/services/blog.service';
|
|
411
|
+
|
|
412
|
+
@Component({
|
|
413
|
+
selector: 'app-tag',
|
|
414
|
+
standalone: true,
|
|
415
|
+
imports: [RouterModule, TranslateModule, NgIconComponent, PostCardComponent],
|
|
416
|
+
viewProviders: [provideIcons({ heroTag })],
|
|
417
|
+
template: \`
|
|
418
|
+
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
419
|
+
<div class="mb-12">
|
|
420
|
+
<a routerLink="/" class="text-primary-600 hover:text-primary-700 mb-4 inline-block">
|
|
421
|
+
← {{ 'blog.backToBlog' | translate }}
|
|
422
|
+
</a>
|
|
423
|
+
<div class="flex items-center gap-3">
|
|
424
|
+
<ng-icon name="heroTag" size="32" class="text-primary-500"></ng-icon>
|
|
425
|
+
<h1 class="text-4xl font-bold text-gray-900">{{ tagName }}</h1>
|
|
426
|
+
</div>
|
|
427
|
+
<p class="mt-2 text-gray-600">{{ posts().length }} {{ 'blog.postsWithTag' | translate }}</p>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
431
|
+
@for (post of posts(); track post.id) {
|
|
432
|
+
<app-post-card [post]="post"></app-post-card>
|
|
433
|
+
} @empty {
|
|
434
|
+
<p class="col-span-full text-gray-500">{{ 'blog.noPostsWithTag' | translate }}</p>
|
|
435
|
+
}
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
\`,
|
|
439
|
+
})
|
|
440
|
+
export class TagComponent {
|
|
441
|
+
private route = inject(ActivatedRoute);
|
|
442
|
+
private blogService = inject(BlogService);
|
|
443
|
+
|
|
444
|
+
tagName = '';
|
|
445
|
+
|
|
446
|
+
posts = computed(() => this.blogService.getPostsByTag(this.tagName));
|
|
447
|
+
|
|
448
|
+
constructor() {
|
|
449
|
+
this.route.params.subscribe(params => {
|
|
450
|
+
this.tagName = params['tag'];
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}`;
|
|
454
|
+
|
|
455
|
+
await fs.writeFile(
|
|
456
|
+
path.join(config.fullPath, "src/app/features/blog/tag/tag.component.ts"),
|
|
457
|
+
tag
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Author Page
|
|
461
|
+
const author = `import { Component, inject, computed, signal } from '@angular/core';
|
|
462
|
+
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
463
|
+
import { TranslateModule } from '@ngx-translate/core';
|
|
464
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
465
|
+
import { heroGlobeAlt, heroCodeBracket, heroBriefcase } from '@ng-icons/heroicons/outline';
|
|
466
|
+
import { PostCardComponent } from '@shared/components/post-card/post-card.component';
|
|
467
|
+
import { BlogService, Author, Post } from '@core/services/blog.service';
|
|
468
|
+
|
|
469
|
+
@Component({
|
|
470
|
+
selector: 'app-author',
|
|
471
|
+
standalone: true,
|
|
472
|
+
imports: [RouterModule, TranslateModule, NgIconComponent, PostCardComponent],
|
|
473
|
+
viewProviders: [provideIcons({ heroGlobeAlt, heroCodeBracket, heroBriefcase })],
|
|
474
|
+
template: \`
|
|
475
|
+
@if (author()) {
|
|
476
|
+
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
477
|
+
<a routerLink="/" class="text-primary-600 hover:text-primary-700 mb-8 inline-block">
|
|
478
|
+
← {{ 'blog.backToBlog' | translate }}
|
|
479
|
+
</a>
|
|
480
|
+
|
|
481
|
+
<!-- Author Profile -->
|
|
482
|
+
<div class="mb-12 rounded-2xl bg-linear-to-r from-primary-500 to-purple-600 p-8 text-white">
|
|
483
|
+
<div class="flex flex-col items-center gap-6 sm:flex-row">
|
|
484
|
+
<img [src]="author()!.avatar" [alt]="author()!.name" class="h-32 w-32 rounded-full border-4 border-white object-cover shadow-lg" />
|
|
485
|
+
<div class="text-center sm:text-left">
|
|
486
|
+
<h1 class="text-3xl font-bold">{{ author()!.name }}</h1>
|
|
487
|
+
<p class="mt-2 text-primary-100">{{ author()!.bio }}</p>
|
|
488
|
+
|
|
489
|
+
@if (author()!.social) {
|
|
490
|
+
<div class="mt-4 flex justify-center gap-4 sm:justify-start">
|
|
491
|
+
@if (author()!.social!.twitter) {
|
|
492
|
+
<a [href]="'https://twitter.com/' + author()!.social!.twitter" target="_blank" class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30">
|
|
493
|
+
<span class="text-sm font-bold">𝕏</span>
|
|
494
|
+
</a>
|
|
495
|
+
}
|
|
496
|
+
@if (author()!.social!.github) {
|
|
497
|
+
<a [href]="'https://github.com/' + author()!.social!.github" target="_blank" class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30">
|
|
498
|
+
<ng-icon name="heroCodeBracket" size="18"></ng-icon>
|
|
499
|
+
</a>
|
|
500
|
+
}
|
|
501
|
+
@if (author()!.social!.linkedin) {
|
|
502
|
+
<a [href]="'https://linkedin.com/in/' + author()!.social!.linkedin" target="_blank" class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30">
|
|
503
|
+
<ng-icon name="heroBriefcase" size="18"></ng-icon>
|
|
504
|
+
</a>
|
|
505
|
+
}
|
|
506
|
+
</div>
|
|
507
|
+
}
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<!-- Author's Posts -->
|
|
513
|
+
<h2 class="mb-6 text-2xl font-bold text-gray-900">
|
|
514
|
+
{{ 'blog.postsByAuthor' | translate }} ({{ posts().length }})
|
|
515
|
+
</h2>
|
|
516
|
+
|
|
517
|
+
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
518
|
+
@for (post of posts(); track post.id) {
|
|
519
|
+
<app-post-card [post]="post"></app-post-card>
|
|
520
|
+
} @empty {
|
|
521
|
+
<p class="col-span-full text-gray-500">{{ 'blog.noPostsByAuthor' | translate }}</p>
|
|
522
|
+
}
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
}
|
|
526
|
+
\`,
|
|
527
|
+
})
|
|
528
|
+
export class AuthorComponent {
|
|
529
|
+
private route = inject(ActivatedRoute);
|
|
530
|
+
private blogService = inject(BlogService);
|
|
531
|
+
|
|
532
|
+
authorId = signal(0);
|
|
533
|
+
|
|
534
|
+
author = computed(() => {
|
|
535
|
+
const posts = this.blogService.getPostsByAuthor(this.authorId());
|
|
536
|
+
return posts.length > 0 ? posts[0].author : null;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
posts = computed(() => this.blogService.getPostsByAuthor(this.authorId()));
|
|
540
|
+
|
|
541
|
+
constructor() {
|
|
542
|
+
this.route.params.subscribe(params => {
|
|
543
|
+
this.authorId.set(Number(params['id']));
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}`;
|
|
547
|
+
|
|
548
|
+
await fs.writeFile(
|
|
549
|
+
path.join(config.fullPath, "src/app/features/blog/author/author.component.ts"),
|
|
550
|
+
author
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
module.exports = { createPages };
|