@navneet_25/tempjs 2.0.0 → 3.0.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 (90) hide show
  1. package/README.md +66 -18
  2. package/cli/brand-manager.js +183 -96
  3. package/cli/config.js +9 -1
  4. package/cli/doctor.js +393 -0
  5. package/cli/fetch-core-modules.js +130 -0
  6. package/cli/index.js +141 -10
  7. package/cli/info.js +28 -1
  8. package/cli/init-options.js +209 -0
  9. package/cli/module-manager.js +168 -0
  10. package/cli/parse-args.js +21 -0
  11. package/cli/update.js +15 -0
  12. package/cli/version-manager.js +518 -0
  13. package/package.json +27 -4
  14. package/packages/core/modules/README.md +72 -0
  15. package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
  16. package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
  17. package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
  18. package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
  19. package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
  20. package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
  21. package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
  22. package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
  23. package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
  24. package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
  25. package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
  26. package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
  27. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
  28. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
  29. package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
  30. package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
  31. package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
  32. package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
  33. package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
  34. package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
  35. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
  36. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
  37. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
  38. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
  39. package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
  40. package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
  41. package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
  42. package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
  43. package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
  44. package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
  45. package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
  46. package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
  47. package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
  48. package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
  49. package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
  50. package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
  51. package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
  52. package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
  53. package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
  54. package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
  55. package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
  56. package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
  57. package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
  58. package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
  59. package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
  60. package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
  61. package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
  62. package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
  63. package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
  64. package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
  65. package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
  66. package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
  67. package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
  68. package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
  69. package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
  70. package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
  71. package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
  72. package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
  73. package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
  74. package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
  75. package/packages/core/modules/seo/app/robots.ts +30 -0
  76. package/packages/core/modules/seo/app/sitemap.ts +9 -0
  77. package/packages/core/modules/seo/lib/seo/index.ts +5 -0
  78. package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
  79. package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
  80. package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
  81. package/packages/core/modules/seo/lib/seo/types.ts +30 -0
  82. package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
  83. package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
  84. package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
  85. package/packages/core/modules.json +140 -0
  86. package/packages/core/prisma/schema.prisma +33 -0
  87. package/scripts/module-installer-core.mjs +657 -0
  88. package/scripts/template-modules-admin.mjs +366 -0
  89. package/scripts/template-modules-home.mjs +120 -0
  90. package/templates.json +163 -8
@@ -0,0 +1,44 @@
1
+ import type { ComposeBlock } from "@/lib/blog/compose";
2
+ import { getComposeBlockDefinition } from "@/lib/blog/compose";
3
+
4
+ export function blockToFormValues(block: ComposeBlock): Record<string, string> {
5
+ const def = getComposeBlockDefinition(block.type);
6
+ const values: Record<string, string> = {};
7
+ const raw = block as Record<string, unknown>;
8
+
9
+ for (const field of def.fields) {
10
+ const v = raw[field.key];
11
+ if (field.input === "tags") {
12
+ values[field.key] = Array.isArray(v) ? (v as string[]).join(", ") : "";
13
+ } else if (field.input === "toggle") {
14
+ values[field.key] = v === true ? "true" : "false";
15
+ } else {
16
+ values[field.key] = typeof v === "string" ? v : String(v ?? "");
17
+ }
18
+ }
19
+ return values;
20
+ }
21
+
22
+ export function formValuesToBlock(
23
+ type: ComposeBlock["type"],
24
+ values: Record<string, string>
25
+ ): ComposeBlock {
26
+ const def = getComposeBlockDefinition(type);
27
+ const block = def.createDefault() as Record<string, unknown>;
28
+
29
+ for (const field of def.fields) {
30
+ const raw = values[field.key] ?? "";
31
+ if (field.input === "tags") {
32
+ block[field.key] = raw
33
+ .split(",")
34
+ .map((s) => s.trim())
35
+ .filter(Boolean);
36
+ } else if (field.input === "toggle") {
37
+ block[field.key] = raw === "true";
38
+ } else {
39
+ block[field.key] = raw;
40
+ }
41
+ }
42
+
43
+ return block as ComposeBlock;
44
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import {
5
+ createEmptyComposeDocument,
6
+ isComposeDocument,
7
+ type ComposeDocument,
8
+ } from "@/lib/blog/compose";
9
+
10
+ export interface BlogPostData {
11
+ id?: string;
12
+ title: string;
13
+ slug?: string;
14
+ excerpt?: string | null;
15
+ coverImage?: string | null;
16
+ published?: boolean;
17
+ content: ComposeDocument;
18
+ }
19
+
20
+ interface BlogPostApiRow {
21
+ id: string;
22
+ slug: string;
23
+ title: string;
24
+ excerpt?: string | null;
25
+ coverImage?: string | null;
26
+ published?: boolean;
27
+ contentJson?: unknown;
28
+ content?: ComposeDocument;
29
+ }
30
+
31
+ function mapApiPost(row: BlogPostApiRow): BlogPostData {
32
+ const content =
33
+ row.content ??
34
+ (isComposeDocument(row.contentJson) ? row.contentJson : createEmptyComposeDocument());
35
+ return {
36
+ id: row.id,
37
+ title: row.title,
38
+ slug: row.slug,
39
+ excerpt: row.excerpt,
40
+ coverImage: row.coverImage,
41
+ published: row.published,
42
+ content,
43
+ };
44
+ }
45
+
46
+ export function useGetBlogPosts() {
47
+ return useQuery({
48
+ queryKey: ["blog-posts"],
49
+ queryFn: async () => {
50
+ const res = await fetch("/api/blog-posts");
51
+ const json = await res.json();
52
+ if (!json.success) throw new Error(json.error || "Failed to load blog posts");
53
+ return (json.data as BlogPostApiRow[]).map(mapApiPost);
54
+ },
55
+ });
56
+ }
57
+
58
+ export function useCreateBlogPost() {
59
+ const qc = useQueryClient();
60
+ return useMutation({
61
+ mutationFn: async (data: BlogPostData) => {
62
+ const res = await fetch("/api/blog-posts", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify(data),
66
+ });
67
+ const json = await res.json();
68
+ if (!json.success) throw new Error(json.error || "Failed to create post");
69
+ return json.data;
70
+ },
71
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["blog-posts"] }),
72
+ });
73
+ }
74
+
75
+ export function useUpdateBlogPost() {
76
+ const qc = useQueryClient();
77
+ return useMutation({
78
+ mutationFn: async ({ id, data }: { id: string; data: BlogPostData }) => {
79
+ const res = await fetch(`/api/blog-posts/${id}`, {
80
+ method: "PUT",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify(data),
83
+ });
84
+ const json = await res.json();
85
+ if (!json.success) throw new Error(json.error || "Failed to update post");
86
+ return json.data;
87
+ },
88
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["blog-posts"] }),
89
+ });
90
+ }
91
+
92
+ export function useDeleteBlogPost() {
93
+ const qc = useQueryClient();
94
+ return useMutation({
95
+ mutationFn: async (id: string) => {
96
+ const res = await fetch(`/api/blog-posts/${id}`, { method: "DELETE" });
97
+ const json = await res.json();
98
+ if (!json.success) throw new Error(json.error || "Failed to delete post");
99
+ return json.data;
100
+ },
101
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["blog-posts"] }),
102
+ });
103
+ }
@@ -0,0 +1,388 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useState } from "react";
4
+ import { Loader2, Plus, Trash2, ChevronUp, ChevronDown, Save } from "lucide-react";
5
+ import {
6
+ COMPOSE_BLOCK_TYPES,
7
+ ComposeBlogRenderer,
8
+ createEmptyComposeDocument,
9
+ getComposeBlockDefinition,
10
+ isComposeDocument,
11
+ type ComposeBlock,
12
+ type ComposeDocument,
13
+ } from "@/lib/blog/compose";
14
+ import {
15
+ useCreateBlogPost,
16
+ useDeleteBlogPost,
17
+ useGetBlogPosts,
18
+ useUpdateBlogPost,
19
+ type BlogPostData,
20
+ } from "../hooks/useBlogPosts";
21
+ import { blockToFormValues, formValuesToBlock } from "./blog-compose-form";
22
+
23
+ function BlockFieldEditor({
24
+ block,
25
+ onChange,
26
+ }: {
27
+ block: ComposeBlock;
28
+ onChange: (block: ComposeBlock) => void;
29
+ }) {
30
+ const def = getComposeBlockDefinition(block.type);
31
+ const values = blockToFormValues(block);
32
+
33
+ function update(key: string, value: string) {
34
+ const nextValues = { ...values, [key]: value };
35
+ onChange(formValuesToBlock(block.type, nextValues));
36
+ }
37
+
38
+ return (
39
+ <div className="space-y-4">
40
+ {def.fields.map((field) => {
41
+ if (field.input === "toggle") {
42
+ return (
43
+ <label key={field.key} className="flex items-center gap-2 text-sm cursor-pointer">
44
+ <input
45
+ type="checkbox"
46
+ checked={values[field.key] === "true"}
47
+ onChange={(e) => update(field.key, e.target.checked ? "true" : "false")}
48
+ />
49
+ <span className="font-medium">{field.label}</span>
50
+ </label>
51
+ );
52
+ }
53
+ if (field.input === "select") {
54
+ return (
55
+ <div key={field.key}>
56
+ <label className="text-xs font-bold uppercase tracking-wider text-text-muted">
57
+ {field.label}
58
+ </label>
59
+ <select
60
+ className="mt-1 w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
61
+ value={values[field.key]}
62
+ onChange={(e) => update(field.key, e.target.value)}
63
+ >
64
+ {field.options?.map((o) => (
65
+ <option key={o.value} value={o.value}>{o.label}</option>
66
+ ))}
67
+ </select>
68
+ </div>
69
+ );
70
+ }
71
+ const isTextarea = field.input === "textarea" || field.input === "tags";
72
+ return (
73
+ <div key={field.key}>
74
+ <label className="text-xs font-bold uppercase tracking-wider text-text-muted">
75
+ {field.label}
76
+ </label>
77
+ {field.description && (
78
+ <p className="text-[10px] text-text-muted mt-0.5">{field.description}</p>
79
+ )}
80
+ {isTextarea ? (
81
+ <textarea
82
+ className="mt-1 w-full border border-primary/20 rounded-lg px-3 py-2 text-sm min-h-[80px]"
83
+ value={values[field.key]}
84
+ placeholder={field.placeholder}
85
+ onChange={(e) => update(field.key, e.target.value)}
86
+ />
87
+ ) : (
88
+ <input
89
+ className="mt-1 w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
90
+ value={values[field.key]}
91
+ placeholder={field.placeholder}
92
+ onChange={(e) => update(field.key, e.target.value)}
93
+ />
94
+ )}
95
+ </div>
96
+ );
97
+ })}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export default function BlogComposePanel() {
103
+ const { data: posts = [], isLoading } = useGetBlogPosts();
104
+ const createPost = useCreateBlogPost();
105
+ const updatePost = useUpdateBlogPost();
106
+ const deletePost = useDeleteBlogPost();
107
+
108
+ const [editingId, setEditingId] = useState<string | null>(null);
109
+ const [editorOpen, setEditorOpen] = useState(false);
110
+ const [title, setTitle] = useState("");
111
+ const [slug, setSlug] = useState("");
112
+ const [excerpt, setExcerpt] = useState("");
113
+ const [published, setPublished] = useState(false);
114
+ const [document, setDocument] = useState<ComposeDocument>(createEmptyComposeDocument());
115
+ const [selectedBlockIndex, setSelectedBlockIndex] = useState<number | null>(null);
116
+ const [error, setError] = useState("");
117
+
118
+ const previewDoc = useMemo(() => document, [document]);
119
+
120
+ function startNew() {
121
+ setEditingId(null);
122
+ setEditorOpen(true);
123
+ setTitle("");
124
+ setSlug("");
125
+ setExcerpt("");
126
+ setPublished(false);
127
+ setDocument(createEmptyComposeDocument());
128
+ setSelectedBlockIndex(null);
129
+ setError("");
130
+ }
131
+
132
+ function startEdit(post: BlogPostData & { id: string }) {
133
+ setEditingId(post.id);
134
+ setEditorOpen(true);
135
+ setTitle(post.title);
136
+ setSlug(post.slug ?? "");
137
+ setExcerpt(post.excerpt ?? "");
138
+ setPublished(post.published ?? false);
139
+ setDocument(isComposeDocument(post.content) ? post.content : createEmptyComposeDocument());
140
+ setSelectedBlockIndex(null);
141
+ setError("");
142
+ }
143
+
144
+ function addBlock(type: ComposeBlock["type"]) {
145
+ const def = getComposeBlockDefinition(type);
146
+ const blocks = [...document.blocks, def.createDefault()];
147
+ setDocument({ ...document, blocks });
148
+ setSelectedBlockIndex(blocks.length - 1);
149
+ }
150
+
151
+ function moveBlock(index: number, dir: -1 | 1) {
152
+ const next = index + dir;
153
+ if (next < 0 || next >= document.blocks.length) return;
154
+ const blocks = [...document.blocks];
155
+ const [item] = blocks.splice(index, 1);
156
+ blocks.splice(next, 0, item);
157
+ setDocument({ ...document, blocks });
158
+ setSelectedBlockIndex(next);
159
+ }
160
+
161
+ function removeBlock(index: number) {
162
+ const blocks = document.blocks.filter((_, i) => i !== index);
163
+ setDocument({ ...document, blocks });
164
+ setSelectedBlockIndex(null);
165
+ }
166
+
167
+ function updateBlock(index: number, block: ComposeBlock) {
168
+ const blocks = [...document.blocks];
169
+ blocks[index] = block;
170
+ setDocument({ ...document, blocks });
171
+ }
172
+
173
+ async function handleSave() {
174
+ setError("");
175
+ const payload: BlogPostData = {
176
+ title,
177
+ slug: slug || undefined,
178
+ excerpt: excerpt || null,
179
+ published,
180
+ content: document,
181
+ };
182
+ try {
183
+ if (editingId) {
184
+ await updatePost.mutateAsync({ id: editingId, data: payload });
185
+ } else {
186
+ await createPost.mutateAsync(payload);
187
+ }
188
+ startNew();
189
+ setEditorOpen(false);
190
+ } catch (err: unknown) {
191
+ setError(err instanceof Error ? err.message : "Save failed");
192
+ }
193
+ }
194
+
195
+ const saving = createPost.isPending || updatePost.isPending;
196
+ const selectedBlock =
197
+ selectedBlockIndex != null ? document.blocks[selectedBlockIndex] : null;
198
+
199
+ return (
200
+ <div className="space-y-8 max-w-6xl">
201
+ <div className="flex flex-wrap items-center justify-between gap-4">
202
+ <div>
203
+ <h3 className="text-lg font-bold text-primary">Compose Blog Engine</h3>
204
+ <p className="text-xs text-text-muted mt-1">
205
+ Build articles from typed blocks — JSON stored in the database, rendered by the compose interpreter.
206
+ </p>
207
+ </div>
208
+ <button
209
+ type="button"
210
+ onClick={startNew}
211
+ className="flex items-center gap-2 px-4 py-2 bg-primary text-white text-xs font-bold rounded-xl cursor-pointer"
212
+ >
213
+ <Plus className="w-4 h-4" />
214
+ New article
215
+ </button>
216
+ </div>
217
+
218
+ {isLoading ? (
219
+ <div className="flex justify-center py-12">
220
+ <Loader2 className="w-6 h-6 animate-spin text-text-muted" />
221
+ </div>
222
+ ) : (
223
+ <div className="rounded-xl border border-primary/10 overflow-hidden">
224
+ <table className="w-full text-sm">
225
+ <thead className="bg-bg-light text-left text-[10px] font-bold uppercase tracking-widest text-text-muted">
226
+ <tr>
227
+ <th className="px-4 py-3">Title</th>
228
+ <th className="px-4 py-3">Slug</th>
229
+ <th className="px-4 py-3">Status</th>
230
+ <th className="px-4 py-3 w-24" />
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ {posts.map((post) => (
235
+ <tr key={post.id} className="border-t border-primary/5">
236
+ <td className="px-4 py-3 font-medium">{post.title}</td>
237
+ <td className="px-4 py-3 text-text-muted">{post.slug}</td>
238
+ <td className="px-4 py-3">
239
+ <span
240
+ className={`text-[10px] font-bold uppercase px-2 py-1 rounded-full ${
241
+ post.published
242
+ ? "bg-emerald-100 text-emerald-800"
243
+ : "bg-bg-light text-text-muted"
244
+ }`}
245
+ >
246
+ {post.published ? "Published" : "Draft"}
247
+ </span>
248
+ </td>
249
+ <td className="px-4 py-3 flex gap-2">
250
+ <button
251
+ type="button"
252
+ onClick={() => startEdit(post as BlogPostData & { id: string })}
253
+ className="text-xs font-bold text-primary cursor-pointer"
254
+ >
255
+ Edit
256
+ </button>
257
+ <button
258
+ type="button"
259
+ onClick={() => post.id && deletePost.mutate(post.id)}
260
+ className="text-xs font-bold text-red-600 cursor-pointer"
261
+ >
262
+ Delete
263
+ </button>
264
+ </td>
265
+ </tr>
266
+ ))}
267
+ </tbody>
268
+ </table>
269
+ </div>
270
+ )}
271
+
272
+ {(editorOpen || editingId !== null) && (
273
+ <div className="grid lg:grid-cols-2 gap-8 border-t border-primary/10 pt-8">
274
+ <div className="space-y-6">
275
+ <h4 className="font-bold text-primary">Article meta</h4>
276
+ {error && (
277
+ <div className="text-xs text-red-700 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
278
+ {error}
279
+ </div>
280
+ )}
281
+ <input
282
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
283
+ placeholder="Title*"
284
+ value={title}
285
+ onChange={(e) => setTitle(e.target.value)}
286
+ />
287
+ <input
288
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
289
+ placeholder="Slug (optional — auto from title)"
290
+ value={slug}
291
+ onChange={(e) => setSlug(e.target.value)}
292
+ />
293
+ <textarea
294
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm resize-none"
295
+ placeholder="Excerpt (listing cards)"
296
+ rows={2}
297
+ value={excerpt}
298
+ onChange={(e) => setExcerpt(e.target.value)}
299
+ />
300
+ <label className="flex items-center gap-2 text-sm cursor-pointer">
301
+ <input type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} />
302
+ Publish on save
303
+ </label>
304
+
305
+ <div className="border-t border-primary/10 pt-6">
306
+ <div className="flex flex-wrap items-center gap-2 mb-4">
307
+ <span className="text-xs font-bold uppercase tracking-widest text-text-muted">Add block</span>
308
+ {COMPOSE_BLOCK_TYPES.map((type) => (
309
+ <button
310
+ key={type}
311
+ type="button"
312
+ onClick={() => addBlock(type)}
313
+ className="px-3 py-1.5 rounded-lg border border-primary/15 text-[10px] font-bold uppercase tracking-wide hover:bg-bg-light cursor-pointer"
314
+ >
315
+ {getComposeBlockDefinition(type).label}
316
+ </button>
317
+ ))}
318
+ </div>
319
+
320
+ <ul className="space-y-2">
321
+ {document.blocks.map((block, index) => (
322
+ <li
323
+ key={index}
324
+ className={`flex items-center gap-2 rounded-lg border px-3 py-2 ${
325
+ selectedBlockIndex === index
326
+ ? "border-primary bg-primary/5"
327
+ : "border-primary/10"
328
+ }`}
329
+ >
330
+ <button
331
+ type="button"
332
+ className="flex-1 text-left text-xs font-bold cursor-pointer"
333
+ onClick={() => setSelectedBlockIndex(index)}
334
+ >
335
+ {getComposeBlockDefinition(block.type).label}
336
+ <span className="text-text-muted font-normal ml-2 truncate">
337
+ {(block as { text?: string; command?: string; code?: string }).text?.slice(0, 40) ||
338
+ (block as { command?: string }).command?.slice(0, 40) ||
339
+ (block as { code?: string }).code?.slice(0, 40) ||
340
+ ""}
341
+ </span>
342
+ </button>
343
+ <button type="button" onClick={() => moveBlock(index, -1)} className="p-1 cursor-pointer">
344
+ <ChevronUp className="w-4 h-4" />
345
+ </button>
346
+ <button type="button" onClick={() => moveBlock(index, 1)} className="p-1 cursor-pointer">
347
+ <ChevronDown className="w-4 h-4" />
348
+ </button>
349
+ <button type="button" onClick={() => removeBlock(index)} className="p-1 text-red-600 cursor-pointer">
350
+ <Trash2 className="w-4 h-4" />
351
+ </button>
352
+ </li>
353
+ ))}
354
+ </ul>
355
+
356
+ {selectedBlock && selectedBlockIndex !== null && (
357
+ <div className="mt-6 p-4 rounded-xl border border-primary/10 bg-bg-light">
358
+ <p className="text-xs font-bold uppercase tracking-widest text-text-muted mb-4">
359
+ Edit {getComposeBlockDefinition(selectedBlock.type).label}
360
+ </p>
361
+ <BlockFieldEditor
362
+ block={selectedBlock}
363
+ onChange={(b) => updateBlock(selectedBlockIndex, b)}
364
+ />
365
+ </div>
366
+ )}
367
+ </div>
368
+
369
+ <button
370
+ type="button"
371
+ disabled={saving || !title.trim()}
372
+ onClick={handleSave}
373
+ className="flex items-center gap-2 px-5 py-2.5 bg-primary text-white text-xs font-bold rounded-xl disabled:opacity-50 cursor-pointer"
374
+ >
375
+ {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
376
+ Save article
377
+ </button>
378
+ </div>
379
+
380
+ <div className="rounded-xl border border-primary/10 bg-bg-card p-6">
381
+ <p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-4">Live preview</p>
382
+ <ComposeBlogRenderer document={previewDoc} />
383
+ </div>
384
+ </div>
385
+ )}
386
+ </div>
387
+ );
388
+ }
@@ -0,0 +1,29 @@
1
+ import { NextRequest } from "next/server";
2
+ import { blogComposeController } from "@/lib/controllers/BlogComposeController";
3
+ import { auth } from "@/auth";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function PUT(
8
+ req: NextRequest,
9
+ context: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const session = await auth();
12
+ if (!session) {
13
+ return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
14
+ }
15
+ const { id } = await context.params;
16
+ return blogComposeController.update(req, { id });
17
+ }
18
+
19
+ export async function DELETE(
20
+ _req: NextRequest,
21
+ context: { params: Promise<{ id: string }> }
22
+ ) {
23
+ const session = await auth();
24
+ if (!session) {
25
+ return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
26
+ }
27
+ const { id } = await context.params;
28
+ return blogComposeController.delete({ id });
29
+ }
@@ -0,0 +1,21 @@
1
+ import { NextRequest } from "next/server";
2
+ import { blogComposeController } from "@/lib/controllers/BlogComposeController";
3
+ import { auth } from "@/auth";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET(req: NextRequest) {
8
+ const session = await auth();
9
+ if (session) {
10
+ return blogComposeController.listAll();
11
+ }
12
+ return blogComposeController.listPublic();
13
+ }
14
+
15
+ export async function POST(req: NextRequest) {
16
+ const session = await auth();
17
+ if (!session) {
18
+ return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
19
+ }
20
+ return blogComposeController.create(req);
21
+ }
@@ -0,0 +1,79 @@
1
+ import { notFound } from "next/navigation";
2
+ import Link from "next/link";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import { SITE } from "@/constants";
5
+ import { blogComposeService } from "@/lib/features/blog-compose";
6
+ import { ComposeBlogRenderer } from "@/lib/blog/compose";
7
+ import { buildPageMetadata } from "@/lib/seo/metadata";
8
+
9
+ export async function generateMetadata({
10
+ params,
11
+ }: {
12
+ params: Promise<{ slug: string }>;
13
+ }) {
14
+ const { slug } = await params;
15
+ const post = await blogComposeService.getPublishedBySlug(slug);
16
+ if (!post) return {};
17
+ return buildPageMetadata({
18
+ title: post.title,
19
+ description: post.excerpt ?? undefined,
20
+ path: `/blog/${slug}`,
21
+ });
22
+ }
23
+
24
+ export default async function BlogPostPage({
25
+ params,
26
+ }: {
27
+ params: Promise<{ slug: string }>;
28
+ }) {
29
+ const { slug } = await params;
30
+ const post = await blogComposeService.getPublishedBySlug(slug);
31
+ if (!post) notFound();
32
+
33
+ const content = blogComposeService.contentFromPost(post);
34
+ const withSidebar = SITE.features.blogSidebar;
35
+
36
+ const articleBody = (
37
+ <>
38
+ <header className="mb-10 border-b border-primary/10 pb-8">
39
+ <h1 className="text-3xl md:text-4xl font-bold font-serif text-primary">{post.title}</h1>
40
+ {post.excerpt && (
41
+ <p className="mt-4 text-lg text-text-muted leading-relaxed">{post.excerpt}</p>
42
+ )}
43
+ {post.publishedAt && (
44
+ <time
45
+ className="mt-4 block text-[10px] font-bold uppercase tracking-widest text-text-muted"
46
+ dateTime={post.publishedAt.toISOString()}
47
+ >
48
+ {post.publishedAt.toLocaleDateString()}
49
+ </time>
50
+ )}
51
+ </header>
52
+ <ComposeBlogRenderer document={content} />
53
+ </>
54
+ );
55
+
56
+ if (withSidebar) {
57
+ return <article>{articleBody}</article>;
58
+ }
59
+
60
+ return (
61
+ <div className="min-h-screen bg-bg-main">
62
+ <header className="border-b border-primary/10 bg-white/80 backdrop-blur-md sticky top-0 z-30">
63
+ <div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
64
+ <Link
65
+ href="/blog"
66
+ className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-hover shrink-0"
67
+ >
68
+ <ArrowLeft className="w-4 h-4" />
69
+ {SITE.blog.backToBlog}
70
+ </Link>
71
+ <span className="text-xs font-bold uppercase tracking-widest text-text-muted truncate">
72
+ {SITE.blog.pageTitle}
73
+ </span>
74
+ </div>
75
+ </header>
76
+ <main className="max-w-3xl mx-auto px-6 py-12">{articleBody}</main>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from "next";
2
+ import { SITE } from "@/constants";
3
+ import { blogComposeService } from "@/lib/features/blog-compose";
4
+ import { buildPageMetadata } from "@/lib/seo/metadata";
5
+ import BlogShell from "@/app/components/BlogShell";
6
+
7
+ export const metadata: Metadata = buildPageMetadata({
8
+ title: SITE.blog.pageTitle,
9
+ description: SITE.blog.pageSubtitle,
10
+ path: "/blog",
11
+ });
12
+
13
+ export default async function BlogLayout({ children }: { children: React.ReactNode }) {
14
+ if (!SITE.features.blogSidebar) {
15
+ return children;
16
+ }
17
+
18
+ const posts = await blogComposeService.listPublic();
19
+ const sidebarPosts = posts.map((p) => ({ slug: p.slug, title: p.title }));
20
+
21
+ return <BlogShell posts={sidebarPosts}>{children}</BlogShell>;
22
+ }