nca-ai-cms-astro-plugin 1.0.15 → 1.0.17
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/package.json +1 -1
- package/src/api/db/download.ts +35 -0
- package/src/api/db/upload.ts +74 -0
- package/src/api/generate-page.ts +42 -0
- package/src/api/pages/[id]/apply.ts +61 -0
- package/src/api/pages/[id]/regenerate-image.ts +35 -0
- package/src/api/pages/[id]/regenerate-text.ts +39 -0
- package/src/api/pages/[id].ts +40 -0
- package/src/api/pages/index.ts +16 -0
- package/src/api/save-page.ts +50 -0
- package/src/components/Editor.tsx +13 -0
- package/src/components/editor/PagesTab.tsx +231 -0
- package/src/components/editor/SettingsTab.tsx +44 -0
- package/src/components/editor/types.ts +1 -1
- package/src/config.d.ts +1 -0
- package/src/domain/entities/Article.test.ts +23 -0
- package/src/domain/entities/Article.ts +6 -0
- package/src/domain/value-objects/ArticleFinder.test.ts +33 -0
- package/src/domain/value-objects/ArticleFinder.ts +12 -2
- package/src/index.ts +57 -1
- package/src/pages/api/page-image/[...path].ts +50 -0
- package/src/services/ArticleService.ts +17 -7
- package/src/utils/authUtils.ts +1 -1
- package/update.md +29 -0
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { jsonError } from '../_utils';
|
|
5
|
+
|
|
6
|
+
function getDbPath(): string {
|
|
7
|
+
const envPath = process.env.ASTRO_DATABASE_FILE;
|
|
8
|
+
if (envPath) {
|
|
9
|
+
const resolved = path.resolve(process.cwd(), envPath);
|
|
10
|
+
if (!resolved.startsWith(process.cwd())) {
|
|
11
|
+
throw new Error('Invalid database path');
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
return path.join(process.cwd(), '.astro', 'content.db');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const GET: APIRoute = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const dbPath = getDbPath();
|
|
21
|
+
const dbBuffer = await fs.readFile(dbPath);
|
|
22
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
23
|
+
|
|
24
|
+
return new Response(dbBuffer, {
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/x-sqlite3',
|
|
28
|
+
'Content-Disposition': `attachment; filename="content-${timestamp}.db"`,
|
|
29
|
+
'Content-Length': String(dbBuffer.length),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
return jsonError('Database file not found', 404);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
5
|
+
|
|
6
|
+
const MAX_DB_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
7
|
+
|
|
8
|
+
function getDbPath(): string {
|
|
9
|
+
const envPath = process.env.ASTRO_DATABASE_FILE;
|
|
10
|
+
if (envPath) {
|
|
11
|
+
const resolved = path.resolve(process.cwd(), envPath);
|
|
12
|
+
if (!resolved.startsWith(process.cwd())) {
|
|
13
|
+
throw new Error('Invalid database path');
|
|
14
|
+
}
|
|
15
|
+
return resolved;
|
|
16
|
+
}
|
|
17
|
+
return path.join(process.cwd(), '.astro', 'content.db');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
21
|
+
try {
|
|
22
|
+
const dbPath = getDbPath();
|
|
23
|
+
const contentType = request.headers.get('content-type') || '';
|
|
24
|
+
|
|
25
|
+
let dbBuffer: Buffer;
|
|
26
|
+
|
|
27
|
+
if (contentType.includes('multipart/form-data')) {
|
|
28
|
+
const formData = await request.formData();
|
|
29
|
+
const file = formData.get('database') as File | null;
|
|
30
|
+
|
|
31
|
+
if (!file) {
|
|
32
|
+
return jsonError('No database file provided. Use field name "database".', 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (file.size > MAX_DB_SIZE) {
|
|
36
|
+
return jsonError('File too large. Maximum 50 MB.', 413);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
40
|
+
dbBuffer = Buffer.from(arrayBuffer);
|
|
41
|
+
} else if (contentType.includes('application/octet-stream')) {
|
|
42
|
+
const arrayBuffer = await request.arrayBuffer();
|
|
43
|
+
dbBuffer = Buffer.from(arrayBuffer);
|
|
44
|
+
|
|
45
|
+
if (dbBuffer.length > MAX_DB_SIZE) {
|
|
46
|
+
return jsonError('File too large. Maximum 50 MB.', 413);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
return jsonError('Invalid content type. Use multipart/form-data or application/octet-stream.', 400);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// SQLite validation
|
|
53
|
+
const header = dbBuffer.subarray(0, 16).toString('utf8');
|
|
54
|
+
if (!header.startsWith('SQLite format 3')) {
|
|
55
|
+
return jsonError('Invalid file: not a SQLite database.', 400);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Backup current DB before overwriting
|
|
59
|
+
try {
|
|
60
|
+
await fs.copyFile(dbPath, `${dbPath}.backup`);
|
|
61
|
+
} catch {
|
|
62
|
+
// No existing DB to backup
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await fs.writeFile(dbPath, dbBuffer);
|
|
66
|
+
|
|
67
|
+
return jsonResponse({
|
|
68
|
+
success: true,
|
|
69
|
+
size: dbBuffer.length,
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
return jsonError('Database upload failed', 500);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
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 GeneratePageSchema = z.object({
|
|
9
|
+
input: z.string().min(1, 'Input is required'),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
13
|
+
try {
|
|
14
|
+
const body = await request.json();
|
|
15
|
+
const parsed = GeneratePageSchema.safeParse(body);
|
|
16
|
+
if (!parsed.success) {
|
|
17
|
+
return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
|
|
18
|
+
}
|
|
19
|
+
const { input } = parsed.data;
|
|
20
|
+
const isUrl = /^https?:\/\//.test(input);
|
|
21
|
+
|
|
22
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
23
|
+
const promptService = new PromptService();
|
|
24
|
+
const generator = new ContentGenerator({ apiKey, promptService });
|
|
25
|
+
const page = isUrl
|
|
26
|
+
? await generator.generateFromUrl(input)
|
|
27
|
+
: await generator.generateFromKeywords(input);
|
|
28
|
+
|
|
29
|
+
return jsonResponse({
|
|
30
|
+
title: page.title,
|
|
31
|
+
description: page.description,
|
|
32
|
+
content: page.content,
|
|
33
|
+
filepath: page.filepath,
|
|
34
|
+
tags: page.tags,
|
|
35
|
+
date: page.date.toISOString(),
|
|
36
|
+
...(generator.warnings.length > 0 ? { warnings: generator.warnings } : {}),
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Page generation error:', error);
|
|
40
|
+
return jsonError(error);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
11
|
+
import { jsonResponse, jsonError } from '../../_utils';
|
|
12
|
+
|
|
13
|
+
interface ApplyRequest {
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
content?: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
imageUrl?: string;
|
|
19
|
+
imageAlt?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const POST: APIRoute = async ({ params, request }) => {
|
|
23
|
+
try {
|
|
24
|
+
const slug = params.id;
|
|
25
|
+
if (!slug) return jsonError('Page ID required', 400);
|
|
26
|
+
|
|
27
|
+
const data: ApplyRequest = await request.json();
|
|
28
|
+
const service = new ArticleService(pagesPath);
|
|
29
|
+
const existingPage = await service.read(slug);
|
|
30
|
+
if (!existingPage) throw new ArticleNotFoundError(slug);
|
|
31
|
+
|
|
32
|
+
if (data.imageUrl) {
|
|
33
|
+
const heroPath = path.join(existingPage.folderPath, 'hero.webp');
|
|
34
|
+
const base64Data = data.imageUrl.replace(/^data:image\/\w+;base64,/, '');
|
|
35
|
+
await convertToWebP(base64Data, heroPath);
|
|
36
|
+
|
|
37
|
+
if (data.imageAlt) {
|
|
38
|
+
const indexPath = path.join(existingPage.folderPath, 'index.md');
|
|
39
|
+
const fileContent = await fs.readFile(indexPath, 'utf-8');
|
|
40
|
+
const { data: frontmatter, content } = matter(fileContent);
|
|
41
|
+
frontmatter.imageAlt = data.imageAlt;
|
|
42
|
+
const updatedContent = matter.stringify(content, frontmatter);
|
|
43
|
+
await fs.writeFile(indexPath, updatedContent);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (data.content || data.title || data.description) {
|
|
48
|
+
await service.updateContent(slug, {
|
|
49
|
+
...(data.title && { title: data.title }),
|
|
50
|
+
...(data.description && { description: data.description }),
|
|
51
|
+
...(data.content && { content: data.content }),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return jsonResponse({ success: true, articleId: existingPage.articleId });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
|
|
58
|
+
console.error('Apply page changes error:', error);
|
|
59
|
+
return jsonError(error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
9
|
+
import { jsonResponse, jsonError } from '../../_utils';
|
|
10
|
+
|
|
11
|
+
export const POST: APIRoute = async ({ params }) => {
|
|
12
|
+
try {
|
|
13
|
+
const slug = params.id;
|
|
14
|
+
if (!slug) return jsonError('Page ID required', 400);
|
|
15
|
+
|
|
16
|
+
const service = new ArticleService(pagesPath);
|
|
17
|
+
const existingPage = await service.read(slug);
|
|
18
|
+
if (!existingPage) throw new ArticleNotFoundError(slug);
|
|
19
|
+
|
|
20
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
21
|
+
const generator = new ImageGenerator({ apiKey });
|
|
22
|
+
const image = await generator.generate(existingPage.title);
|
|
23
|
+
|
|
24
|
+
return jsonResponse({
|
|
25
|
+
url: image.url,
|
|
26
|
+
alt: image.alt,
|
|
27
|
+
articleId: existingPage.articleId,
|
|
28
|
+
articleTitle: existingPage.title,
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
|
|
32
|
+
console.error('Regenerate page image error:', error);
|
|
33
|
+
return jsonError(error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
10
|
+
import { jsonResponse, jsonError } from '../../_utils';
|
|
11
|
+
|
|
12
|
+
export const POST: APIRoute = async ({ params }) => {
|
|
13
|
+
try {
|
|
14
|
+
const slug = params.id;
|
|
15
|
+
if (!slug) return jsonError('Page ID required', 400);
|
|
16
|
+
|
|
17
|
+
const service = new ArticleService(pagesPath);
|
|
18
|
+
const existingPage = await service.read(slug);
|
|
19
|
+
if (!existingPage) throw new ArticleNotFoundError(slug);
|
|
20
|
+
|
|
21
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
22
|
+
const promptService = new PromptService();
|
|
23
|
+
const generator = new ContentGenerator({ apiKey, promptService });
|
|
24
|
+
const newPage = await generator.generateFromKeywords(existingPage.title);
|
|
25
|
+
|
|
26
|
+
return jsonResponse({
|
|
27
|
+
title: newPage.title,
|
|
28
|
+
description: newPage.description,
|
|
29
|
+
content: newPage.content,
|
|
30
|
+
tags: newPage.tags,
|
|
31
|
+
originalTitle: existingPage.title,
|
|
32
|
+
articleId: existingPage.articleId,
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
|
|
36
|
+
console.error('Regenerate page text error:', error);
|
|
37
|
+
return jsonError(error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import {
|
|
3
|
+
ArticleService,
|
|
4
|
+
ArticleNotFoundError,
|
|
5
|
+
} from '../../services/ArticleService';
|
|
6
|
+
import { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
7
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
8
|
+
|
|
9
|
+
// GET /api/pages/[id] - Get page details
|
|
10
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
11
|
+
try {
|
|
12
|
+
const slug = params.id;
|
|
13
|
+
if (!slug) return jsonError('Page ID required', 400);
|
|
14
|
+
|
|
15
|
+
const service = new ArticleService(pagesPath);
|
|
16
|
+
const page = await service.read(slug);
|
|
17
|
+
if (!page) return jsonError('Page not found', 404);
|
|
18
|
+
|
|
19
|
+
return jsonResponse(page);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error('Read page error:', error);
|
|
22
|
+
return jsonError(error);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// DELETE /api/pages/[id] - Delete a page by slug
|
|
27
|
+
export const DELETE: APIRoute = async ({ params }) => {
|
|
28
|
+
try {
|
|
29
|
+
const slug = params.id;
|
|
30
|
+
if (!slug) return jsonError('Page ID required', 400);
|
|
31
|
+
|
|
32
|
+
const service = new ArticleService(pagesPath);
|
|
33
|
+
await service.delete(slug);
|
|
34
|
+
return jsonResponse({ success: true });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
|
|
37
|
+
console.error('Delete page error:', error);
|
|
38
|
+
return jsonError(error);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { ArticleService } from '../../services/ArticleService';
|
|
3
|
+
import { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
4
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
5
|
+
|
|
6
|
+
// GET /api/pages - List all pages
|
|
7
|
+
export const GET: APIRoute = async () => {
|
|
8
|
+
try {
|
|
9
|
+
const service = new ArticleService(pagesPath);
|
|
10
|
+
const pages = await service.list();
|
|
11
|
+
return jsonResponse(pages);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error('List pages error:', error);
|
|
14
|
+
return jsonError(error);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { Article } from '../domain/entities/Article';
|
|
4
|
+
import { FileWriter } from '../services/FileWriter';
|
|
5
|
+
import { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
6
|
+
import { jsonResponse, jsonError } from './_utils';
|
|
7
|
+
|
|
8
|
+
const SavePageSchema = z.object({
|
|
9
|
+
title: z.string().min(1),
|
|
10
|
+
description: z.string().min(1),
|
|
11
|
+
content: z.string().min(1),
|
|
12
|
+
date: z.string().optional(),
|
|
13
|
+
tags: z.array(z.string()).optional(),
|
|
14
|
+
imageAlt: z.string().optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
18
|
+
try {
|
|
19
|
+
const body = await request.json();
|
|
20
|
+
const parsed = SavePageSchema.safeParse(body);
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
|
|
23
|
+
}
|
|
24
|
+
const data = parsed.data;
|
|
25
|
+
|
|
26
|
+
const page = new Article({
|
|
27
|
+
title: data.title,
|
|
28
|
+
description: data.description,
|
|
29
|
+
content: data.content,
|
|
30
|
+
date: new Date(data.date || Date.now()),
|
|
31
|
+
tags: data.tags || [],
|
|
32
|
+
image: './hero.webp',
|
|
33
|
+
imageAlt: data.imageAlt,
|
|
34
|
+
contentPath: pagesPath,
|
|
35
|
+
flatPath: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const writer = new FileWriter();
|
|
39
|
+
const result = await writer.write(page);
|
|
40
|
+
|
|
41
|
+
return jsonResponse({
|
|
42
|
+
success: true,
|
|
43
|
+
filepath: result.filepath,
|
|
44
|
+
folderPath: page.folderPath,
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Save page error:', error);
|
|
48
|
+
return jsonError(error);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
} from './editor/GenerateTab';
|
|
10
10
|
import { SettingsTab } from './editor/SettingsTab';
|
|
11
11
|
import { PlannerTab } from './editor/PlannerTab';
|
|
12
|
+
import { PagesTab } from './editor/PagesTab';
|
|
12
13
|
|
|
13
14
|
const TABS: { key: TabType; label: string }[] = [
|
|
14
15
|
{ key: 'generate', label: 'Generieren' },
|
|
15
16
|
{ key: 'planner', label: 'Planer' },
|
|
17
|
+
{ key: 'pages', label: 'Seiten' },
|
|
16
18
|
{ key: 'settings', label: 'Einstellungen' },
|
|
17
19
|
];
|
|
18
20
|
|
|
@@ -100,6 +102,17 @@ export default function Editor() {
|
|
|
100
102
|
</div>
|
|
101
103
|
)}
|
|
102
104
|
|
|
105
|
+
{activeTab === 'pages' && (
|
|
106
|
+
<div
|
|
107
|
+
id="panel-pages"
|
|
108
|
+
role="tabpanel"
|
|
109
|
+
aria-labelledby="tab-pages"
|
|
110
|
+
style={isFullWidth ? styles.panelFullWidth : undefined}
|
|
111
|
+
>
|
|
112
|
+
<PagesTab />
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
103
116
|
{activeTab === 'settings' && (
|
|
104
117
|
<div
|
|
105
118
|
id="panel-settings"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { styles } from './styles';
|
|
3
|
+
|
|
4
|
+
interface PageData {
|
|
5
|
+
articleId: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
date: string;
|
|
9
|
+
tags: string[];
|
|
10
|
+
image?: string;
|
|
11
|
+
imageAlt?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PagesTab() {
|
|
15
|
+
const [pages, setPages] = useState<PageData[]>([]);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [generating, setGenerating] = useState(false);
|
|
19
|
+
const [newInput, setNewInput] = useState('');
|
|
20
|
+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
const loadPages = useCallback(async () => {
|
|
23
|
+
setLoading(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch('/api/pages');
|
|
27
|
+
if (!res.ok) throw new Error('Fehler beim Laden der Seiten');
|
|
28
|
+
const data = (await res.json()) as PageData[];
|
|
29
|
+
setPages(data);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
loadPages();
|
|
39
|
+
}, [loadPages]);
|
|
40
|
+
|
|
41
|
+
const handleGenerate = async () => {
|
|
42
|
+
if (!newInput.trim()) return;
|
|
43
|
+
setGenerating(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
// Generate content
|
|
47
|
+
const contentRes = await fetch('/api/generate-page', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ input: newInput }),
|
|
51
|
+
});
|
|
52
|
+
if (!contentRes.ok) throw new Error('Fehler bei der Seitengenerierung');
|
|
53
|
+
const content = await contentRes.json();
|
|
54
|
+
|
|
55
|
+
// Generate image
|
|
56
|
+
const imageRes = await fetch('/api/generate-image', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ title: content.title }),
|
|
60
|
+
});
|
|
61
|
+
const image = imageRes.ok ? await imageRes.json() : null;
|
|
62
|
+
|
|
63
|
+
// Save page
|
|
64
|
+
const saveRes = await fetch('/api/save-page', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
title: content.title,
|
|
69
|
+
description: content.description,
|
|
70
|
+
content: content.content,
|
|
71
|
+
tags: content.tags,
|
|
72
|
+
date: content.date,
|
|
73
|
+
imageAlt: image?.alt,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
if (!saveRes.ok) throw new Error('Fehler beim Speichern der Seite');
|
|
77
|
+
const saveResult = await saveRes.json();
|
|
78
|
+
|
|
79
|
+
// Save image if generated
|
|
80
|
+
if (image?.url && saveResult.folderPath) {
|
|
81
|
+
await fetch('/api/save-image', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
imageUrl: image.url,
|
|
86
|
+
folderPath: saveResult.folderPath,
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setNewInput('');
|
|
92
|
+
await loadPages();
|
|
93
|
+
} catch (err) {
|
|
94
|
+
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
|
95
|
+
} finally {
|
|
96
|
+
setGenerating(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleDelete = async (slug: string) => {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`/api/pages/${slug}`, {
|
|
103
|
+
method: 'DELETE',
|
|
104
|
+
credentials: 'same-origin',
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) throw new Error('Fehler beim Löschen');
|
|
107
|
+
setDeleteConfirm(null);
|
|
108
|
+
await loadPages();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (loading) {
|
|
115
|
+
return <div style={styles.loadingBox}>Seiten werden geladen...</div>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div style={styles.plannerContent}>
|
|
120
|
+
{/* Generate new page form */}
|
|
121
|
+
<div style={styles.plannerForm}>
|
|
122
|
+
<div style={styles.addFormTitle}>Neue Seite generieren</div>
|
|
123
|
+
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'end' }}>
|
|
124
|
+
<div style={{ flex: 1 }}>
|
|
125
|
+
<label style={styles.addFormLabel}>Thema, Keywords oder URL</label>
|
|
126
|
+
<input
|
|
127
|
+
type="text"
|
|
128
|
+
value={newInput}
|
|
129
|
+
onChange={(e) => setNewInput(e.target.value)}
|
|
130
|
+
placeholder="z.B. Laserreinigung, Gleitschleifen..."
|
|
131
|
+
style={styles.addFormInput}
|
|
132
|
+
onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
style={styles.addButton}
|
|
138
|
+
onClick={handleGenerate}
|
|
139
|
+
disabled={generating || !newInput.trim()}
|
|
140
|
+
>
|
|
141
|
+
{generating ? 'Generiert...' : 'Seite erstellen'}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{error && <div style={styles.error}>{error}</div>}
|
|
147
|
+
|
|
148
|
+
{/* Page list */}
|
|
149
|
+
{pages.length === 0 ? (
|
|
150
|
+
<div style={styles.emptyState}>
|
|
151
|
+
Noch keine Seiten vorhanden. Erstellen Sie Ihre erste Seite!
|
|
152
|
+
</div>
|
|
153
|
+
) : (
|
|
154
|
+
<div style={styles.plannerList}>
|
|
155
|
+
{pages.map((page) => (
|
|
156
|
+
<div key={page.articleId} style={styles.plannerCard}>
|
|
157
|
+
<div style={styles.plannerCardHeader}>
|
|
158
|
+
<div>
|
|
159
|
+
<div style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--color-text, #faf9f7)' }}>
|
|
160
|
+
{page.title}
|
|
161
|
+
</div>
|
|
162
|
+
<div style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #b8b5b0)', marginTop: '0.25rem' }}>
|
|
163
|
+
{page.description}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div style={styles.plannerCardActions}>
|
|
167
|
+
<a
|
|
168
|
+
href={`/services/${page.articleId}`}
|
|
169
|
+
style={{
|
|
170
|
+
...styles.editButton,
|
|
171
|
+
textDecoration: 'none',
|
|
172
|
+
display: 'inline-flex',
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
Ansehen
|
|
177
|
+
</a>
|
|
178
|
+
{deleteConfirm === page.articleId ? (
|
|
179
|
+
<>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
style={styles.deleteButton}
|
|
183
|
+
onClick={() => handleDelete(page.articleId)}
|
|
184
|
+
>
|
|
185
|
+
Bestätigen
|
|
186
|
+
</button>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
style={styles.cancelButton}
|
|
190
|
+
onClick={() => setDeleteConfirm(null)}
|
|
191
|
+
>
|
|
192
|
+
Abbrechen
|
|
193
|
+
</button>
|
|
194
|
+
</>
|
|
195
|
+
) : (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
style={styles.deleteButton}
|
|
199
|
+
onClick={() => setDeleteConfirm(page.articleId)}
|
|
200
|
+
>
|
|
201
|
+
Löschen
|
|
202
|
+
</button>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
{page.tags && page.tags.length > 0 && (
|
|
207
|
+
<div style={{ padding: '0.75rem 1.25rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' as const }}>
|
|
208
|
+
{page.tags.map((tag) => (
|
|
209
|
+
<span
|
|
210
|
+
key={tag}
|
|
211
|
+
style={{
|
|
212
|
+
background: 'var(--color-surface-accent, #222226)',
|
|
213
|
+
border: '1px solid var(--color-border, #2a2a2e)',
|
|
214
|
+
borderRadius: '4px',
|
|
215
|
+
padding: '0.15rem 0.5rem',
|
|
216
|
+
fontSize: '0.75rem',
|
|
217
|
+
color: 'var(--color-text-muted, #b8b5b0)',
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{tag}
|
|
221
|
+
</span>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -289,6 +289,50 @@ export function SettingsTab() {
|
|
|
289
289
|
))}
|
|
290
290
|
</div>
|
|
291
291
|
|
|
292
|
+
{/* Database Management — only on Website tab */}
|
|
293
|
+
{activeSubTab === 'website' && (
|
|
294
|
+
<div style={{ ...styles.plannerForm, flexDirection: 'row', alignItems: 'center' }}>
|
|
295
|
+
<span style={{ ...styles.label, marginRight: 'auto' }}>Datenbank-Verwaltung</span>
|
|
296
|
+
<a
|
|
297
|
+
href="/api/db/download"
|
|
298
|
+
style={{ ...styles.editButton, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}
|
|
299
|
+
>
|
|
300
|
+
↓ DB herunterladen
|
|
301
|
+
</a>
|
|
302
|
+
<label style={{ ...styles.editButton, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.4rem', margin: 0 }}>
|
|
303
|
+
↑ DB hochladen
|
|
304
|
+
<input
|
|
305
|
+
type="file"
|
|
306
|
+
accept=".db,.sqlite,.sqlite3"
|
|
307
|
+
style={styles.srOnly}
|
|
308
|
+
onChange={async (e) => {
|
|
309
|
+
const file = e.target.files?.[0];
|
|
310
|
+
if (!file) return;
|
|
311
|
+
if (!confirm(`Datenbank "${file.name}" hochladen? Die aktuelle DB wird ueberschrieben.`)) {
|
|
312
|
+
e.target.value = '';
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const formData = new FormData();
|
|
316
|
+
formData.append('database', file);
|
|
317
|
+
try {
|
|
318
|
+
const res = await fetch('/api/db/upload', { method: 'POST', body: formData });
|
|
319
|
+
const data = await res.json();
|
|
320
|
+
if (res.ok) {
|
|
321
|
+
alert('Datenbank hochgeladen. Seite wird neu geladen.');
|
|
322
|
+
window.location.reload();
|
|
323
|
+
} else {
|
|
324
|
+
alert('Fehler: ' + (data.error || 'Upload fehlgeschlagen'));
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
alert('Upload fehlgeschlagen');
|
|
328
|
+
}
|
|
329
|
+
e.target.value = '';
|
|
330
|
+
}}
|
|
331
|
+
/>
|
|
332
|
+
</label>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
|
|
292
336
|
{loading && (
|
|
293
337
|
<div style={styles.loadingBox}>Einstellungen werden geladen...</div>
|
|
294
338
|
)}
|
package/src/config.d.ts
CHANGED
|
@@ -135,4 +135,27 @@ describe('Article', () => {
|
|
|
135
135
|
expect(markdown).toContain('image: "./hero.webp"');
|
|
136
136
|
expect(markdown).toContain('imageAlt: "Accessibility illustration"');
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
it('generates flat folderPath when flatPath is true', () => {
|
|
140
|
+
const article = new Article({
|
|
141
|
+
...defaultProps,
|
|
142
|
+
contentPath: 'nca-ai-cms-pages',
|
|
143
|
+
flatPath: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(article.folderPath).toBe(
|
|
147
|
+
'nca-ai-cms-pages/html-accessibility-grundlagen'
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('generates nested folderPath when flatPath is false', () => {
|
|
152
|
+
const article = new Article({
|
|
153
|
+
...defaultProps,
|
|
154
|
+
flatPath: false,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(article.folderPath).toBe(
|
|
158
|
+
'nca-ai-cms-content/2025/12/html-accessibility-grundlagen'
|
|
159
|
+
);
|
|
160
|
+
});
|
|
138
161
|
});
|
|
@@ -10,6 +10,7 @@ export type ArticleProps = {
|
|
|
10
10
|
image?: string;
|
|
11
11
|
imageAlt?: string;
|
|
12
12
|
contentPath?: string;
|
|
13
|
+
flatPath?: boolean;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
export class Article {
|
|
@@ -23,6 +24,7 @@ export class Article {
|
|
|
23
24
|
readonly image?: string;
|
|
24
25
|
readonly imageAlt?: string;
|
|
25
26
|
readonly contentPath: string;
|
|
27
|
+
readonly flatPath: boolean;
|
|
26
28
|
|
|
27
29
|
constructor(props: ArticleProps) {
|
|
28
30
|
this.title = props.title;
|
|
@@ -32,6 +34,7 @@ export class Article {
|
|
|
32
34
|
this.slug = new Slug(props.title);
|
|
33
35
|
this.seoMetadata = new SEOMetadata(props.title, props.description);
|
|
34
36
|
this.contentPath = props.contentPath ?? 'nca-ai-cms-content';
|
|
37
|
+
this.flatPath = props.flatPath ?? false;
|
|
35
38
|
|
|
36
39
|
this.content = props.content;
|
|
37
40
|
|
|
@@ -56,6 +59,9 @@ export class Article {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
get folderPath(): string {
|
|
62
|
+
if (this.flatPath) {
|
|
63
|
+
return `${this.contentPath}/${this.slug.toString()}`;
|
|
64
|
+
}
|
|
59
65
|
return `${this.contentPath}/${this.year}/${this.month}/${this.slug.toString()}`;
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -101,4 +101,37 @@ describe('ArticleFinder', () => {
|
|
|
101
101
|
expect(result?.articleId).toBe('2026/01/test-slug');
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
describe('flat folder support', () => {
|
|
106
|
+
it('finds article in flat structure (basePath/slug/index.md)', async () => {
|
|
107
|
+
const flatFolder = path.join(tempDir, 'laserreinigung');
|
|
108
|
+
await fs.mkdir(flatFolder, { recursive: true });
|
|
109
|
+
await fs.writeFile(
|
|
110
|
+
path.join(flatFolder, 'index.md'),
|
|
111
|
+
'---\ntitle: Laserreinigung\n---\nContent'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const result = await finder.findBySlug('laserreinigung');
|
|
115
|
+
|
|
116
|
+
expect(result).not.toBeNull();
|
|
117
|
+
expect(result?.folderPath).toBe(flatFolder);
|
|
118
|
+
expect(result?.articleId).toBe('laserreinigung');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('flat folder takes priority over year/month match', async () => {
|
|
122
|
+
// Create both flat and nested
|
|
123
|
+
const flatFolder = path.join(tempDir, 'my-page');
|
|
124
|
+
await fs.mkdir(flatFolder, { recursive: true });
|
|
125
|
+
await fs.writeFile(path.join(flatFolder, 'index.md'), '---\ntitle: Flat\n---');
|
|
126
|
+
|
|
127
|
+
const nestedFolder = path.join(tempDir, '2026', '01', 'my-page');
|
|
128
|
+
await fs.mkdir(nestedFolder, { recursive: true });
|
|
129
|
+
await fs.writeFile(path.join(nestedFolder, 'index.md'), '---\ntitle: Nested\n---');
|
|
130
|
+
|
|
131
|
+
const result = await finder.findBySlug('my-page');
|
|
132
|
+
|
|
133
|
+
expect(result?.articleId).toBe('my-page');
|
|
134
|
+
expect(result?.folderPath).toBe(flatFolder);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
104
137
|
});
|
|
@@ -12,6 +12,18 @@ export class ArticleFinder {
|
|
|
12
12
|
|
|
13
13
|
async findBySlug(slug: string): Promise<ArticleLocation | null> {
|
|
14
14
|
try {
|
|
15
|
+
// 1. Check flat structure: basePath/slug/index.md
|
|
16
|
+
const flatPath = path.join(this.basePath, slug);
|
|
17
|
+
const flatIndex = path.join(flatPath, 'index.md');
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(flatIndex);
|
|
20
|
+
const stat = await fs.stat(flatPath);
|
|
21
|
+
if (stat.isDirectory()) {
|
|
22
|
+
return { folderPath: flatPath, indexPath: flatIndex, articleId: slug };
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
|
|
26
|
+
// 2. Fall back to year/month/slug structure
|
|
15
27
|
const years = await this.getDirectories(this.basePath);
|
|
16
28
|
|
|
17
29
|
for (const year of years) {
|
|
@@ -27,7 +39,6 @@ export class ArticleFinder {
|
|
|
27
39
|
const folderPath = path.join(monthPath, article);
|
|
28
40
|
const indexPath = path.join(folderPath, 'index.md');
|
|
29
41
|
|
|
30
|
-
// Verify index.md exists
|
|
31
42
|
try {
|
|
32
43
|
await fs.access(indexPath);
|
|
33
44
|
return {
|
|
@@ -36,7 +47,6 @@ export class ArticleFinder {
|
|
|
36
47
|
articleId: `${year}/${month}/${article}`,
|
|
37
48
|
};
|
|
38
49
|
} catch {
|
|
39
|
-
// index.md doesn't exist, skip this folder
|
|
40
50
|
continue;
|
|
41
51
|
}
|
|
42
52
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import node from '@astrojs/node';
|
|
|
6
6
|
|
|
7
7
|
export interface NcaAiCmsPluginOptions {
|
|
8
8
|
contentPath?: string;
|
|
9
|
+
pagesPath?: string;
|
|
9
10
|
autoPublish?: boolean;
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -13,6 +14,7 @@ export default function ncaAiCms(
|
|
|
13
14
|
options: NcaAiCmsPluginOptions = {}
|
|
14
15
|
): AstroIntegration {
|
|
15
16
|
const contentPath = options.contentPath ?? 'nca-ai-cms-content';
|
|
17
|
+
const pagesPath = options.pagesPath ?? 'nca-ai-cms-pages';
|
|
16
18
|
const autoPublish = options.autoPublish ?? import.meta.env.PROD;
|
|
17
19
|
|
|
18
20
|
return {
|
|
@@ -60,7 +62,7 @@ export default function ncaAiCms(
|
|
|
60
62
|
},
|
|
61
63
|
load(id) {
|
|
62
64
|
if (id === '\0virtual:nca-ai-cms/config') {
|
|
63
|
-
return `export const contentPath = ${JSON.stringify(contentPath)};\nexport const autoPublish = ${JSON.stringify(autoPublish)};`;
|
|
65
|
+
return `export const contentPath = ${JSON.stringify(contentPath)};\nexport const pagesPath = ${JSON.stringify(pagesPath)};\nexport const autoPublish = ${JSON.stringify(autoPublish)};`;
|
|
64
66
|
}
|
|
65
67
|
},
|
|
66
68
|
},
|
|
@@ -137,6 +139,60 @@ export default function ncaAiCms(
|
|
|
137
139
|
prerender: false,
|
|
138
140
|
});
|
|
139
141
|
|
|
142
|
+
// Inject page API routes (mirrors article routes with pagesPath)
|
|
143
|
+
injectRoute({
|
|
144
|
+
pattern: '/api/pages',
|
|
145
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/pages/index.ts',
|
|
146
|
+
prerender: false,
|
|
147
|
+
});
|
|
148
|
+
injectRoute({
|
|
149
|
+
pattern: '/api/pages/[id]',
|
|
150
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id].ts',
|
|
151
|
+
prerender: false,
|
|
152
|
+
});
|
|
153
|
+
injectRoute({
|
|
154
|
+
pattern: '/api/pages/[id]/apply',
|
|
155
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id]/apply.ts',
|
|
156
|
+
prerender: false,
|
|
157
|
+
});
|
|
158
|
+
injectRoute({
|
|
159
|
+
pattern: '/api/pages/[id]/regenerate-text',
|
|
160
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id]/regenerate-text.ts',
|
|
161
|
+
prerender: false,
|
|
162
|
+
});
|
|
163
|
+
injectRoute({
|
|
164
|
+
pattern: '/api/pages/[id]/regenerate-image',
|
|
165
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id]/regenerate-image.ts',
|
|
166
|
+
prerender: false,
|
|
167
|
+
});
|
|
168
|
+
injectRoute({
|
|
169
|
+
pattern: '/api/generate-page',
|
|
170
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/generate-page.ts',
|
|
171
|
+
prerender: false,
|
|
172
|
+
});
|
|
173
|
+
injectRoute({
|
|
174
|
+
pattern: '/api/save-page',
|
|
175
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/save-page.ts',
|
|
176
|
+
prerender: false,
|
|
177
|
+
});
|
|
178
|
+
injectRoute({
|
|
179
|
+
pattern: '/api/page-image/[...path]',
|
|
180
|
+
entrypoint: 'nca-ai-cms-astro-plugin/pages/api/page-image/[...path].ts',
|
|
181
|
+
prerender: false,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Inject DB management routes (auth-protected via middleware)
|
|
185
|
+
injectRoute({
|
|
186
|
+
pattern: '/api/db/download',
|
|
187
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/db/download.ts',
|
|
188
|
+
prerender: false,
|
|
189
|
+
});
|
|
190
|
+
injectRoute({
|
|
191
|
+
pattern: '/api/db/upload',
|
|
192
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/db/upload.ts',
|
|
193
|
+
prerender: false,
|
|
194
|
+
});
|
|
195
|
+
|
|
140
196
|
// Inject auth routes
|
|
141
197
|
injectRoute({
|
|
142
198
|
pattern: '/api/auth/login',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { pagesPath } from 'virtual:nca-ai-cms/config';
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
7
|
+
const imagePath = params.path;
|
|
8
|
+
|
|
9
|
+
if (!imagePath) {
|
|
10
|
+
return new Response('Not found', { status: 404 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const allowedExtensions = ['.webp', '.png', '.jpg', '.jpeg'];
|
|
14
|
+
const ext = path.extname(imagePath).toLowerCase();
|
|
15
|
+
|
|
16
|
+
if (!allowedExtensions.includes(ext)) {
|
|
17
|
+
return new Response('Invalid file type', { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const contentDir = path.resolve(process.cwd(), pagesPath);
|
|
21
|
+
const fullPath = path.resolve(contentDir, imagePath);
|
|
22
|
+
|
|
23
|
+
if (!fullPath.startsWith(contentDir + path.sep)) {
|
|
24
|
+
return new Response('Invalid path', { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const imageBuffer = await fs.readFile(fullPath);
|
|
29
|
+
const contentType =
|
|
30
|
+
ext === '.webp'
|
|
31
|
+
? 'image/webp'
|
|
32
|
+
: ext === '.png'
|
|
33
|
+
? 'image/png'
|
|
34
|
+
: 'image/jpeg';
|
|
35
|
+
|
|
36
|
+
const stats = await fs.stat(fullPath);
|
|
37
|
+
const etag = `"${stats.mtimeMs.toString(36)}"`;
|
|
38
|
+
|
|
39
|
+
return new Response(imageBuffer, {
|
|
40
|
+
status: 200,
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': contentType,
|
|
43
|
+
'Cache-Control': 'public, max-age=0, must-revalidate',
|
|
44
|
+
ETag: etag,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
return new Response('Image not found', { status: 404 });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -43,17 +43,27 @@ export class ArticleService {
|
|
|
43
43
|
const fullBasePath = path.join(process.cwd(), this.basePath);
|
|
44
44
|
|
|
45
45
|
try {
|
|
46
|
-
const
|
|
46
|
+
const entries = await fs.readdir(fullBasePath);
|
|
47
47
|
|
|
48
|
-
for (const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const entryPath = path.join(fullBasePath, entry);
|
|
50
|
+
const entryStat = await fs.stat(entryPath).catch(() => null);
|
|
51
|
+
if (!entryStat?.isDirectory()) continue;
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// Check if this is a flat article (has index.md directly)
|
|
54
|
+
const flatIndex = path.join(entryPath, 'index.md');
|
|
55
|
+
try {
|
|
56
|
+
await fs.access(flatIndex);
|
|
57
|
+
const article = await this.read(entry);
|
|
58
|
+
if (article) articles.push(article);
|
|
59
|
+
continue;
|
|
60
|
+
} catch {}
|
|
61
|
+
|
|
62
|
+
// Otherwise treat as year directory → scan month/slug
|
|
63
|
+
const months = await fs.readdir(entryPath);
|
|
54
64
|
|
|
55
65
|
for (const month of months) {
|
|
56
|
-
const monthPath = path.join(
|
|
66
|
+
const monthPath = path.join(entryPath, month);
|
|
57
67
|
const monthStat = await fs.stat(monthPath).catch(() => null);
|
|
58
68
|
if (!monthStat?.isDirectory()) continue;
|
|
59
69
|
|
package/src/utils/authUtils.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateSession } from '../services/SessionService.js';
|
|
2
2
|
|
|
3
3
|
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/logout', '/login'];
|
|
4
|
-
const PUBLIC_PATH_PREFIXES = ['/api/article-image/'];
|
|
4
|
+
const PUBLIC_PATH_PREFIXES = ['/api/article-image/', '/api/page-image/'];
|
|
5
5
|
|
|
6
6
|
export function isPublicPath(pathname: string): boolean {
|
|
7
7
|
return PUBLIC_PATHS.includes(pathname) || PUBLIC_PATH_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
package/update.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
# v1.0.17
|
|
2
|
+
|
|
3
|
+
## Feature: Database download/upload via Editor UI
|
|
4
|
+
- New GET `/api/db/download` endpoint — exports SQLite database as file download with timestamp
|
|
5
|
+
- New POST `/api/db/upload` endpoint — imports SQLite database with validation and backup
|
|
6
|
+
- Download/Upload buttons in Editor → Einstellungen → Website tab
|
|
7
|
+
- Path traversal protection: database path validated against project root
|
|
8
|
+
- File size limit: 50 MB max upload
|
|
9
|
+
- SQLite header validation on upload (rejects non-SQLite files)
|
|
10
|
+
- Automatic backup of current DB before overwrite (`content.db.backup`)
|
|
11
|
+
- Both routes auth-protected via existing middleware
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# v1.0.16
|
|
16
|
+
|
|
17
|
+
## Feature: Pages content type with flat URL structure
|
|
18
|
+
- New `pagesPath` plugin option (default: `nca-ai-cms-pages`) for managing CMS pages separately from articles
|
|
19
|
+
- Flat folder support in ArticleFinder — pages stored as `basePath/slug/index.md` without date hierarchy
|
|
20
|
+
- SEO-friendly URLs: `/services/laserreinigung` instead of `/services/2024/04/laserreinigung`
|
|
21
|
+
- Flat paths take priority over year/month paths when both exist
|
|
22
|
+
- New `flatPath` option on Article entity for flat folder creation
|
|
23
|
+
- 8 new API routes: `/api/pages/*` (list, CRUD, regenerate text/image, apply, generate, save)
|
|
24
|
+
- `/api/page-image/[...path]` route for serving page images (public, no auth required)
|
|
25
|
+
- New "Seiten" tab in editor UI — list, create, delete pages with AI content generation
|
|
26
|
+
- 4 new tests (211 total, up from 207)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
1
30
|
# v1.0.15
|
|
2
31
|
|
|
3
32
|
## Security: authentication hardening
|