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,250 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create Blog Routing
|
|
6
|
+
*/
|
|
7
|
+
async function createRouting(config) {
|
|
8
|
+
const routes = `import { Routes } from '@angular/router';
|
|
9
|
+
|
|
10
|
+
export const routes: Routes = [
|
|
11
|
+
// Home - Blog Post List
|
|
12
|
+
{
|
|
13
|
+
path: '',
|
|
14
|
+
loadComponent: () => import('./features/blog/post-list/post-list.component').then(c => c.PostListComponent)
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
// Blog routes
|
|
18
|
+
{
|
|
19
|
+
path: 'category/:category',
|
|
20
|
+
loadComponent: () => import('./features/blog/category/category.component').then(c => c.CategoryComponent)
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
path: 'tag/:tag',
|
|
24
|
+
loadComponent: () => import('./features/blog/tag/tag.component').then(c => c.TagComponent)
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
path: 'author/:id',
|
|
28
|
+
loadComponent: () => import('./features/blog/author/author.component').then(c => c.AuthorComponent)
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
path: 'post/:slug',
|
|
32
|
+
loadComponent: () => import('./features/blog/post-detail/post-detail.component').then(c => c.PostDetailComponent)
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Auth routes
|
|
36
|
+
{
|
|
37
|
+
path: 'auth',
|
|
38
|
+
loadComponent: () => import('./layout/auth/auth-layout.component').then(c => c.AuthLayoutComponent),
|
|
39
|
+
children: [
|
|
40
|
+
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
|
41
|
+
{ path: 'login', loadComponent: () => import('./features/auth/login/login.component').then(c => c.LoginComponent) },
|
|
42
|
+
{ path: 'register', loadComponent: () => import('./features/auth/register/register.component').then(c => c.RegisterComponent) },
|
|
43
|
+
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password.component').then(c => c.ForgotPasswordComponent) }
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// Catch-all
|
|
48
|
+
{ path: '**', redirectTo: '' }
|
|
49
|
+
];`;
|
|
50
|
+
|
|
51
|
+
await fs.writeFile(path.join(config.fullPath, "src/app/app.routes.ts"), routes);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create Blog App Component
|
|
56
|
+
*/
|
|
57
|
+
async function createAppComponent(config) {
|
|
58
|
+
const appComponent = `import { Component, inject } from '@angular/core';
|
|
59
|
+
import { RouterOutlet, RouterModule } from '@angular/router';
|
|
60
|
+
import { TranslateService } from '@ngx-translate/core';
|
|
61
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
62
|
+
import { heroMagnifyingGlass, heroUser, heroRss, heroEnvelope } from '@ng-icons/heroicons/outline';
|
|
63
|
+
import { BlogService } from '@core/services/blog.service';
|
|
64
|
+
|
|
65
|
+
@Component({
|
|
66
|
+
selector: 'app-root',
|
|
67
|
+
standalone: true,
|
|
68
|
+
imports: [RouterOutlet, RouterModule, NgIconComponent],
|
|
69
|
+
viewProviders: [provideIcons({ heroMagnifyingGlass, heroUser, heroRss, heroEnvelope })],
|
|
70
|
+
template: \`
|
|
71
|
+
<div class="min-h-screen bg-gray-50">
|
|
72
|
+
<!-- Header -->
|
|
73
|
+
<header class="sticky top-0 z-50 border-b border-gray-200 bg-white/95 backdrop-blur-sm shadow-sm">
|
|
74
|
+
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
75
|
+
<div class="flex h-20 items-center justify-between">
|
|
76
|
+
<!-- Logo & Tagline -->
|
|
77
|
+
<div class="flex items-center gap-4">
|
|
78
|
+
<a routerLink="/" class="flex items-center gap-3 group">
|
|
79
|
+
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-primary-500 to-purple-600 text-white font-bold text-2xl shadow-lg group-hover:shadow-xl transition-shadow">
|
|
80
|
+
📝
|
|
81
|
+
</div>
|
|
82
|
+
<div class="hidden sm:block">
|
|
83
|
+
<h1 class="text-2xl font-bold text-gray-900 group-hover:text-primary-600 transition-colors">My Blog</h1>
|
|
84
|
+
<p class="text-xs text-gray-500">Insights & Tutorials</p>
|
|
85
|
+
</div>
|
|
86
|
+
</a>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Desktop Navigation -->
|
|
90
|
+
<nav class="hidden md:flex items-center gap-8">
|
|
91
|
+
<a routerLink="/" routerLinkActive="text-primary-600 font-semibold" [routerLinkActiveOptions]="{exact: true}" class="text-gray-600 hover:text-primary-600 font-medium transition-colors">
|
|
92
|
+
Home
|
|
93
|
+
</a>
|
|
94
|
+
@for (category of blogService.categories(); track category) {
|
|
95
|
+
<a [routerLink]="['/category', category]" routerLinkActive="text-primary-600 font-semibold" class="text-gray-600 hover:text-primary-600 font-medium transition-colors">
|
|
96
|
+
{{ category }}
|
|
97
|
+
</a>
|
|
98
|
+
}
|
|
99
|
+
</nav>
|
|
100
|
+
|
|
101
|
+
<!-- Actions -->
|
|
102
|
+
<div class="flex items-center gap-3">
|
|
103
|
+
<button class="flex h-10 w-10 items-center justify-center rounded-full text-gray-600 hover:bg-gray-100 hover:text-primary-600 transition-colors" aria-label="Search">
|
|
104
|
+
<ng-icon name="heroMagnifyingGlass" size="20"></ng-icon>
|
|
105
|
+
</button>
|
|
106
|
+
<a routerLink="/auth/login" class="flex h-10 w-10 items-center justify-center rounded-full text-gray-600 hover:bg-gray-100 hover:text-primary-600 transition-colors" aria-label="Sign In">
|
|
107
|
+
<ng-icon name="heroUser" size="20"></ng-icon>
|
|
108
|
+
</a>
|
|
109
|
+
<a href="#" class="hidden sm:inline-flex items-center gap-2 rounded-full bg-primary-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-600 shadow-md hover:shadow-lg transition-all">
|
|
110
|
+
<ng-icon name="heroRss" size="16"></ng-icon>
|
|
111
|
+
Subscribe
|
|
112
|
+
</a>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</header>
|
|
117
|
+
|
|
118
|
+
<!-- Main Content -->
|
|
119
|
+
<main class="min-h-screen">
|
|
120
|
+
<router-outlet></router-outlet>
|
|
121
|
+
</main>
|
|
122
|
+
|
|
123
|
+
<!-- Footer -->
|
|
124
|
+
<footer class="border-t border-gray-200 bg-white">
|
|
125
|
+
<div class="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
|
|
126
|
+
<div class="grid gap-12 md:grid-cols-4">
|
|
127
|
+
<!-- About -->
|
|
128
|
+
<div class="md:col-span-2">
|
|
129
|
+
<div class="flex items-center gap-3 mb-4">
|
|
130
|
+
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-primary-500 to-purple-600 text-white font-bold text-2xl shadow-lg">
|
|
131
|
+
📝
|
|
132
|
+
</div>
|
|
133
|
+
<div>
|
|
134
|
+
<h3 class="text-xl font-bold text-gray-900">My Blog</h3>
|
|
135
|
+
<p class="text-xs text-gray-500">Insights & Tutorials</p>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<p class="text-gray-600 mb-6 max-w-md">
|
|
139
|
+
Discover insights, tutorials, and best practices on modern web development, Angular, TypeScript, and more. Join our community of developers.
|
|
140
|
+
</p>
|
|
141
|
+
<div class="flex items-center gap-3">
|
|
142
|
+
<a href="#" class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-primary-500 hover:text-white transition-colors">
|
|
143
|
+
<span class="text-sm font-bold">𝕏</span>
|
|
144
|
+
</a>
|
|
145
|
+
<a href="#" class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-primary-500 hover:text-white transition-colors">
|
|
146
|
+
<span class="text-sm font-bold">in</span>
|
|
147
|
+
</a>
|
|
148
|
+
<a href="#" class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-primary-500 hover:text-white transition-colors">
|
|
149
|
+
<ng-icon name="heroRss" size="18"></ng-icon>
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Categories -->
|
|
155
|
+
<div>
|
|
156
|
+
<h3 class="mb-4 font-semibold text-gray-900">Categories</h3>
|
|
157
|
+
<ul class="space-y-3">
|
|
158
|
+
@for (category of blogService.categories(); track category) {
|
|
159
|
+
<li>
|
|
160
|
+
<a [routerLink]="['/category', category]" class="text-gray-600 hover:text-primary-600 transition-colors">
|
|
161
|
+
{{ category }}
|
|
162
|
+
</a>
|
|
163
|
+
</li>
|
|
164
|
+
}
|
|
165
|
+
</ul>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<!-- Quick Links -->
|
|
169
|
+
<div>
|
|
170
|
+
<h3 class="mb-4 font-semibold text-gray-900">Quick Links</h3>
|
|
171
|
+
<ul class="space-y-3">
|
|
172
|
+
<li><a routerLink="/" class="text-gray-600 hover:text-primary-600 transition-colors">All Posts</a></li>
|
|
173
|
+
<li><a routerLink="/auth/login" class="text-gray-600 hover:text-primary-600 transition-colors">Sign In</a></li>
|
|
174
|
+
<li><a routerLink="/auth/register" class="text-gray-600 hover:text-primary-600 transition-colors">Register</a></li>
|
|
175
|
+
</ul>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- Newsletter -->
|
|
180
|
+
<div class="mt-12 rounded-2xl bg-linear-to-r from-primary-500 to-purple-600 p-8 text-white">
|
|
181
|
+
<div class="mx-auto max-w-3xl text-center">
|
|
182
|
+
<h3 class="text-2xl font-bold mb-2">Subscribe to our Newsletter</h3>
|
|
183
|
+
<p class="mb-6 text-primary-100">Get the latest posts and updates delivered directly to your inbox.</p>
|
|
184
|
+
<div class="flex flex-col sm:flex-row gap-3 max-w-md mx-auto">
|
|
185
|
+
<input
|
|
186
|
+
type="email"
|
|
187
|
+
placeholder="Enter your email"
|
|
188
|
+
class="flex-1 rounded-full px-6 py-3 text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
189
|
+
/>
|
|
190
|
+
<button class="rounded-full bg-white px-8 py-3 font-semibold text-primary-600 hover:bg-gray-100 transition-colors whitespace-nowrap">
|
|
191
|
+
Subscribe
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<!-- Bottom Bar -->
|
|
198
|
+
<div class="mt-12 flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-gray-200 pt-8">
|
|
199
|
+
<p class="text-sm text-gray-600">
|
|
200
|
+
© {{ currentYear }} My Blog. All rights reserved.
|
|
201
|
+
</p>
|
|
202
|
+
<div class="flex items-center gap-6">
|
|
203
|
+
<a href="#" class="text-sm text-gray-600 hover:text-primary-600 transition-colors">Privacy Policy</a>
|
|
204
|
+
<a href="#" class="text-sm text-gray-600 hover:text-primary-600 transition-colors">Terms of Service</a>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</footer>
|
|
209
|
+
</div>
|
|
210
|
+
\`,
|
|
211
|
+
})
|
|
212
|
+
export class App {
|
|
213
|
+
blogService = inject(BlogService);
|
|
214
|
+
currentYear = new Date().getFullYear();
|
|
215
|
+
|
|
216
|
+
constructor(private translate: TranslateService) {
|
|
217
|
+
this.translate.setDefaultLang('en');
|
|
218
|
+
this.translate.use('en');
|
|
219
|
+
}
|
|
220
|
+
}`;
|
|
221
|
+
|
|
222
|
+
await fs.writeFile(path.join(config.fullPath, "src/app/app.ts"), appComponent);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Clean up starter layout files (blog has its own header/footer in app.component)
|
|
227
|
+
*/
|
|
228
|
+
async function cleanupStarterLayout(config) {
|
|
229
|
+
const foldersToRemove = [
|
|
230
|
+
"src/app/layout/header",
|
|
231
|
+
"src/app/layout/footer",
|
|
232
|
+
"src/app/features/home",
|
|
233
|
+
"src/app/features/about",
|
|
234
|
+
"src/app/features/contact",
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
for (const folder of foldersToRemove) {
|
|
238
|
+
try {
|
|
239
|
+
await fs.remove(path.join(config.fullPath, folder));
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// Ignore errors if files don't exist
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
createRouting,
|
|
248
|
+
createAppComponent,
|
|
249
|
+
cleanupStarterLayout,
|
|
250
|
+
};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create Blog Components
|
|
6
|
+
*/
|
|
7
|
+
async function createComponents(config) {
|
|
8
|
+
// Post Card Component
|
|
9
|
+
const postCard = `import { Component, Input } from '@angular/core';
|
|
10
|
+
import { RouterModule } from '@angular/router';
|
|
11
|
+
import { DatePipe } from '@angular/common';
|
|
12
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
13
|
+
import { heroCalendar, heroClock, heroTag } from '@ng-icons/heroicons/outline';
|
|
14
|
+
import { Post } from '@core/services/blog.service';
|
|
15
|
+
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'app-post-card',
|
|
18
|
+
standalone: true,
|
|
19
|
+
imports: [RouterModule, DatePipe, NgIconComponent],
|
|
20
|
+
viewProviders: [provideIcons({ heroCalendar, heroClock, heroTag })],
|
|
21
|
+
template: \`
|
|
22
|
+
<article class="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm transition-all hover:shadow-xl hover:-translate-y-1">
|
|
23
|
+
<a [routerLink]="['/post', post.slug]" class="block">
|
|
24
|
+
<div class="aspect-video overflow-hidden">
|
|
25
|
+
<img
|
|
26
|
+
[src]="post.coverImage"
|
|
27
|
+
[alt]="post.title"
|
|
28
|
+
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
</a>
|
|
32
|
+
|
|
33
|
+
<div class="p-6">
|
|
34
|
+
<div class="mb-3 flex items-center gap-4 text-sm text-gray-500">
|
|
35
|
+
<a [routerLink]="['/category', post.category]" class="text-primary-600 hover:text-primary-700 font-medium">
|
|
36
|
+
{{ post.category }}
|
|
37
|
+
</a>
|
|
38
|
+
<span class="flex items-center gap-1">
|
|
39
|
+
<ng-icon name="heroClock" size="14"></ng-icon>
|
|
40
|
+
{{ post.readingTime }} min read
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<a [routerLink]="['/post', post.slug]">
|
|
45
|
+
<h3 class="mb-2 text-xl font-bold text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
|
|
46
|
+
{{ post.title }}
|
|
47
|
+
</h3>
|
|
48
|
+
</a>
|
|
49
|
+
|
|
50
|
+
<p class="mb-4 text-gray-600 line-clamp-2">{{ post.excerpt }}</p>
|
|
51
|
+
|
|
52
|
+
<div class="flex items-center justify-between">
|
|
53
|
+
<div class="flex items-center gap-3">
|
|
54
|
+
<img [src]="post.author.avatar" [alt]="post.author.name" class="h-8 w-8 rounded-full object-cover" />
|
|
55
|
+
<div>
|
|
56
|
+
<a [routerLink]="['/author', post.author.id]" class="text-sm font-medium text-gray-900 hover:text-primary-600">
|
|
57
|
+
{{ post.author.name }}
|
|
58
|
+
</a>
|
|
59
|
+
<p class="text-xs text-gray-500">{{ post.publishedAt | date:'mediumDate' }}</p>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</article>
|
|
65
|
+
\`,
|
|
66
|
+
})
|
|
67
|
+
export class PostCardComponent {
|
|
68
|
+
@Input({ required: true }) post!: Post;
|
|
69
|
+
}`;
|
|
70
|
+
|
|
71
|
+
await fs.writeFile(
|
|
72
|
+
path.join(config.fullPath, "src/app/shared/components/post-card/post-card.component.ts"),
|
|
73
|
+
postCard
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Comment Component
|
|
77
|
+
const comment = `import { Component, Input, Output, EventEmitter } from '@angular/core';
|
|
78
|
+
import { DatePipe } from '@angular/common';
|
|
79
|
+
import { FormsModule } from '@angular/forms';
|
|
80
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
81
|
+
import { heroArrowUturnLeft, heroUser } from '@ng-icons/heroicons/outline';
|
|
82
|
+
import { Comment } from '@core/services/blog.service';
|
|
83
|
+
|
|
84
|
+
@Component({
|
|
85
|
+
selector: 'app-comment',
|
|
86
|
+
standalone: true,
|
|
87
|
+
imports: [DatePipe, FormsModule, NgIconComponent],
|
|
88
|
+
viewProviders: [provideIcons({ heroArrowUturnLeft, heroUser })],
|
|
89
|
+
template: \`
|
|
90
|
+
<div class="border-b border-gray-100 py-4 last:border-0" [class.ml-8]="isReply">
|
|
91
|
+
<div class="flex gap-4">
|
|
92
|
+
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100">
|
|
93
|
+
<ng-icon name="heroUser" size="20" class="text-gray-500"></ng-icon>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="flex-1">
|
|
97
|
+
<div class="flex items-center gap-2 mb-1">
|
|
98
|
+
<span class="font-semibold text-gray-900">{{ comment.author }}</span>
|
|
99
|
+
<span class="text-sm text-gray-500">{{ comment.createdAt | date:'mediumDate' }}</span>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<p class="text-gray-700 mb-2">{{ comment.content }}</p>
|
|
103
|
+
|
|
104
|
+
@if (!isReply) {
|
|
105
|
+
<button
|
|
106
|
+
(click)="showReplyForm = !showReplyForm"
|
|
107
|
+
class="flex items-center gap-1 text-sm text-gray-500 hover:text-primary-600">
|
|
108
|
+
<ng-icon name="heroArrowUturnLeft" size="16"></ng-icon>
|
|
109
|
+
Reply
|
|
110
|
+
</button>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@if (showReplyForm) {
|
|
114
|
+
<div class="mt-3">
|
|
115
|
+
<textarea
|
|
116
|
+
[(ngModel)]="replyText"
|
|
117
|
+
placeholder="Write a reply..."
|
|
118
|
+
rows="2"
|
|
119
|
+
class="w-full rounded-lg border border-gray-300 p-3 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
120
|
+
></textarea>
|
|
121
|
+
<div class="mt-2 flex gap-2">
|
|
122
|
+
<button
|
|
123
|
+
(click)="submitReply()"
|
|
124
|
+
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">
|
|
125
|
+
Reply
|
|
126
|
+
</button>
|
|
127
|
+
<button
|
|
128
|
+
(click)="showReplyForm = false; replyText = ''"
|
|
129
|
+
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
130
|
+
Cancel
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@if (comment.replies && comment.replies.length > 0) {
|
|
137
|
+
<div class="mt-4">
|
|
138
|
+
@for (reply of comment.replies; track reply.id) {
|
|
139
|
+
<app-comment [comment]="reply" [isReply]="true"></app-comment>
|
|
140
|
+
}
|
|
141
|
+
</div>
|
|
142
|
+
}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
\`,
|
|
147
|
+
})
|
|
148
|
+
export class CommentComponent {
|
|
149
|
+
@Input({ required: true }) comment!: Comment;
|
|
150
|
+
@Input() isReply = false;
|
|
151
|
+
@Output() reply = new EventEmitter<string>();
|
|
152
|
+
|
|
153
|
+
showReplyForm = false;
|
|
154
|
+
replyText = '';
|
|
155
|
+
|
|
156
|
+
submitReply(): void {
|
|
157
|
+
if (this.replyText.trim()) {
|
|
158
|
+
this.reply.emit(this.replyText);
|
|
159
|
+
this.replyText = '';
|
|
160
|
+
this.showReplyForm = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}`;
|
|
164
|
+
|
|
165
|
+
await fs.writeFile(
|
|
166
|
+
path.join(config.fullPath, "src/app/shared/components/comment/comment.component.ts"),
|
|
167
|
+
comment
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Share Buttons Component
|
|
171
|
+
const shareButtons = `import { Component, Input } from '@angular/core';
|
|
172
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
173
|
+
import { heroLink, heroShare } from '@ng-icons/heroicons/outline';
|
|
174
|
+
|
|
175
|
+
@Component({
|
|
176
|
+
selector: 'app-share-buttons',
|
|
177
|
+
standalone: true,
|
|
178
|
+
imports: [NgIconComponent],
|
|
179
|
+
viewProviders: [provideIcons({ heroLink, heroShare })],
|
|
180
|
+
template: \`
|
|
181
|
+
<div class="flex items-center gap-2">
|
|
182
|
+
<span class="text-sm font-medium text-gray-700">Share:</span>
|
|
183
|
+
|
|
184
|
+
<a
|
|
185
|
+
[href]="twitterUrl"
|
|
186
|
+
target="_blank"
|
|
187
|
+
rel="noopener noreferrer"
|
|
188
|
+
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-blue-500 hover:text-white transition-colors"
|
|
189
|
+
aria-label="Share on Twitter">
|
|
190
|
+
<span class="text-sm font-bold">𝕏</span>
|
|
191
|
+
</a>
|
|
192
|
+
|
|
193
|
+
<a
|
|
194
|
+
[href]="facebookUrl"
|
|
195
|
+
target="_blank"
|
|
196
|
+
rel="noopener noreferrer"
|
|
197
|
+
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-blue-600 hover:text-white transition-colors"
|
|
198
|
+
aria-label="Share on Facebook">
|
|
199
|
+
<span class="text-sm font-bold">f</span>
|
|
200
|
+
</a>
|
|
201
|
+
|
|
202
|
+
<a
|
|
203
|
+
[href]="linkedinUrl"
|
|
204
|
+
target="_blank"
|
|
205
|
+
rel="noopener noreferrer"
|
|
206
|
+
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-blue-700 hover:text-white transition-colors"
|
|
207
|
+
aria-label="Share on LinkedIn">
|
|
208
|
+
<span class="text-sm font-bold">in</span>
|
|
209
|
+
</a>
|
|
210
|
+
|
|
211
|
+
<button
|
|
212
|
+
(click)="copyLink()"
|
|
213
|
+
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
|
214
|
+
aria-label="Copy link">
|
|
215
|
+
<ng-icon name="heroLink" size="18"></ng-icon>
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
\`,
|
|
219
|
+
})
|
|
220
|
+
export class ShareButtonsComponent {
|
|
221
|
+
@Input({ required: true }) url!: string;
|
|
222
|
+
@Input({ required: true }) title!: string;
|
|
223
|
+
|
|
224
|
+
get twitterUrl(): string {
|
|
225
|
+
return \`https://twitter.com/intent/tweet?text=\${encodeURIComponent(this.title)}&url=\${encodeURIComponent(this.url)}\`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get facebookUrl(): string {
|
|
229
|
+
return \`https://www.facebook.com/sharer/sharer.php?u=\${encodeURIComponent(this.url)}\`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
get linkedinUrl(): string {
|
|
233
|
+
return \`https://www.linkedin.com/sharing/share-offsite/?url=\${encodeURIComponent(this.url)}\`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
copyLink(): void {
|
|
237
|
+
navigator.clipboard.writeText(this.url).then(() => {
|
|
238
|
+
alert('Link copied to clipboard!');
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}`;
|
|
242
|
+
|
|
243
|
+
await fs.writeFile(
|
|
244
|
+
path.join(config.fullPath, "src/app/shared/components/share-buttons/share-buttons.component.ts"),
|
|
245
|
+
shareButtons
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Pagination Component
|
|
249
|
+
const pagination = `import { Component, Input, Output, EventEmitter } from '@angular/core';
|
|
250
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
251
|
+
import { heroChevronLeft, heroChevronRight } from '@ng-icons/heroicons/outline';
|
|
252
|
+
|
|
253
|
+
@Component({
|
|
254
|
+
selector: 'app-pagination',
|
|
255
|
+
standalone: true,
|
|
256
|
+
imports: [NgIconComponent],
|
|
257
|
+
viewProviders: [provideIcons({ heroChevronLeft, heroChevronRight })],
|
|
258
|
+
template: \`
|
|
259
|
+
<nav class="flex items-center justify-center gap-2" aria-label="Pagination">
|
|
260
|
+
<button
|
|
261
|
+
(click)="goToPage(currentPage - 1)"
|
|
262
|
+
[disabled]="currentPage === 1"
|
|
263
|
+
class="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
264
|
+
<ng-icon name="heroChevronLeft" size="20"></ng-icon>
|
|
265
|
+
</button>
|
|
266
|
+
|
|
267
|
+
@for (page of visiblePages; track page) {
|
|
268
|
+
@if (page === '...') {
|
|
269
|
+
<span class="px-2 text-gray-500">...</span>
|
|
270
|
+
} @else {
|
|
271
|
+
<button
|
|
272
|
+
(click)="goToPage(+page)"
|
|
273
|
+
class="flex h-10 w-10 items-center justify-center rounded-lg text-sm font-medium transition-colors"
|
|
274
|
+
[class.bg-primary-500]="currentPage === page"
|
|
275
|
+
[class.text-white]="currentPage === page"
|
|
276
|
+
[class.border]="currentPage !== page"
|
|
277
|
+
[class.border-gray-300]="currentPage !== page"
|
|
278
|
+
[class.text-gray-700]="currentPage !== page"
|
|
279
|
+
[class.hover:bg-gray-50]="currentPage !== page">
|
|
280
|
+
{{ page }}
|
|
281
|
+
</button>
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
<button
|
|
286
|
+
(click)="goToPage(currentPage + 1)"
|
|
287
|
+
[disabled]="currentPage === totalPages"
|
|
288
|
+
class="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
289
|
+
<ng-icon name="heroChevronRight" size="20"></ng-icon>
|
|
290
|
+
</button>
|
|
291
|
+
</nav>
|
|
292
|
+
\`,
|
|
293
|
+
})
|
|
294
|
+
export class PaginationComponent {
|
|
295
|
+
@Input() currentPage = 1;
|
|
296
|
+
@Input() totalPages = 1;
|
|
297
|
+
@Output() pageChange = new EventEmitter<number>();
|
|
298
|
+
|
|
299
|
+
get visiblePages(): (number | string)[] {
|
|
300
|
+
const pages: (number | string)[] = [];
|
|
301
|
+
const delta = 2;
|
|
302
|
+
|
|
303
|
+
for (let i = 1; i <= this.totalPages; i++) {
|
|
304
|
+
if (i === 1 || i === this.totalPages || (i >= this.currentPage - delta && i <= this.currentPage + delta)) {
|
|
305
|
+
pages.push(i);
|
|
306
|
+
} else if (pages[pages.length - 1] !== '...') {
|
|
307
|
+
pages.push('...');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return pages;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
goToPage(page: number): void {
|
|
315
|
+
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
|
316
|
+
this.pageChange.emit(page);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}`;
|
|
320
|
+
|
|
321
|
+
await fs.writeFile(
|
|
322
|
+
path.join(config.fullPath, "src/app/shared/components/pagination/pagination.component.ts"),
|
|
323
|
+
pagination
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Tag List Component
|
|
327
|
+
const tagList = `import { Component, Input } from '@angular/core';
|
|
328
|
+
import { RouterModule } from '@angular/router';
|
|
329
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
330
|
+
import { heroTag } from '@ng-icons/heroicons/outline';
|
|
331
|
+
|
|
332
|
+
@Component({
|
|
333
|
+
selector: 'app-tag-list',
|
|
334
|
+
standalone: true,
|
|
335
|
+
imports: [RouterModule, NgIconComponent],
|
|
336
|
+
viewProviders: [provideIcons({ heroTag })],
|
|
337
|
+
template: \`
|
|
338
|
+
<div class="flex flex-wrap gap-2">
|
|
339
|
+
@for (tag of tags; track tag) {
|
|
340
|
+
<a
|
|
341
|
+
[routerLink]="['/tag', tag]"
|
|
342
|
+
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-primary-100 hover:text-primary-700 transition-colors">
|
|
343
|
+
<ng-icon name="heroTag" size="14"></ng-icon>
|
|
344
|
+
{{ tag }}
|
|
345
|
+
</a>
|
|
346
|
+
}
|
|
347
|
+
</div>
|
|
348
|
+
\`,
|
|
349
|
+
})
|
|
350
|
+
export class TagListComponent {
|
|
351
|
+
@Input({ required: true }) tags!: string[];
|
|
352
|
+
}`;
|
|
353
|
+
|
|
354
|
+
await fs.writeFile(
|
|
355
|
+
path.join(config.fullPath, "src/app/shared/components/tag-list/tag-list.component.ts"),
|
|
356
|
+
tagList
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = { createComponents };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create Blog i18n Translations
|
|
6
|
+
*/
|
|
7
|
+
async function createI18n(config) {
|
|
8
|
+
const enPath = path.join(config.fullPath, "public/assets/i18n/en.json");
|
|
9
|
+
const arPath = path.join(config.fullPath, "public/assets/i18n/ar.json");
|
|
10
|
+
|
|
11
|
+
let en = {};
|
|
12
|
+
let ar = {};
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
en = JSON.parse(await fs.readFile(enPath, "utf-8"));
|
|
16
|
+
ar = JSON.parse(await fs.readFile(arPath, "utf-8"));
|
|
17
|
+
} catch {
|
|
18
|
+
// Files don't exist yet
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const blogEn = {
|
|
22
|
+
blog: {
|
|
23
|
+
title: "Blog",
|
|
24
|
+
subtitle: "Insights, tutorials, and updates from our team",
|
|
25
|
+
searchPlaceholder: "Search articles...",
|
|
26
|
+
featured: "Featured Posts",
|
|
27
|
+
all: "All",
|
|
28
|
+
noPostsFound: "No posts found",
|
|
29
|
+
backToBlog: "Back to Blog",
|
|
30
|
+
comments: "Comments",
|
|
31
|
+
leaveComment: "Leave a Comment",
|
|
32
|
+
yourName: "Your Name",
|
|
33
|
+
yourEmail: "Your Email",
|
|
34
|
+
yourComment: "Your Comment",
|
|
35
|
+
postComment: "Post Comment",
|
|
36
|
+
noComments: "No comments yet. Be the first to comment!",
|
|
37
|
+
relatedPosts: "Related Posts",
|
|
38
|
+
postsInCategory: "posts in this category",
|
|
39
|
+
noPostsInCategory: "No posts in this category",
|
|
40
|
+
postsWithTag: "posts with this tag",
|
|
41
|
+
noPostsWithTag: "No posts with this tag",
|
|
42
|
+
postsByAuthor: "Posts by this author",
|
|
43
|
+
noPostsByAuthor: "No posts by this author",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const blogAr = {
|
|
48
|
+
blog: {
|
|
49
|
+
title: "المدونة",
|
|
50
|
+
subtitle: "رؤى ودروس وتحديثات من فريقنا",
|
|
51
|
+
searchPlaceholder: "ابحث في المقالات...",
|
|
52
|
+
featured: "مقالات مميزة",
|
|
53
|
+
all: "الكل",
|
|
54
|
+
noPostsFound: "لم يتم العثور على مقالات",
|
|
55
|
+
backToBlog: "العودة إلى المدونة",
|
|
56
|
+
comments: "التعليقات",
|
|
57
|
+
leaveComment: "اترك تعليقاً",
|
|
58
|
+
yourName: "اسمك",
|
|
59
|
+
yourEmail: "بريدك الإلكتروني",
|
|
60
|
+
yourComment: "تعليقك",
|
|
61
|
+
postComment: "نشر التعليق",
|
|
62
|
+
noComments: "لا توجد تعليقات بعد. كن أول من يعلق!",
|
|
63
|
+
relatedPosts: "مقالات ذات صلة",
|
|
64
|
+
postsInCategory: "مقالات في هذه الفئة",
|
|
65
|
+
noPostsInCategory: "لا توجد مقالات في هذه الفئة",
|
|
66
|
+
postsWithTag: "مقالات بهذه العلامة",
|
|
67
|
+
noPostsWithTag: "لا توجد مقالات بهذه العلامة",
|
|
68
|
+
postsByAuthor: "مقالات هذا الكاتب",
|
|
69
|
+
noPostsByAuthor: "لا توجد مقالات لهذا الكاتب",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await fs.writeFile(enPath, JSON.stringify({ ...en, ...blogEn }, null, 2));
|
|
74
|
+
await fs.writeFile(arPath, JSON.stringify({ ...ar, ...blogAr }, null, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { createI18n };
|