@natesena/blog-lib 0.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.
- package/README.md +293 -0
- package/dist/chunk-EG563RZL.js +475 -0
- package/dist/chunk-EG563RZL.js.map +1 -0
- package/dist/chunk-OPJV2ECE.js +122 -0
- package/dist/chunk-OPJV2ECE.js.map +1 -0
- package/dist/components/index.js +447 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index-TnUz7zF0.d.ts +404 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +19 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/server/index.d.ts +131 -0
- package/dist/server/index.js +132 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/components/BlogPost.tsx
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
function BlogPost({ post }) {
|
|
8
|
+
var _a;
|
|
9
|
+
const postFormattedPublishDate = post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-US", {
|
|
10
|
+
year: "numeric",
|
|
11
|
+
month: "long",
|
|
12
|
+
day: "numeric"
|
|
13
|
+
}) : null;
|
|
14
|
+
return /* @__PURE__ */ jsxs("article", { className: "max-w-3xl mx-auto", children: [
|
|
15
|
+
post.coverImage && /* @__PURE__ */ jsx("div", { className: "relative w-full h-64 md:h-96 mb-8 rounded-lg overflow-hidden", children: /* @__PURE__ */ jsx(
|
|
16
|
+
Image,
|
|
17
|
+
{
|
|
18
|
+
src: post.coverImage,
|
|
19
|
+
alt: post.title,
|
|
20
|
+
fill: true,
|
|
21
|
+
className: "object-cover",
|
|
22
|
+
priority: true
|
|
23
|
+
}
|
|
24
|
+
) }),
|
|
25
|
+
/* @__PURE__ */ jsx("h1", { className: "text-3xl md:text-4xl font-bold text-gray-900 mb-4", children: post.title }),
|
|
26
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 text-gray-600 text-sm mb-6", children: [
|
|
27
|
+
post.author && /* @__PURE__ */ jsx(BlogPostAuthorInfo, { author: post.author }),
|
|
28
|
+
postFormattedPublishDate && /* @__PURE__ */ jsx("time", { dateTime: (_a = post.publishedAt) == null ? void 0 : _a.toISOString(), children: postFormattedPublishDate })
|
|
29
|
+
] }),
|
|
30
|
+
post.tags && post.tags.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2 mb-8", children: post.tags.map((tag) => /* @__PURE__ */ jsx(BlogPostTagBadge, { tag }, tag.id)) }),
|
|
31
|
+
/* @__PURE__ */ jsx("div", { className: "prose prose-lg max-w-none", children: /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: DOMPurify.sanitize(post.content) } }) })
|
|
32
|
+
] });
|
|
33
|
+
}
|
|
34
|
+
function BlogPostAuthorInfo({ author }) {
|
|
35
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
36
|
+
author.avatar && /* @__PURE__ */ jsx(
|
|
37
|
+
Image,
|
|
38
|
+
{
|
|
39
|
+
src: author.avatar,
|
|
40
|
+
alt: author.name,
|
|
41
|
+
width: 32,
|
|
42
|
+
height: 32,
|
|
43
|
+
className: "rounded-full"
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium", children: author.name })
|
|
47
|
+
] });
|
|
48
|
+
}
|
|
49
|
+
function BlogPostTagBadge({ tag }) {
|
|
50
|
+
return /* @__PURE__ */ jsx("span", { className: "inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-700", children: tag.name });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/components/BlogPostList.tsx
|
|
54
|
+
import Image2 from "next/image";
|
|
55
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
56
|
+
function BlogPostList({
|
|
57
|
+
posts,
|
|
58
|
+
totalPostCount,
|
|
59
|
+
currentPage = 1,
|
|
60
|
+
postsPerPage = 10,
|
|
61
|
+
onPageChange,
|
|
62
|
+
onPostClick,
|
|
63
|
+
isLoading = false
|
|
64
|
+
}) {
|
|
65
|
+
const totalPageCount = Math.ceil(totalPostCount / postsPerPage);
|
|
66
|
+
if (isLoading) {
|
|
67
|
+
return /* @__PURE__ */ jsx2(BlogPostListSkeleton, { skeletonCount: postsPerPage });
|
|
68
|
+
}
|
|
69
|
+
if (posts.length === 0) {
|
|
70
|
+
return /* @__PURE__ */ jsx2(BlogPostListEmptyState, {});
|
|
71
|
+
}
|
|
72
|
+
return /* @__PURE__ */ jsxs2("div", { className: "space-y-8", children: [
|
|
73
|
+
/* @__PURE__ */ jsx2("div", { className: "grid gap-6 md:grid-cols-2 lg:grid-cols-3", children: posts.map((post) => /* @__PURE__ */ jsx2(
|
|
74
|
+
BlogPostCard,
|
|
75
|
+
{
|
|
76
|
+
post,
|
|
77
|
+
onClick: () => onPostClick == null ? void 0 : onPostClick(post)
|
|
78
|
+
},
|
|
79
|
+
post.id
|
|
80
|
+
)) }),
|
|
81
|
+
totalPageCount > 1 && /* @__PURE__ */ jsx2(
|
|
82
|
+
BlogPostListPagination,
|
|
83
|
+
{
|
|
84
|
+
currentPage,
|
|
85
|
+
totalPageCount,
|
|
86
|
+
onPageChange
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
] });
|
|
90
|
+
}
|
|
91
|
+
function BlogPostCard({
|
|
92
|
+
post,
|
|
93
|
+
onClick
|
|
94
|
+
}) {
|
|
95
|
+
const cardFormattedDate = post.publishedAt ? new Date(post.publishedAt).toLocaleDateString("en-US", {
|
|
96
|
+
month: "short",
|
|
97
|
+
day: "numeric",
|
|
98
|
+
year: "numeric"
|
|
99
|
+
}) : null;
|
|
100
|
+
return /* @__PURE__ */ jsxs2(
|
|
101
|
+
"div",
|
|
102
|
+
{
|
|
103
|
+
className: "group cursor-pointer rounded-lg border border-gray-200 overflow-hidden hover:shadow-md transition-shadow",
|
|
104
|
+
onClick,
|
|
105
|
+
role: "button",
|
|
106
|
+
tabIndex: 0,
|
|
107
|
+
onKeyDown: (e) => e.key === "Enter" && (onClick == null ? void 0 : onClick()),
|
|
108
|
+
children: [
|
|
109
|
+
post.coverImage && /* @__PURE__ */ jsx2("div", { className: "relative w-full h-48", children: /* @__PURE__ */ jsx2(
|
|
110
|
+
Image2,
|
|
111
|
+
{
|
|
112
|
+
src: post.coverImage,
|
|
113
|
+
alt: post.title,
|
|
114
|
+
fill: true,
|
|
115
|
+
className: "object-cover group-hover:scale-105 transition-transform"
|
|
116
|
+
}
|
|
117
|
+
) }),
|
|
118
|
+
/* @__PURE__ */ jsxs2("div", { className: "p-4", children: [
|
|
119
|
+
/* @__PURE__ */ jsx2("h2", { className: "text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2", children: post.title }),
|
|
120
|
+
post.excerpt && /* @__PURE__ */ jsx2("p", { className: "mt-2 text-sm text-gray-600 line-clamp-3", children: post.excerpt }),
|
|
121
|
+
/* @__PURE__ */ jsxs2("div", { className: "mt-3 flex items-center gap-2 text-xs text-gray-500", children: [
|
|
122
|
+
post.author && /* @__PURE__ */ jsx2("span", { children: post.author.name }),
|
|
123
|
+
post.author && cardFormattedDate && /* @__PURE__ */ jsx2("span", { children: "\xB7" }),
|
|
124
|
+
cardFormattedDate && /* @__PURE__ */ jsx2("time", { children: cardFormattedDate })
|
|
125
|
+
] }),
|
|
126
|
+
post.tags && post.tags.length > 0 && /* @__PURE__ */ jsx2("div", { className: "mt-3 flex flex-wrap gap-1", children: post.tags.slice(0, 3).map((tag) => /* @__PURE__ */ jsx2(
|
|
127
|
+
"span",
|
|
128
|
+
{
|
|
129
|
+
className: "px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600",
|
|
130
|
+
children: tag.name
|
|
131
|
+
},
|
|
132
|
+
tag.id
|
|
133
|
+
)) })
|
|
134
|
+
] })
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
function BlogPostListPagination({
|
|
140
|
+
currentPage,
|
|
141
|
+
totalPageCount,
|
|
142
|
+
onPageChange
|
|
143
|
+
}) {
|
|
144
|
+
return /* @__PURE__ */ jsxs2("nav", { className: "flex justify-center items-center gap-2", children: [
|
|
145
|
+
/* @__PURE__ */ jsx2(
|
|
146
|
+
"button",
|
|
147
|
+
{
|
|
148
|
+
onClick: () => onPageChange == null ? void 0 : onPageChange(currentPage - 1),
|
|
149
|
+
disabled: currentPage <= 1,
|
|
150
|
+
className: "px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50",
|
|
151
|
+
children: "Previous"
|
|
152
|
+
}
|
|
153
|
+
),
|
|
154
|
+
/* @__PURE__ */ jsxs2("span", { className: "text-sm text-gray-600", children: [
|
|
155
|
+
"Page ",
|
|
156
|
+
currentPage,
|
|
157
|
+
" of ",
|
|
158
|
+
totalPageCount
|
|
159
|
+
] }),
|
|
160
|
+
/* @__PURE__ */ jsx2(
|
|
161
|
+
"button",
|
|
162
|
+
{
|
|
163
|
+
onClick: () => onPageChange == null ? void 0 : onPageChange(currentPage + 1),
|
|
164
|
+
disabled: currentPage >= totalPageCount,
|
|
165
|
+
className: "px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50",
|
|
166
|
+
children: "Next"
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
] });
|
|
170
|
+
}
|
|
171
|
+
function BlogPostListSkeleton({ skeletonCount }) {
|
|
172
|
+
return /* @__PURE__ */ jsx2("div", { className: "grid gap-6 md:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: Math.min(skeletonCount, 6) }).map((_, index) => /* @__PURE__ */ jsxs2(
|
|
173
|
+
"div",
|
|
174
|
+
{
|
|
175
|
+
className: "rounded-lg border border-gray-200 overflow-hidden animate-pulse",
|
|
176
|
+
children: [
|
|
177
|
+
/* @__PURE__ */ jsx2("div", { className: "w-full h-48 bg-gray-200" }),
|
|
178
|
+
/* @__PURE__ */ jsxs2("div", { className: "p-4 space-y-3", children: [
|
|
179
|
+
/* @__PURE__ */ jsx2("div", { className: "h-5 bg-gray-200 rounded w-3/4" }),
|
|
180
|
+
/* @__PURE__ */ jsx2("div", { className: "h-4 bg-gray-200 rounded w-full" }),
|
|
181
|
+
/* @__PURE__ */ jsx2("div", { className: "h-4 bg-gray-200 rounded w-1/2" })
|
|
182
|
+
] })
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
index
|
|
186
|
+
)) });
|
|
187
|
+
}
|
|
188
|
+
function BlogPostListEmptyState() {
|
|
189
|
+
return /* @__PURE__ */ jsxs2("div", { className: "text-center py-12", children: [
|
|
190
|
+
/* @__PURE__ */ jsx2("p", { className: "text-gray-500 text-lg", children: "No posts found." }),
|
|
191
|
+
/* @__PURE__ */ jsx2("p", { className: "text-gray-400 text-sm mt-2", children: "Check back later or try different filters." })
|
|
192
|
+
] });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/components/BlogLayout.tsx
|
|
196
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
197
|
+
function BlogLayout({
|
|
198
|
+
children,
|
|
199
|
+
headerContent,
|
|
200
|
+
footerContent,
|
|
201
|
+
containerClassName = "max-w-4xl mx-auto px-4 py-8"
|
|
202
|
+
}) {
|
|
203
|
+
return /* @__PURE__ */ jsxs3("div", { className: "min-h-screen bg-white", children: [
|
|
204
|
+
headerContent && /* @__PURE__ */ jsx3("header", { className: "border-b border-gray-200", children: headerContent }),
|
|
205
|
+
/* @__PURE__ */ jsx3("main", { className: containerClassName, children }),
|
|
206
|
+
footerContent && /* @__PURE__ */ jsx3("footer", { className: "border-t border-gray-200 mt-8", children: footerContent })
|
|
207
|
+
] });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/components/BlogSeo.tsx
|
|
211
|
+
function generateBlogPostMetadata(input) {
|
|
212
|
+
const canonicalUrl = input.siteBaseUrl ? `${input.siteBaseUrl}/blog/${input.postSlug}` : void 0;
|
|
213
|
+
const openGraphImages = input.postCoverImageUrl ? [{ url: input.postCoverImageUrl }] : void 0;
|
|
214
|
+
return {
|
|
215
|
+
title: input.postTitle,
|
|
216
|
+
description: input.postExcerpt || void 0,
|
|
217
|
+
authors: input.authorName ? [{ name: input.authorName }] : void 0,
|
|
218
|
+
alternates: canonicalUrl ? { canonical: canonicalUrl } : void 0,
|
|
219
|
+
openGraph: {
|
|
220
|
+
title: input.postTitle,
|
|
221
|
+
description: input.postExcerpt || void 0,
|
|
222
|
+
type: "article",
|
|
223
|
+
images: openGraphImages
|
|
224
|
+
},
|
|
225
|
+
twitter: {
|
|
226
|
+
card: input.postCoverImageUrl ? "summary_large_image" : "summary",
|
|
227
|
+
title: input.postTitle,
|
|
228
|
+
description: input.postExcerpt || void 0,
|
|
229
|
+
images: input.postCoverImageUrl ? [input.postCoverImageUrl] : void 0
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/components/cms/ImageUploader.tsx
|
|
235
|
+
import { useState, useCallback } from "react";
|
|
236
|
+
|
|
237
|
+
// src/sdk/storage/gcs.ts
|
|
238
|
+
var GCSStorage = class {
|
|
239
|
+
constructor(config) {
|
|
240
|
+
this.storage = null;
|
|
241
|
+
var _a, _b;
|
|
242
|
+
this.bucketName = config.bucket;
|
|
243
|
+
this.accessMode = (_a = config.accessMode) != null ? _a : "public";
|
|
244
|
+
this.signedReadUrlExpiresInSeconds = (_b = config.signedReadUrlExpiresInSeconds) != null ? _b : 3600;
|
|
245
|
+
this.storageInitPromise = import("@google-cloud/storage").then(({ Storage }) => {
|
|
246
|
+
this.storage = new Storage({
|
|
247
|
+
projectId: config.projectId,
|
|
248
|
+
keyFilename: config.keyFile
|
|
249
|
+
});
|
|
250
|
+
return this.storage;
|
|
251
|
+
}).catch(() => {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"@google-cloud/storage is not installed. Install it to use GCS features: npm install @google-cloud/storage"
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async getStorage() {
|
|
258
|
+
if (this.storage) return this.storage;
|
|
259
|
+
return this.storageInitPromise;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Generate a presigned URL for direct browser upload to GCS.
|
|
263
|
+
*
|
|
264
|
+
* @param filename - Destination path in bucket (e.g. "blog/posts/1234-abc.jpg")
|
|
265
|
+
* @param contentType - MIME type (e.g. "image/jpeg")
|
|
266
|
+
* @param uploadUrlExpiresInSeconds - URL expiry (default 300 = 5 min)
|
|
267
|
+
*/
|
|
268
|
+
async getPresignedUploadUrl(filename, contentType, uploadUrlExpiresInSeconds = 300) {
|
|
269
|
+
const storage = await this.getStorage();
|
|
270
|
+
const file = storage.bucket(this.bucketName).file(filename);
|
|
271
|
+
const [uploadUrl] = await file.getSignedUrl({
|
|
272
|
+
version: "v4",
|
|
273
|
+
action: "write",
|
|
274
|
+
expires: Date.now() + uploadUrlExpiresInSeconds * 1e3,
|
|
275
|
+
contentType
|
|
276
|
+
});
|
|
277
|
+
let publicUrl;
|
|
278
|
+
if (this.accessMode === "private") {
|
|
279
|
+
const [signedReadUrl] = await file.getSignedUrl({
|
|
280
|
+
version: "v4",
|
|
281
|
+
action: "read",
|
|
282
|
+
expires: Date.now() + this.signedReadUrlExpiresInSeconds * 1e3
|
|
283
|
+
});
|
|
284
|
+
publicUrl = signedReadUrl;
|
|
285
|
+
} else {
|
|
286
|
+
publicUrl = `https://storage.googleapis.com/${this.bucketName}/${filename}`;
|
|
287
|
+
}
|
|
288
|
+
return { uploadUrl, publicUrl };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get a read URL for an existing file.
|
|
292
|
+
* Returns a direct public URL or a v4 signed URL depending on accessMode.
|
|
293
|
+
*/
|
|
294
|
+
async getReadUrl(filename) {
|
|
295
|
+
if (this.accessMode === "private") {
|
|
296
|
+
const storage = await this.getStorage();
|
|
297
|
+
const file = storage.bucket(this.bucketName).file(filename);
|
|
298
|
+
const [signedReadUrl] = await file.getSignedUrl({
|
|
299
|
+
version: "v4",
|
|
300
|
+
action: "read",
|
|
301
|
+
expires: Date.now() + this.signedReadUrlExpiresInSeconds * 1e3
|
|
302
|
+
});
|
|
303
|
+
return signedReadUrl;
|
|
304
|
+
}
|
|
305
|
+
return `https://storage.googleapis.com/${this.bucketName}/${filename}`;
|
|
306
|
+
}
|
|
307
|
+
/** Delete a file from GCS. */
|
|
308
|
+
async delete(filename) {
|
|
309
|
+
const storage = await this.getStorage();
|
|
310
|
+
await storage.bucket(this.bucketName).file(filename).delete();
|
|
311
|
+
}
|
|
312
|
+
/** Check if a file exists in the bucket. */
|
|
313
|
+
async exists(filename) {
|
|
314
|
+
const storage = await this.getStorage();
|
|
315
|
+
const [fileExists] = await storage.bucket(this.bucketName).file(filename).exists();
|
|
316
|
+
return fileExists;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// src/server/auth/config.ts
|
|
321
|
+
var blogAuthConfig = null;
|
|
322
|
+
async function getBlogAuthCurrentUser() {
|
|
323
|
+
if (!blogAuthConfig) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return blogAuthConfig.getCurrentUser();
|
|
327
|
+
}
|
|
328
|
+
function isBlogAuthConfigured() {
|
|
329
|
+
return blogAuthConfig !== null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/server/actions/upload.ts
|
|
333
|
+
var ALLOWED_IMAGE_MIME_TYPES = [
|
|
334
|
+
"image/jpeg",
|
|
335
|
+
"image/png",
|
|
336
|
+
"image/webp",
|
|
337
|
+
"image/gif"
|
|
338
|
+
];
|
|
339
|
+
var MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
340
|
+
async function initiateImageUpload(input) {
|
|
341
|
+
if (isBlogAuthConfigured()) {
|
|
342
|
+
const authenticatedUser = await getBlogAuthCurrentUser();
|
|
343
|
+
if (!authenticatedUser) {
|
|
344
|
+
throw new Error("Authentication required. Configure auth with configureBlogAuth().");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!ALLOWED_IMAGE_MIME_TYPES.includes(input.fileType)) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Invalid file type "${input.fileType}". Allowed: ${ALLOWED_IMAGE_MIME_TYPES.join(", ")}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (input.fileSizeBytes > MAX_FILE_SIZE_BYTES) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`File too large (${Math.round(input.fileSizeBytes / 1024 / 1024)}MB). Maximum: 10MB.`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const timestamp = Date.now();
|
|
358
|
+
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
359
|
+
const fileExtension = input.fileName.split(".").pop() || "jpg";
|
|
360
|
+
const destinationFilename = input.folder ? `${input.folder}/${timestamp}-${randomSuffix}.${fileExtension}` : `${timestamp}-${randomSuffix}.${fileExtension}`;
|
|
361
|
+
const gcsStorage = new GCSStorage({
|
|
362
|
+
projectId: process.env.GCP_PROJECT_ID,
|
|
363
|
+
bucket: process.env.GCS_BUCKET,
|
|
364
|
+
keyFile: process.env.GCP_KEYFILE
|
|
365
|
+
});
|
|
366
|
+
const { uploadUrl, publicUrl } = await gcsStorage.getPresignedUploadUrl(
|
|
367
|
+
destinationFilename,
|
|
368
|
+
input.fileType
|
|
369
|
+
);
|
|
370
|
+
return { uploadUrl, publicUrl, destinationFilename };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/components/cms/ImageUploader.tsx
|
|
374
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
375
|
+
function ImageUploader({
|
|
376
|
+
onUploadComplete,
|
|
377
|
+
uploadFolder = "blog/posts",
|
|
378
|
+
labelText = "Upload Image"
|
|
379
|
+
}) {
|
|
380
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
381
|
+
const [uploadedImagePreviewUrl, setUploadedImagePreviewUrl] = useState(null);
|
|
382
|
+
const [uploadErrorMessage, setUploadErrorMessage] = useState(null);
|
|
383
|
+
const handleFileSelection = useCallback(
|
|
384
|
+
async (event) => {
|
|
385
|
+
var _a;
|
|
386
|
+
const selectedFile = (_a = event.target.files) == null ? void 0 : _a[0];
|
|
387
|
+
if (!selectedFile) return;
|
|
388
|
+
setIsUploading(true);
|
|
389
|
+
setUploadErrorMessage(null);
|
|
390
|
+
try {
|
|
391
|
+
const { uploadUrl, publicUrl } = await initiateImageUpload({
|
|
392
|
+
fileName: selectedFile.name,
|
|
393
|
+
fileType: selectedFile.type,
|
|
394
|
+
fileSizeBytes: selectedFile.size,
|
|
395
|
+
folder: uploadFolder
|
|
396
|
+
});
|
|
397
|
+
const gcsUploadResponse = await fetch(uploadUrl, {
|
|
398
|
+
method: "PUT",
|
|
399
|
+
body: selectedFile,
|
|
400
|
+
headers: { "Content-Type": selectedFile.type }
|
|
401
|
+
});
|
|
402
|
+
if (!gcsUploadResponse.ok) {
|
|
403
|
+
throw new Error(`Upload failed with status ${gcsUploadResponse.status}`);
|
|
404
|
+
}
|
|
405
|
+
setUploadedImagePreviewUrl(publicUrl);
|
|
406
|
+
onUploadComplete(publicUrl);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
const errorMessage = error instanceof Error ? error.message : "Upload failed. Please try again.";
|
|
409
|
+
setUploadErrorMessage(errorMessage);
|
|
410
|
+
} finally {
|
|
411
|
+
setIsUploading(false);
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
[onUploadComplete, uploadFolder]
|
|
415
|
+
);
|
|
416
|
+
return /* @__PURE__ */ jsxs4("div", { className: "space-y-2", children: [
|
|
417
|
+
/* @__PURE__ */ jsx4("label", { className: "block text-sm font-medium text-gray-700", children: labelText }),
|
|
418
|
+
/* @__PURE__ */ jsx4(
|
|
419
|
+
"input",
|
|
420
|
+
{
|
|
421
|
+
type: "file",
|
|
422
|
+
accept: "image/jpeg,image/png,image/webp,image/gif",
|
|
423
|
+
onChange: handleFileSelection,
|
|
424
|
+
disabled: isUploading,
|
|
425
|
+
className: "block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50"
|
|
426
|
+
}
|
|
427
|
+
),
|
|
428
|
+
isUploading && /* @__PURE__ */ jsx4("p", { className: "text-sm text-gray-500", children: "Uploading..." }),
|
|
429
|
+
uploadErrorMessage && /* @__PURE__ */ jsx4("p", { className: "text-sm text-red-600", children: uploadErrorMessage }),
|
|
430
|
+
uploadedImagePreviewUrl && /* @__PURE__ */ jsx4(
|
|
431
|
+
"img",
|
|
432
|
+
{
|
|
433
|
+
src: uploadedImagePreviewUrl,
|
|
434
|
+
alt: "Uploaded preview",
|
|
435
|
+
className: "mt-2 w-full max-w-xs rounded border"
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
] });
|
|
439
|
+
}
|
|
440
|
+
export {
|
|
441
|
+
BlogLayout,
|
|
442
|
+
BlogPost,
|
|
443
|
+
BlogPostList,
|
|
444
|
+
ImageUploader,
|
|
445
|
+
generateBlogPostMetadata
|
|
446
|
+
};
|
|
447
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/BlogPost.tsx","../../src/components/BlogPostList.tsx","../../src/components/BlogLayout.tsx","../../src/components/BlogSeo.tsx","../../src/components/cms/ImageUploader.tsx","../../src/sdk/storage/gcs.ts","../../src/server/auth/config.ts","../../src/server/actions/upload.ts"],"sourcesContent":["/**\n * BlogPost Component\n *\n * Renders a single blog post with title, cover image, author, tags,\n * publish date, and content.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 236-255)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.6)\n */\n\nimport Image from 'next/image';\nimport DOMPurify from 'isomorphic-dompurify';\nimport type { AdapterPost } from '../sdk/adapters/base';\nimport type { Author } from '../types/author';\nimport type { Tag } from '../types/tag';\n\ninterface BlogPostProps {\n post: AdapterPost;\n}\n\nexport function BlogPost({ post }: BlogPostProps) {\n const postFormattedPublishDate = post.publishedAt\n ? new Date(post.publishedAt).toLocaleDateString('en-US', {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n })\n : null;\n\n return (\n <article className=\"max-w-3xl mx-auto\">\n {/* Cover Image */}\n {post.coverImage && (\n <div className=\"relative w-full h-64 md:h-96 mb-8 rounded-lg overflow-hidden\">\n <Image\n src={post.coverImage}\n alt={post.title}\n fill\n className=\"object-cover\"\n priority\n />\n </div>\n )}\n\n {/* Title */}\n <h1 className=\"text-3xl md:text-4xl font-bold text-gray-900 mb-4\">\n {post.title}\n </h1>\n\n {/* Author & Date */}\n <div className=\"flex items-center gap-4 text-gray-600 text-sm mb-6\">\n {post.author && (\n <BlogPostAuthorInfo author={post.author} />\n )}\n {postFormattedPublishDate && (\n <time dateTime={post.publishedAt?.toISOString()}>\n {postFormattedPublishDate}\n </time>\n )}\n </div>\n\n {/* Tags */}\n {post.tags && post.tags.length > 0 && (\n <div className=\"flex flex-wrap gap-2 mb-8\">\n {post.tags.map((tag) => (\n <BlogPostTagBadge key={tag.id} tag={tag} />\n ))}\n </div>\n )}\n\n {/* Content — sanitized to prevent XSS attacks */}\n <div className=\"prose prose-lg max-w-none\">\n <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.content) }} />\n </div>\n </article>\n );\n}\n\nfunction BlogPostAuthorInfo({ author }: { author: Author }) {\n return (\n <div className=\"flex items-center gap-2\">\n {author.avatar && (\n <Image\n src={author.avatar}\n alt={author.name}\n width={32}\n height={32}\n className=\"rounded-full\"\n />\n )}\n <span className=\"font-medium\">{author.name}</span>\n </div>\n );\n}\n\nfunction BlogPostTagBadge({ tag }: { tag: Tag }) {\n return (\n <span className=\"inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-700\">\n {tag.name}\n </span>\n );\n}\n","/**\n * BlogPostList Component\n *\n * Renders a list of blog post cards with pagination support.\n * Includes skeleton loading state and empty state.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 241-252)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.6)\n */\n\nimport Image from 'next/image';\nimport type { AdapterPost } from '../sdk/adapters/base';\n\ninterface BlogPostListProps {\n posts: AdapterPost[];\n totalPostCount: number;\n currentPage?: number;\n postsPerPage?: number;\n /** Called when user navigates to a different page */\n onPageChange?: (pageNumber: number) => void;\n /** Called when user clicks a post */\n onPostClick?: (post: AdapterPost) => void;\n /** Show loading skeleton */\n isLoading?: boolean;\n}\n\nexport function BlogPostList({\n posts,\n totalPostCount,\n currentPage = 1,\n postsPerPage = 10,\n onPageChange,\n onPostClick,\n isLoading = false,\n}: BlogPostListProps) {\n const totalPageCount = Math.ceil(totalPostCount / postsPerPage);\n\n if (isLoading) {\n return <BlogPostListSkeleton skeletonCount={postsPerPage} />;\n }\n\n if (posts.length === 0) {\n return <BlogPostListEmptyState />;\n }\n\n return (\n <div className=\"space-y-8\">\n {/* Post Cards */}\n <div className=\"grid gap-6 md:grid-cols-2 lg:grid-cols-3\">\n {posts.map((post) => (\n <BlogPostCard\n key={post.id}\n post={post}\n onClick={() => onPostClick?.(post)}\n />\n ))}\n </div>\n\n {/* Pagination */}\n {totalPageCount > 1 && (\n <BlogPostListPagination\n currentPage={currentPage}\n totalPageCount={totalPageCount}\n onPageChange={onPageChange}\n />\n )}\n </div>\n );\n}\n\nfunction BlogPostCard({\n post,\n onClick,\n}: {\n post: AdapterPost;\n onClick?: () => void;\n}) {\n const cardFormattedDate = post.publishedAt\n ? new Date(post.publishedAt).toLocaleDateString('en-US', {\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n })\n : null;\n\n return (\n <div\n className=\"group cursor-pointer rounded-lg border border-gray-200 overflow-hidden hover:shadow-md transition-shadow\"\n onClick={onClick}\n role=\"button\"\n tabIndex={0}\n onKeyDown={(e) => e.key === 'Enter' && onClick?.()}\n >\n {/* Cover Image */}\n {post.coverImage && (\n <div className=\"relative w-full h-48\">\n <Image\n src={post.coverImage}\n alt={post.title}\n fill\n className=\"object-cover group-hover:scale-105 transition-transform\"\n />\n </div>\n )}\n\n <div className=\"p-4\">\n <h2 className=\"text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2\">\n {post.title}\n </h2>\n\n {post.excerpt && (\n <p className=\"mt-2 text-sm text-gray-600 line-clamp-3\">\n {post.excerpt}\n </p>\n )}\n\n <div className=\"mt-3 flex items-center gap-2 text-xs text-gray-500\">\n {post.author && <span>{post.author.name}</span>}\n {post.author && cardFormattedDate && <span>·</span>}\n {cardFormattedDate && <time>{cardFormattedDate}</time>}\n </div>\n\n {/* Tags */}\n {post.tags && post.tags.length > 0 && (\n <div className=\"mt-3 flex flex-wrap gap-1\">\n {post.tags.slice(0, 3).map((tag) => (\n <span\n key={tag.id}\n className=\"px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600\"\n >\n {tag.name}\n </span>\n ))}\n </div>\n )}\n </div>\n </div>\n );\n}\n\nfunction BlogPostListPagination({\n currentPage,\n totalPageCount,\n onPageChange,\n}: {\n currentPage: number;\n totalPageCount: number;\n onPageChange?: (pageNumber: number) => void;\n}) {\n return (\n <nav className=\"flex justify-center items-center gap-2\">\n <button\n onClick={() => onPageChange?.(currentPage - 1)}\n disabled={currentPage <= 1}\n className=\"px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50\"\n >\n Previous\n </button>\n\n <span className=\"text-sm text-gray-600\">\n Page {currentPage} of {totalPageCount}\n </span>\n\n <button\n onClick={() => onPageChange?.(currentPage + 1)}\n disabled={currentPage >= totalPageCount}\n className=\"px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50\"\n >\n Next\n </button>\n </nav>\n );\n}\n\nfunction BlogPostListSkeleton({ skeletonCount }: { skeletonCount: number }) {\n return (\n <div className=\"grid gap-6 md:grid-cols-2 lg:grid-cols-3\">\n {Array.from({ length: Math.min(skeletonCount, 6) }).map((_, index) => (\n <div\n key={index}\n className=\"rounded-lg border border-gray-200 overflow-hidden animate-pulse\"\n >\n <div className=\"w-full h-48 bg-gray-200\" />\n <div className=\"p-4 space-y-3\">\n <div className=\"h-5 bg-gray-200 rounded w-3/4\" />\n <div className=\"h-4 bg-gray-200 rounded w-full\" />\n <div className=\"h-4 bg-gray-200 rounded w-1/2\" />\n </div>\n </div>\n ))}\n </div>\n );\n}\n\nfunction BlogPostListEmptyState() {\n return (\n <div className=\"text-center py-12\">\n <p className=\"text-gray-500 text-lg\">No posts found.</p>\n <p className=\"text-gray-400 text-sm mt-2\">\n Check back later or try different filters.\n </p>\n </div>\n );\n}\n","/**\n * BlogLayout Component\n *\n * Layout wrapper for blog pages. Provides consistent structure\n * with optional header/footer slots.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 248-251)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.5)\n */\n\ninterface BlogLayoutProps {\n children: React.ReactNode;\n headerContent?: React.ReactNode;\n footerContent?: React.ReactNode;\n containerClassName?: string;\n}\n\nexport function BlogLayout({\n children,\n headerContent,\n footerContent,\n containerClassName = 'max-w-4xl mx-auto px-4 py-8',\n}: BlogLayoutProps) {\n return (\n <div className=\"min-h-screen bg-white\">\n {headerContent && (\n <header className=\"border-b border-gray-200\">{headerContent}</header>\n )}\n\n <main className={containerClassName}>{children}</main>\n\n {footerContent && (\n <footer className=\"border-t border-gray-200 mt-8\">{footerContent}</footer>\n )}\n </div>\n );\n}\n","/**\n * BlogSeo Component\n *\n * Generates SEO metadata for blog posts. Use inside generateMetadata\n * in Next.js App Router pages.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 253-254)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.5)\n */\n\nimport type { Metadata } from 'next';\n\ninterface BlogSeoInput {\n postTitle: string;\n postExcerpt?: string | null;\n postCoverImageUrl?: string | null;\n postSlug: string;\n authorName?: string;\n siteBaseUrl?: string;\n}\n\n/**\n * Generate Next.js Metadata object for a blog post.\n * Call this from your page's generateMetadata export.\n *\n * @example\n * export async function generateMetadata({ params }) {\n * const post = await blog.posts.getBySlug(params.slug);\n * return generateBlogPostMetadata({ postTitle: post.title, ... });\n * }\n */\nexport function generateBlogPostMetadata(input: BlogSeoInput): Metadata {\n const canonicalUrl = input.siteBaseUrl\n ? `${input.siteBaseUrl}/blog/${input.postSlug}`\n : undefined;\n\n const openGraphImages = input.postCoverImageUrl\n ? [{ url: input.postCoverImageUrl }]\n : undefined;\n\n return {\n title: input.postTitle,\n description: input.postExcerpt || undefined,\n authors: input.authorName ? [{ name: input.authorName }] : undefined,\n alternates: canonicalUrl ? { canonical: canonicalUrl } : undefined,\n openGraph: {\n title: input.postTitle,\n description: input.postExcerpt || undefined,\n type: 'article',\n images: openGraphImages,\n },\n twitter: {\n card: input.postCoverImageUrl ? 'summary_large_image' : 'summary',\n title: input.postTitle,\n description: input.postExcerpt || undefined,\n images: input.postCoverImageUrl ? [input.postCoverImageUrl] : undefined,\n },\n };\n}\n","/**\n * ImageUploader Component\n *\n * Client component for uploading images directly to GCS via presigned URLs.\n * Handles file selection, validation, upload progress, and preview.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 994-1051)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.11)\n */\n\n'use client';\n\nimport { useState, useCallback } from 'react';\nimport { initiateImageUpload } from '../../server/actions/upload';\n\ninterface ImageUploaderProps {\n /** Called with the public GCS URL after successful upload */\n onUploadComplete: (imagePublicUrl: string) => void;\n /** Optional folder path in GCS bucket (e.g. \"blog/posts\") */\n uploadFolder?: string;\n /** Optional label text */\n labelText?: string;\n}\n\nexport function ImageUploader({\n onUploadComplete,\n uploadFolder = 'blog/posts',\n labelText = 'Upload Image',\n}: ImageUploaderProps) {\n const [isUploading, setIsUploading] = useState(false);\n const [uploadedImagePreviewUrl, setUploadedImagePreviewUrl] = useState<string | null>(null);\n const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);\n\n const handleFileSelection = useCallback(\n async (event: React.ChangeEvent<HTMLInputElement>) => {\n const selectedFile = event.target.files?.[0];\n if (!selectedFile) return;\n\n setIsUploading(true);\n setUploadErrorMessage(null);\n\n try {\n // Get presigned URL from server\n const { uploadUrl, publicUrl } = await initiateImageUpload({\n fileName: selectedFile.name,\n fileType: selectedFile.type,\n fileSizeBytes: selectedFile.size,\n folder: uploadFolder,\n });\n\n // Upload directly to GCS\n const gcsUploadResponse = await fetch(uploadUrl, {\n method: 'PUT',\n body: selectedFile,\n headers: { 'Content-Type': selectedFile.type },\n });\n\n if (!gcsUploadResponse.ok) {\n throw new Error(`Upload failed with status ${gcsUploadResponse.status}`);\n }\n\n setUploadedImagePreviewUrl(publicUrl);\n onUploadComplete(publicUrl);\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : 'Upload failed. Please try again.';\n setUploadErrorMessage(errorMessage);\n } finally {\n setIsUploading(false);\n }\n },\n [onUploadComplete, uploadFolder],\n );\n\n return (\n <div className=\"space-y-2\">\n <label className=\"block text-sm font-medium text-gray-700\">\n {labelText}\n </label>\n\n <input\n type=\"file\"\n accept=\"image/jpeg,image/png,image/webp,image/gif\"\n onChange={handleFileSelection}\n disabled={isUploading}\n className=\"block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50\"\n />\n\n {isUploading && (\n <p className=\"text-sm text-gray-500\">Uploading...</p>\n )}\n\n {uploadErrorMessage && (\n <p className=\"text-sm text-red-600\">{uploadErrorMessage}</p>\n )}\n\n {uploadedImagePreviewUrl && (\n <img\n src={uploadedImagePreviewUrl}\n alt=\"Uploaded preview\"\n className=\"mt-2 w-full max-w-xs rounded border\"\n />\n )}\n </div>\n );\n}\n","/**\n * Google Cloud Storage Integration\n *\n * Handles presigned URL generation, file deletion, and existence checks\n * for image uploads to GCS.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 866-923)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.9)\n */\n\nimport type { Storage as StorageType } from '@google-cloud/storage';\n\nexport interface GCSStorageConfig {\n projectId: string;\n bucket: string;\n keyFile?: string;\n /**\n * 'public' (default) — files are publicly readable via direct URL.\n * 'private' — files require a v4 signed read URL to access.\n *\n * Ref: Plan Phase 5, Step 5.5\n */\n accessMode?: 'public' | 'private';\n /** How long signed read URLs last in seconds (default 3600 = 1 hour). Only used when accessMode is 'private'. */\n signedReadUrlExpiresInSeconds?: number;\n}\n\nexport interface PresignedUploadResult {\n uploadUrl: string;\n /** Direct public URL when accessMode='public', or a v4 signed read URL when accessMode='private'. */\n publicUrl: string;\n}\n\nexport class GCSStorage {\n private storage: StorageType | null = null;\n private storageInitPromise: Promise<StorageType>;\n private bucketName: string;\n private accessMode: 'public' | 'private';\n private signedReadUrlExpiresInSeconds: number;\n\n constructor(config: GCSStorageConfig) {\n this.bucketName = config.bucket;\n this.accessMode = config.accessMode ?? 'public';\n this.signedReadUrlExpiresInSeconds = config.signedReadUrlExpiresInSeconds ?? 3600;\n\n // Lazy-load @google-cloud/storage so the SDK doesn't crash\n // at import time when GCS isn't installed. Ref: Plan blog-6cd\n this.storageInitPromise = import('@google-cloud/storage')\n .then(({ Storage }) => {\n this.storage = new Storage({\n projectId: config.projectId,\n keyFilename: config.keyFile,\n });\n return this.storage;\n })\n .catch(() => {\n throw new Error(\n '@google-cloud/storage is not installed. ' +\n 'Install it to use GCS features: npm install @google-cloud/storage'\n );\n });\n }\n\n private async getStorage(): Promise<StorageType> {\n if (this.storage) return this.storage;\n return this.storageInitPromise;\n }\n\n /**\n * Generate a presigned URL for direct browser upload to GCS.\n *\n * @param filename - Destination path in bucket (e.g. \"blog/posts/1234-abc.jpg\")\n * @param contentType - MIME type (e.g. \"image/jpeg\")\n * @param uploadUrlExpiresInSeconds - URL expiry (default 300 = 5 min)\n */\n async getPresignedUploadUrl(\n filename: string,\n contentType: string,\n uploadUrlExpiresInSeconds: number = 300,\n ): Promise<PresignedUploadResult> {\n const storage = await this.getStorage();\n const file = storage.bucket(this.bucketName).file(filename);\n\n const [uploadUrl] = await file.getSignedUrl({\n version: 'v4',\n action: 'write',\n expires: Date.now() + uploadUrlExpiresInSeconds * 1000,\n contentType,\n });\n\n let publicUrl: string;\n\n if (this.accessMode === 'private') {\n const [signedReadUrl] = await file.getSignedUrl({\n version: 'v4',\n action: 'read',\n expires: Date.now() + this.signedReadUrlExpiresInSeconds * 1000,\n });\n publicUrl = signedReadUrl;\n } else {\n publicUrl = `https://storage.googleapis.com/${this.bucketName}/${filename}`;\n }\n\n return { uploadUrl, publicUrl };\n }\n\n /**\n * Get a read URL for an existing file.\n * Returns a direct public URL or a v4 signed URL depending on accessMode.\n */\n async getReadUrl(filename: string): Promise<string> {\n if (this.accessMode === 'private') {\n const storage = await this.getStorage();\n const file = storage.bucket(this.bucketName).file(filename);\n const [signedReadUrl] = await file.getSignedUrl({\n version: 'v4',\n action: 'read',\n expires: Date.now() + this.signedReadUrlExpiresInSeconds * 1000,\n });\n return signedReadUrl;\n }\n\n return `https://storage.googleapis.com/${this.bucketName}/${filename}`;\n }\n\n /** Delete a file from GCS. */\n async delete(filename: string): Promise<void> {\n const storage = await this.getStorage();\n await storage.bucket(this.bucketName).file(filename).delete();\n }\n\n /** Check if a file exists in the bucket. */\n async exists(filename: string): Promise<boolean> {\n const storage = await this.getStorage();\n const [fileExists] = await storage\n .bucket(this.bucketName)\n .file(filename)\n .exists();\n return fileExists;\n }\n}\n","/**\n * Pluggable Auth Configuration\n *\n * Allows consumers to inject their own auth provider into blog-lib.\n * Upload actions and other protected operations call getCurrentUser()\n * and reject requests if no user is returned.\n *\n * @example\n * import { configureBlogAuth } from '@nate/blog-lib/server';\n *\n * configureBlogAuth({\n * getCurrentUser: async () => {\n * const session = await auth(); // your auth provider\n * return session?.user ?? null;\n * },\n * });\n *\n * Ref: Plan Phase 3, Step 3.2\n */\n\nexport interface BlogAuthUser {\n id: string;\n email?: string;\n}\n\nexport interface BlogAuthConfig {\n /**\n * Return the currently authenticated user, or null if unauthenticated.\n * Called by upload actions and other protected operations.\n */\n getCurrentUser: () => Promise<BlogAuthUser | null>;\n}\n\nlet blogAuthConfig: BlogAuthConfig | null = null;\n\n/**\n * Configure blog-lib's auth integration. Call once during app initialization.\n */\nexport function configureBlogAuth(config: BlogAuthConfig): void {\n blogAuthConfig = config;\n}\n\n/**\n * Get the currently authenticated user using the configured auth provider.\n * Returns null if no auth provider is configured or no user is authenticated.\n *\n * @internal Used by upload actions and other protected operations.\n */\nexport async function getBlogAuthCurrentUser(): Promise<BlogAuthUser | null> {\n if (!blogAuthConfig) {\n return null;\n }\n return blogAuthConfig.getCurrentUser();\n}\n\n/**\n * Check if blog auth has been configured.\n */\nexport function isBlogAuthConfigured(): boolean {\n return blogAuthConfig !== null;\n}\n","/**\n * Image Upload Server Actions\n *\n * Server-side actions for initiating and confirming image uploads to GCS.\n * Frontend calls these, then uploads directly to GCS using the presigned URL.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 927-991)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.10)\n */\n\n'use server';\n\nimport { GCSStorage } from '../../sdk/storage/gcs';\nimport { getBlogAuthCurrentUser, isBlogAuthConfigured } from '../auth/config';\n\nconst ALLOWED_IMAGE_MIME_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n];\n\nconst MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB\n\nexport interface InitiateUploadInput {\n fileName: string;\n fileType: string;\n fileSizeBytes: number;\n folder?: string;\n}\n\nexport interface InitiateUploadResult {\n uploadUrl: string;\n publicUrl: string;\n destinationFilename: string;\n}\n\n/**\n * Validate the file and generate a presigned GCS upload URL.\n * The client then uploads directly to GCS using this URL.\n */\nexport async function initiateImageUpload(\n input: InitiateUploadInput,\n): Promise<InitiateUploadResult> {\n // Auth check — require authenticated user if auth is configured\n // Ref: Plan Phase 3, Step 3.2\n if (isBlogAuthConfigured()) {\n const authenticatedUser = await getBlogAuthCurrentUser();\n if (!authenticatedUser) {\n throw new Error('Authentication required. Configure auth with configureBlogAuth().');\n }\n }\n\n // Validate file type\n if (!ALLOWED_IMAGE_MIME_TYPES.includes(input.fileType)) {\n throw new Error(\n `Invalid file type \"${input.fileType}\". Allowed: ${ALLOWED_IMAGE_MIME_TYPES.join(', ')}`,\n );\n }\n\n // Validate file size\n if (input.fileSizeBytes > MAX_FILE_SIZE_BYTES) {\n throw new Error(\n `File too large (${Math.round(input.fileSizeBytes / 1024 / 1024)}MB). Maximum: 10MB.`,\n );\n }\n\n // Generate unique destination filename\n const timestamp = Date.now();\n const randomSuffix = Math.random().toString(36).substring(2, 8);\n const fileExtension = input.fileName.split('.').pop() || 'jpg';\n const destinationFilename = input.folder\n ? `${input.folder}/${timestamp}-${randomSuffix}.${fileExtension}`\n : `${timestamp}-${randomSuffix}.${fileExtension}`;\n\n // Initialize GCS and generate presigned URL\n const gcsStorage = new GCSStorage({\n projectId: process.env.GCP_PROJECT_ID!,\n bucket: process.env.GCS_BUCKET!,\n keyFile: process.env.GCP_KEYFILE,\n });\n\n const { uploadUrl, publicUrl } = await gcsStorage.getPresignedUploadUrl(\n destinationFilename,\n input.fileType,\n );\n\n return { uploadUrl, publicUrl, destinationFilename };\n}\n\n/**\n * Confirm that a file was successfully uploaded to GCS.\n * Call after the client finishes the direct upload.\n */\nexport async function confirmImageUpload(\n destinationFilename: string,\n): Promise<{ isUploadConfirmed: boolean }> {\n // Auth check — require authenticated user if auth is configured\n if (isBlogAuthConfigured()) {\n const authenticatedUser = await getBlogAuthCurrentUser();\n if (!authenticatedUser) {\n throw new Error('Authentication required. Configure auth with configureBlogAuth().');\n }\n }\n\n const gcsStorage = new GCSStorage({\n projectId: process.env.GCP_PROJECT_ID!,\n bucket: process.env.GCS_BUCKET!,\n keyFile: process.env.GCP_KEYFILE,\n });\n\n const isUploadConfirmed = await gcsStorage.exists(destinationFilename);\n if (!isUploadConfirmed) {\n throw new Error('File not found in storage after upload');\n }\n\n return { isUploadConfirmed };\n}\n"],"mappings":"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","names":["Image","jsx","jsxs","jsx","jsxs","jsx","jsxs"]}
|