nca-ai-cms-astro-plugin 1.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.
- package/.claude/settings.local.json +9 -0
- package/README.md +87 -0
- package/package.json +53 -0
- package/src/api/_utils.ts +20 -0
- package/src/api/articles/[id]/apply.ts +89 -0
- package/src/api/articles/[id]/regenerate-image.ts +49 -0
- package/src/api/articles/[id]/regenerate-text.ts +57 -0
- package/src/api/articles/[id].ts +53 -0
- package/src/api/auth/check.ts +6 -0
- package/src/api/auth/login.ts +43 -0
- package/src/api/auth/logout.ts +6 -0
- package/src/api/generate-content.ts +43 -0
- package/src/api/generate-image.ts +33 -0
- package/src/api/prompts.ts +45 -0
- package/src/api/save-image.ts +38 -0
- package/src/api/save.ts +49 -0
- package/src/api/scheduler/[id].ts +31 -0
- package/src/api/scheduler/generate.ts +94 -0
- package/src/api/scheduler/publish.ts +96 -0
- package/src/api/scheduler.ts +51 -0
- package/src/components/Editor.tsx +115 -0
- package/src/components/editor/GenerateTab.tsx +384 -0
- package/src/components/editor/PlannerTab.tsx +345 -0
- package/src/components/editor/SettingsTab.tsx +185 -0
- package/src/components/editor/styles.ts +597 -0
- package/src/components/editor/types.ts +49 -0
- package/src/components/editor/useTabNavigation.ts +69 -0
- package/src/config.d.ts +4 -0
- package/src/db/tables.ts +39 -0
- package/src/domain/entities/Article.test.ts +138 -0
- package/src/domain/entities/Article.ts +90 -0
- package/src/domain/entities/ScheduledPost.test.ts +228 -0
- package/src/domain/entities/ScheduledPost.ts +152 -0
- package/src/domain/entities/Source.test.ts +57 -0
- package/src/domain/entities/Source.ts +43 -0
- package/src/domain/entities/index.ts +9 -0
- package/src/domain/index.ts +16 -0
- package/src/domain/value-objects/ArticleFinder.test.ts +104 -0
- package/src/domain/value-objects/ArticleFinder.ts +61 -0
- package/src/domain/value-objects/SEOMetadata.test.ts +48 -0
- package/src/domain/value-objects/SEOMetadata.ts +19 -0
- package/src/domain/value-objects/Slug.test.ts +51 -0
- package/src/domain/value-objects/Slug.ts +33 -0
- package/src/domain/value-objects/index.ts +4 -0
- package/src/index.ts +146 -0
- package/src/middleware.ts +30 -0
- package/src/pages/editor.astro +22 -0
- package/src/pages/login.astro +117 -0
- package/src/services/ArticleService.test.ts +148 -0
- package/src/services/ArticleService.ts +150 -0
- package/src/services/AutoPublisher.ts +122 -0
- package/src/services/ContentFetcher.ts +89 -0
- package/src/services/ContentGenerator.ts +320 -0
- package/src/services/FileWriter.test.ts +80 -0
- package/src/services/FileWriter.ts +59 -0
- package/src/services/ImageConverter.ts +15 -0
- package/src/services/ImageGenerator.ts +108 -0
- package/src/services/PromptService.ts +84 -0
- package/src/services/SchedulerDBAdapter.ts +75 -0
- package/src/services/SchedulerService.test.ts +286 -0
- package/src/services/SchedulerService.ts +149 -0
- package/src/services/index.ts +27 -0
- package/src/utils/authUtils.test.ts +60 -0
- package/src/utils/authUtils.ts +25 -0
- package/src/utils/envUtils.test.ts +40 -0
- package/src/utils/envUtils.ts +26 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/markdown.test.ts +65 -0
- package/src/utils/markdown.ts +13 -0
- package/src/utils/sanitize.test.ts +180 -0
- package/src/utils/sanitize.ts +98 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# NCA AI CMS Astro Plugin
|
|
2
|
+
|
|
3
|
+
An Astro plugin that adds an AI-powered content editor to your site. Generate articles and images with Google Gemini, schedule posts, and manage everything from a built-in editor UI.
|
|
4
|
+
|
|
5
|
+
**Never code alone** — this is an open source project by [Never Code Alone](https://nevercodealone.de).
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Adds an `/editor` page with a React-based UI for content management
|
|
10
|
+
- Generates articles and images using Google Gemini AI
|
|
11
|
+
- Schedules and auto-publishes posts as Markdown files
|
|
12
|
+
- Handles authentication so your host project stays simple
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
### 1. Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install nca-ai-cms-astro-plugin
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 2. Add to your Astro config
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// astro.config.mjs
|
|
26
|
+
import ncaAiCms from 'nca-ai-cms-astro-plugin';
|
|
27
|
+
|
|
28
|
+
export default defineConfig({
|
|
29
|
+
integrations: [ncaAiCms()],
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 3. Environment variables
|
|
34
|
+
|
|
35
|
+
```env
|
|
36
|
+
GOOGLE_GEMINI_API_KEY=your-gemini-api-key
|
|
37
|
+
EDITOR_ADMIN=your-username
|
|
38
|
+
EDITOR_PASSWORD=your-password
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Variable | Required | Description |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `GOOGLE_GEMINI_API_KEY` | Yes | Google Gemini API key for content and image generation |
|
|
44
|
+
| `EDITOR_ADMIN` | Yes | Username for editor login |
|
|
45
|
+
| `EDITOR_PASSWORD` | Yes | Password for editor login |
|
|
46
|
+
|
|
47
|
+
### 4. Requirements
|
|
48
|
+
|
|
49
|
+
- Astro 5+
|
|
50
|
+
- `@astrojs/db` and `@astrojs/react` integrations
|
|
51
|
+
- Node.js 18+
|
|
52
|
+
|
|
53
|
+
## Options
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
ncaAiCms({
|
|
57
|
+
contentPath: 'src/content/blog', // where Markdown files are saved (default: 'nca-ai-cms-content')
|
|
58
|
+
autoPublish: true, // auto-publish scheduled posts (default: true in production)
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Routes added
|
|
63
|
+
|
|
64
|
+
| Route | Description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `/login` | Login page |
|
|
67
|
+
| `/editor` | Content editor UI |
|
|
68
|
+
| `/api/auth/*` | Authentication endpoints |
|
|
69
|
+
| `/api/generate-content` | Generate article text |
|
|
70
|
+
| `/api/generate-image` | Generate article image |
|
|
71
|
+
| `/api/save` | Save Markdown file |
|
|
72
|
+
| `/api/prompts` | Manage prompt templates |
|
|
73
|
+
| `/api/scheduler` | Manage scheduled posts |
|
|
74
|
+
| `/api/articles/*` | Article operations |
|
|
75
|
+
|
|
76
|
+
All `/api/*` and `/editor` routes are protected by cookie-based authentication.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm test # run tests
|
|
82
|
+
npm run typecheck # check types
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nca-ai-cms-astro-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./services": "./src/services/index.ts",
|
|
8
|
+
"./domain": "./src/domain/index.ts",
|
|
9
|
+
"./domain/entities": "./src/domain/entities/index.ts",
|
|
10
|
+
"./domain/value-objects": "./src/domain/value-objects/index.ts",
|
|
11
|
+
"./utils": "./src/utils/index.ts",
|
|
12
|
+
"./api/*": "./src/api/*"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"astro": "^5.0.0",
|
|
21
|
+
"@astrojs/db": "^0.18.0",
|
|
22
|
+
"@astrojs/react": "^4.0.0",
|
|
23
|
+
"@google/genai": "^1.0.0",
|
|
24
|
+
"@google/generative-ai": "^0.24.0",
|
|
25
|
+
"gray-matter": "^4.0.0",
|
|
26
|
+
"marked": "^17.0.0",
|
|
27
|
+
"react": "^19.0.0",
|
|
28
|
+
"react-dom": "^19.0.0",
|
|
29
|
+
"sanitize-html": "^2.0.0",
|
|
30
|
+
"sharp": "^0.34.0",
|
|
31
|
+
"turndown": "^7.0.0",
|
|
32
|
+
"zod": "^3.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/sanitize-html": "^2.16.0",
|
|
36
|
+
"@types/turndown": "^5.0.6",
|
|
37
|
+
"astro": "^5.16.4",
|
|
38
|
+
"@astrojs/db": "^0.18.3",
|
|
39
|
+
"@astrojs/react": "^4.4.2",
|
|
40
|
+
"@google/genai": "^1.31.0",
|
|
41
|
+
"@google/generative-ai": "^0.24.1",
|
|
42
|
+
"gray-matter": "^4.0.3",
|
|
43
|
+
"marked": "^17.0.1",
|
|
44
|
+
"react": "^19.2.1",
|
|
45
|
+
"react-dom": "^19.2.1",
|
|
46
|
+
"sanitize-html": "^2.17.0",
|
|
47
|
+
"sharp": "^0.34.5",
|
|
48
|
+
"turndown": "^7.2.2",
|
|
49
|
+
"zod": "^3.25.76",
|
|
50
|
+
"typescript": "^5.9.3",
|
|
51
|
+
"vitest": "^4.0.15"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function jsonResponse(data: unknown, status = 200): Response {
|
|
2
|
+
return new Response(JSON.stringify(data), {
|
|
3
|
+
status,
|
|
4
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function jsonError(error: unknown, status = 500): Response {
|
|
9
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function parseBody<T = Record<string, unknown>>(
|
|
17
|
+
request: Request
|
|
18
|
+
): Promise<T> {
|
|
19
|
+
return request.json() as Promise<T>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import {
|
|
6
|
+
ArticleService,
|
|
7
|
+
ArticleNotFoundError,
|
|
8
|
+
} from '../../../services/ArticleService';
|
|
9
|
+
import { convertToWebP } from '../../../services/ImageConverter';
|
|
10
|
+
import { contentPath } from 'virtual:nca-ai-cms/config';
|
|
11
|
+
import { jsonResponse, jsonError } from '../../_utils';
|
|
12
|
+
|
|
13
|
+
interface ApplyRequest {
|
|
14
|
+
// For text updates
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
content?: string;
|
|
18
|
+
tags?: string[];
|
|
19
|
+
// For image updates
|
|
20
|
+
imageUrl?: string;
|
|
21
|
+
imageAlt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// POST /api/articles/[id]/apply - Save regenerated content or image
|
|
25
|
+
export const POST: APIRoute = async ({ params, request }) => {
|
|
26
|
+
try {
|
|
27
|
+
const slug = params.id;
|
|
28
|
+
|
|
29
|
+
if (!slug) {
|
|
30
|
+
return jsonError('Article ID required', 400);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const data: ApplyRequest = await request.json();
|
|
34
|
+
const service = new ArticleService(contentPath);
|
|
35
|
+
|
|
36
|
+
// Read existing article
|
|
37
|
+
const existingArticle = await service.read(slug);
|
|
38
|
+
if (!existingArticle) {
|
|
39
|
+
throw new ArticleNotFoundError(slug);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Handle image update if imageUrl is provided
|
|
43
|
+
if (data.imageUrl) {
|
|
44
|
+
const heroPath = path.join(existingArticle.folderPath, 'hero.webp');
|
|
45
|
+
|
|
46
|
+
console.log('Saving image to:', heroPath);
|
|
47
|
+
console.log('Image URL length:', data.imageUrl.length);
|
|
48
|
+
|
|
49
|
+
// Decode base64 image data and convert to WebP
|
|
50
|
+
const base64Data = data.imageUrl.replace(/^data:image\/\w+;base64,/, '');
|
|
51
|
+
|
|
52
|
+
await convertToWebP(base64Data, heroPath);
|
|
53
|
+
console.log('Image saved successfully');
|
|
54
|
+
|
|
55
|
+
// Update imageAlt if provided
|
|
56
|
+
if (data.imageAlt) {
|
|
57
|
+
const indexPath = path.join(existingArticle.folderPath, 'index.md');
|
|
58
|
+
const fileContent = await fs.readFile(indexPath, 'utf-8');
|
|
59
|
+
const { data: frontmatter, content } = matter(fileContent);
|
|
60
|
+
|
|
61
|
+
frontmatter.imageAlt = data.imageAlt;
|
|
62
|
+
|
|
63
|
+
const updatedContent = matter.stringify(content, frontmatter);
|
|
64
|
+
await fs.writeFile(indexPath, updatedContent);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Handle text update if content is provided
|
|
69
|
+
if (data.content || data.title || data.description) {
|
|
70
|
+
await service.updateContent(slug, {
|
|
71
|
+
...(data.title && { title: data.title }),
|
|
72
|
+
...(data.description && { description: data.description }),
|
|
73
|
+
...(data.content && { content: data.content }),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return jsonResponse({
|
|
78
|
+
success: true,
|
|
79
|
+
articleId: existingArticle.articleId,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ArticleNotFoundError) {
|
|
83
|
+
return jsonError(error, 404);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.error('Apply changes error:', error);
|
|
87
|
+
return jsonError(error);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import {
|
|
3
|
+
ArticleService,
|
|
4
|
+
ArticleNotFoundError,
|
|
5
|
+
} from '../../../services/ArticleService';
|
|
6
|
+
import { ImageGenerator } from '../../../services/ImageGenerator';
|
|
7
|
+
import { getEnvVariable } from '../../../utils/envUtils';
|
|
8
|
+
import { contentPath } from 'virtual:nca-ai-cms/config';
|
|
9
|
+
import { jsonResponse, jsonError } from '../../_utils';
|
|
10
|
+
|
|
11
|
+
// POST /api/articles/[id]/regenerate-image - Generate new image for article
|
|
12
|
+
// Returns preview of new image URL WITHOUT saving
|
|
13
|
+
export const POST: APIRoute = async ({ params }) => {
|
|
14
|
+
try {
|
|
15
|
+
const slug = params.id;
|
|
16
|
+
|
|
17
|
+
if (!slug) {
|
|
18
|
+
return jsonError('Article ID required', 400);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Read existing article to get title for image generation
|
|
22
|
+
const service = new ArticleService(contentPath);
|
|
23
|
+
const existingArticle = await service.read(slug);
|
|
24
|
+
|
|
25
|
+
if (!existingArticle) {
|
|
26
|
+
throw new ArticleNotFoundError(slug);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Generate new image using article title
|
|
30
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
31
|
+
const generator = new ImageGenerator({ apiKey });
|
|
32
|
+
const image = await generator.generate(existingArticle.title);
|
|
33
|
+
|
|
34
|
+
return jsonResponse({
|
|
35
|
+
url: image.url,
|
|
36
|
+
alt: image.alt,
|
|
37
|
+
// Include article info for reference
|
|
38
|
+
articleId: existingArticle.articleId,
|
|
39
|
+
articleTitle: existingArticle.title,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof ArticleNotFoundError) {
|
|
43
|
+
return jsonError(error, 404);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.error('Regenerate image error:', error);
|
|
47
|
+
return jsonError(error);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import {
|
|
3
|
+
ArticleService,
|
|
4
|
+
ArticleNotFoundError,
|
|
5
|
+
} from '../../../services/ArticleService';
|
|
6
|
+
import { ContentGenerator } from '../../../services/ContentGenerator';
|
|
7
|
+
import { PromptService } from '../../../services/PromptService';
|
|
8
|
+
import { getEnvVariable } from '../../../utils/envUtils';
|
|
9
|
+
import { contentPath } from 'virtual:nca-ai-cms/config';
|
|
10
|
+
import { jsonResponse, jsonError } from '../../_utils';
|
|
11
|
+
|
|
12
|
+
// POST /api/articles/[id]/regenerate-text - Generate new content for article
|
|
13
|
+
// Returns preview of new content WITHOUT saving
|
|
14
|
+
export const POST: APIRoute = async ({ params }) => {
|
|
15
|
+
try {
|
|
16
|
+
const slug = params.id;
|
|
17
|
+
|
|
18
|
+
if (!slug) {
|
|
19
|
+
return jsonError('Article ID required', 400);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Read existing article to get title for regeneration
|
|
23
|
+
const service = new ArticleService(contentPath);
|
|
24
|
+
const existingArticle = await service.read(slug);
|
|
25
|
+
|
|
26
|
+
if (!existingArticle) {
|
|
27
|
+
throw new ArticleNotFoundError(slug);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Generate new content using existing title as keywords
|
|
31
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
32
|
+
const promptService = new PromptService();
|
|
33
|
+
const generator = new ContentGenerator({ apiKey, promptService });
|
|
34
|
+
|
|
35
|
+
// Use the existing title as keywords for regeneration
|
|
36
|
+
const newArticle = await generator.generateFromKeywords(
|
|
37
|
+
existingArticle.title
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return jsonResponse({
|
|
41
|
+
title: newArticle.title,
|
|
42
|
+
description: newArticle.description,
|
|
43
|
+
content: newArticle.content,
|
|
44
|
+
tags: newArticle.tags,
|
|
45
|
+
// Include original article info for reference
|
|
46
|
+
originalTitle: existingArticle.title,
|
|
47
|
+
articleId: existingArticle.articleId,
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof ArticleNotFoundError) {
|
|
51
|
+
return jsonError(error, 404);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.error('Regenerate text error:', error);
|
|
55
|
+
return jsonError(error);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import {
|
|
3
|
+
ArticleService,
|
|
4
|
+
ArticleNotFoundError,
|
|
5
|
+
} from '../../services/ArticleService';
|
|
6
|
+
import { contentPath } from 'virtual:nca-ai-cms/config';
|
|
7
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
8
|
+
|
|
9
|
+
// GET /api/articles/[id] - Get article details
|
|
10
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
11
|
+
try {
|
|
12
|
+
const slug = params.id;
|
|
13
|
+
|
|
14
|
+
if (!slug) {
|
|
15
|
+
return jsonError('Article ID required', 400);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const service = new ArticleService(contentPath);
|
|
19
|
+
const article = await service.read(slug);
|
|
20
|
+
|
|
21
|
+
if (!article) {
|
|
22
|
+
return jsonError('Article not found', 404);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return jsonResponse(article);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Read error:', error);
|
|
28
|
+
return jsonError(error);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// DELETE /api/articles/[id] - Delete an article by slug
|
|
33
|
+
export const DELETE: APIRoute = async ({ params }) => {
|
|
34
|
+
try {
|
|
35
|
+
const slug = params.id;
|
|
36
|
+
|
|
37
|
+
if (!slug) {
|
|
38
|
+
return jsonError('Article ID required', 400);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const service = new ArticleService(contentPath);
|
|
42
|
+
await service.delete(slug);
|
|
43
|
+
|
|
44
|
+
return jsonResponse({ success: true });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof ArticleNotFoundError) {
|
|
47
|
+
return jsonError(error, 404);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.error('Delete error:', error);
|
|
51
|
+
return jsonError(error);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { jsonResponse, jsonError } from '../_utils.js';
|
|
4
|
+
import { getEnvVariable } from '../../utils/envUtils.js';
|
|
5
|
+
|
|
6
|
+
const loginSchema = z.object({
|
|
7
|
+
username: z.string().min(1),
|
|
8
|
+
password: z.string().min(1),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
12
|
+
let body: unknown;
|
|
13
|
+
try {
|
|
14
|
+
body = await request.json();
|
|
15
|
+
} catch {
|
|
16
|
+
return jsonError('Invalid JSON', 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const result = loginSchema.safeParse(body);
|
|
20
|
+
if (!result.success) {
|
|
21
|
+
return jsonError('Username and password are required', 400);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { username, password } = result.data;
|
|
25
|
+
const expectedUsername = getEnvVariable('EDITOR_ADMIN');
|
|
26
|
+
const expectedPassword = getEnvVariable('EDITOR_PASSWORD');
|
|
27
|
+
|
|
28
|
+
if (username !== expectedUsername || password !== expectedPassword) {
|
|
29
|
+
return jsonError('Invalid credentials', 401);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const token = btoa(`${username}:${password}`);
|
|
33
|
+
|
|
34
|
+
cookies.set('editor-auth', token, {
|
|
35
|
+
httpOnly: true,
|
|
36
|
+
secure: import.meta.env.PROD,
|
|
37
|
+
sameSite: 'strict',
|
|
38
|
+
path: '/',
|
|
39
|
+
maxAge: 60 * 60 * 24, // 24 hours
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return jsonResponse({ success: true });
|
|
43
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ContentGenerator } from '../services/ContentGenerator';
|
|
4
|
+
import { PromptService } from '../services/PromptService';
|
|
5
|
+
import { getEnvVariable } from '../utils/envUtils';
|
|
6
|
+
import { jsonResponse, jsonError } from './_utils';
|
|
7
|
+
|
|
8
|
+
const GenerateContentSchema = z.object({
|
|
9
|
+
url: z.string().url().optional(),
|
|
10
|
+
keywords: z.string().min(1).optional(),
|
|
11
|
+
}).refine((data) => data.url || data.keywords, {
|
|
12
|
+
message: 'URL or keywords required',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
16
|
+
try {
|
|
17
|
+
const body = await request.json();
|
|
18
|
+
const parsed = GenerateContentSchema.safeParse(body);
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
|
|
21
|
+
}
|
|
22
|
+
const { url, keywords } = parsed.data;
|
|
23
|
+
|
|
24
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
25
|
+
const promptService = new PromptService();
|
|
26
|
+
const generator = new ContentGenerator({ apiKey, promptService });
|
|
27
|
+
const article = url
|
|
28
|
+
? await generator.generateFromUrl(url)
|
|
29
|
+
: await generator.generateFromKeywords(keywords!);
|
|
30
|
+
|
|
31
|
+
return jsonResponse({
|
|
32
|
+
title: article.title,
|
|
33
|
+
description: article.description,
|
|
34
|
+
content: article.content,
|
|
35
|
+
filepath: article.filepath,
|
|
36
|
+
tags: article.tags,
|
|
37
|
+
date: article.date.toISOString(),
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('Generation error:', error);
|
|
41
|
+
return jsonError(error);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ImageGenerator } from '../services/ImageGenerator';
|
|
4
|
+
import { getEnvVariable } from '../utils/envUtils';
|
|
5
|
+
import { jsonResponse, jsonError } from './_utils';
|
|
6
|
+
|
|
7
|
+
const GenerateImageSchema = z.object({
|
|
8
|
+
title: z.string().min(1, 'Title is required'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
12
|
+
try {
|
|
13
|
+
const body = await request.json();
|
|
14
|
+
const parsed = GenerateImageSchema.safeParse(body);
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
|
|
17
|
+
}
|
|
18
|
+
const { title } = parsed.data;
|
|
19
|
+
|
|
20
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
21
|
+
const generator = new ImageGenerator({ apiKey });
|
|
22
|
+
const image = await generator.generate(title);
|
|
23
|
+
|
|
24
|
+
return jsonResponse({
|
|
25
|
+
url: image.url,
|
|
26
|
+
alt: image.alt,
|
|
27
|
+
filepath: image.filepath,
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('Image generation error:', error);
|
|
31
|
+
return jsonError(error);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { PromptService } from '../services/PromptService';
|
|
3
|
+
import { jsonResponse, jsonError } from './_utils';
|
|
4
|
+
|
|
5
|
+
const service = new PromptService();
|
|
6
|
+
|
|
7
|
+
// GET /api/prompts - Get all prompts and settings
|
|
8
|
+
export const GET: APIRoute = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const [prompts, settings] = await Promise.all([
|
|
11
|
+
service.getAllPrompts(),
|
|
12
|
+
service.getAllSettings(),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
return jsonResponse({
|
|
16
|
+
prompts,
|
|
17
|
+
settings,
|
|
18
|
+
});
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error('Get prompts error:', error);
|
|
21
|
+
return jsonError(error);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// POST /api/prompts - Update a prompt or setting
|
|
26
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
27
|
+
try {
|
|
28
|
+
const data = await request.json();
|
|
29
|
+
|
|
30
|
+
if (data.type === 'prompt' && data.id && data.promptText !== undefined) {
|
|
31
|
+
await service.updatePrompt(data.id, data.promptText);
|
|
32
|
+
return jsonResponse({ success: true, type: 'prompt', id: data.id });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (data.type === 'setting' && data.key && data.value !== undefined) {
|
|
36
|
+
await service.updateSetting(data.key, data.value);
|
|
37
|
+
return jsonResponse({ success: true, type: 'setting', key: data.key });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return jsonError('Invalid request: missing type, id/key, or value', 400);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Update prompt error:', error);
|
|
43
|
+
return jsonError(error);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { convertToWebP } from '../services/ImageConverter';
|
|
5
|
+
import { jsonResponse, jsonError } from './_utils';
|
|
6
|
+
|
|
7
|
+
const SaveImageSchema = z.object({
|
|
8
|
+
url: z.string().regex(/^data:image\/\w+;base64,.+$/, 'Invalid image data URL'),
|
|
9
|
+
folderPath: z.string().min(1, 'folderPath is required'),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
13
|
+
try {
|
|
14
|
+
const body = await request.json();
|
|
15
|
+
const parsed = SaveImageSchema.safeParse(body);
|
|
16
|
+
if (!parsed.success) {
|
|
17
|
+
return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
|
|
18
|
+
}
|
|
19
|
+
const { url, folderPath } = parsed.data;
|
|
20
|
+
|
|
21
|
+
// Regex is guaranteed to match since Zod already validated the format
|
|
22
|
+
const base64Data = url.split(',')[1]!;
|
|
23
|
+
|
|
24
|
+
// Save hero.webp in the article folder
|
|
25
|
+
const filepath = path.join(folderPath, 'hero.webp');
|
|
26
|
+
const fullPath = path.resolve(process.cwd(), filepath);
|
|
27
|
+
|
|
28
|
+
await convertToWebP(base64Data, fullPath);
|
|
29
|
+
|
|
30
|
+
return jsonResponse({
|
|
31
|
+
success: true,
|
|
32
|
+
filepath: filepath,
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('Image save error:', error);
|
|
36
|
+
return jsonError(error);
|
|
37
|
+
}
|
|
38
|
+
};
|