nca-ai-cms-astro-plugin 1.0.17 → 1.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/package.json +3 -2
- package/src/api/articles/create.ts +103 -0
- package/src/api/db/upload.test.ts +173 -0
- package/src/api/db/upload.ts +13 -0
- package/src/components/frontend/EditorToolbar.astro +353 -0
- package/src/components/frontend/InlineEditor.astro +307 -0
- package/src/index.ts +5 -0
- package/src/services/ArticleService.ts +2 -0
- package/update.md +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nca-ai-cms-astro-plugin",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"./api/*": "./src/api/*",
|
|
13
13
|
"./pages/*": "./src/pages/*",
|
|
14
14
|
"./db/*": "./src/db/*",
|
|
15
|
-
"./middleware.ts": "./src/middleware.ts"
|
|
15
|
+
"./middleware.ts": "./src/middleware.ts",
|
|
16
|
+
"./components/frontend/*": "./src/components/frontend/*"
|
|
16
17
|
},
|
|
17
18
|
"scripts": {
|
|
18
19
|
"test": "vitest run",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ContentGenerator } from '../../services/ContentGenerator';
|
|
4
|
+
import { ImageGenerator } from '../../services/ImageGenerator';
|
|
5
|
+
import { PromptService } from '../../services/PromptService';
|
|
6
|
+
import { ArticleService } from '../../services/ArticleService';
|
|
7
|
+
import { Article } from '../../domain/entities/Article';
|
|
8
|
+
import { FileWriter } from '../../services/FileWriter';
|
|
9
|
+
import { convertToWebP } from '../../services/ImageConverter';
|
|
10
|
+
import { getEnvVariable } from '../../utils/envUtils';
|
|
11
|
+
import { contentPath } from 'virtual:nca-ai-cms/config';
|
|
12
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
13
|
+
|
|
14
|
+
const CreateArticleSchema = z.object({
|
|
15
|
+
input: z.string().min(1, 'Thema ist erforderlich'),
|
|
16
|
+
notes: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// POST /api/articles/create - Generate content + image and save in one call
|
|
20
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
21
|
+
try {
|
|
22
|
+
// CSRF: reject cross-origin requests
|
|
23
|
+
const origin = request.headers.get('origin');
|
|
24
|
+
const requestUrl = new URL(request.url);
|
|
25
|
+
if (origin && new URL(origin).origin !== requestUrl.origin) {
|
|
26
|
+
return jsonError('Forbidden', 403);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let body: unknown;
|
|
30
|
+
try {
|
|
31
|
+
body = await request.json();
|
|
32
|
+
} catch {
|
|
33
|
+
return jsonError('Invalid JSON', 400);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parsed = CreateArticleSchema.safeParse(body);
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { input, notes } = parsed.data;
|
|
42
|
+
const isUrl = /^https?:\/\//.test(input);
|
|
43
|
+
|
|
44
|
+
// Build combined input with notes if provided
|
|
45
|
+
const combinedInput = notes ? `${input}\n\nHinweise: ${notes}` : input;
|
|
46
|
+
|
|
47
|
+
const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
|
|
48
|
+
const promptService = new PromptService();
|
|
49
|
+
const generator = new ContentGenerator({ apiKey, promptService });
|
|
50
|
+
|
|
51
|
+
// Generate content
|
|
52
|
+
const article = isUrl
|
|
53
|
+
? await generator.generateFromUrl(combinedInput)
|
|
54
|
+
: await generator.generateFromKeywords(combinedInput);
|
|
55
|
+
|
|
56
|
+
// Create article entity with image reference
|
|
57
|
+
const articleEntity = new Article({
|
|
58
|
+
title: article.title,
|
|
59
|
+
description: article.description,
|
|
60
|
+
content: article.content,
|
|
61
|
+
date: new Date(),
|
|
62
|
+
tags: article.tags,
|
|
63
|
+
image: './hero.webp',
|
|
64
|
+
contentPath,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Save markdown file
|
|
68
|
+
const writer = new FileWriter();
|
|
69
|
+
await writer.write(articleEntity);
|
|
70
|
+
|
|
71
|
+
// Generate and save hero image
|
|
72
|
+
try {
|
|
73
|
+
const imageGenerator = new ImageGenerator({ apiKey });
|
|
74
|
+
const image = await imageGenerator.generate(article.title);
|
|
75
|
+
|
|
76
|
+
if (image.base64) {
|
|
77
|
+
const heroPath = `${articleEntity.folderPath}/hero.webp`;
|
|
78
|
+
await convertToWebP(image.base64, heroPath);
|
|
79
|
+
|
|
80
|
+
// Update frontmatter with imageAlt
|
|
81
|
+
const service = new ArticleService(contentPath);
|
|
82
|
+
await service.updateContent(articleEntity.slug.toString(), {
|
|
83
|
+
imageAlt: image.alt,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
} catch (imageError) {
|
|
87
|
+
console.error('Image generation failed (article saved without image):', imageError);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build the slug path for redirect
|
|
91
|
+
const slugPath = `${articleEntity.year}/${articleEntity.month}/${articleEntity.slug.toString()}`;
|
|
92
|
+
|
|
93
|
+
return jsonResponse({
|
|
94
|
+
success: true,
|
|
95
|
+
slug: slugPath,
|
|
96
|
+
articleId: articleEntity.slug.toString(),
|
|
97
|
+
title: article.title,
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('Create article error:', error);
|
|
101
|
+
return jsonError(error);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
const mockClose = vi.fn();
|
|
5
|
+
const mockReconnect = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock('astro:db', () => ({
|
|
8
|
+
db: {
|
|
9
|
+
$client: {
|
|
10
|
+
close: mockClose,
|
|
11
|
+
reconnect: mockReconnect,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('fs/promises', () => ({
|
|
17
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
copyFile: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// SQLite file header
|
|
22
|
+
const SQLITE_HEADER = Buffer.from('SQLite format 3\0');
|
|
23
|
+
|
|
24
|
+
function createSqliteBuffer(size = 4096): Buffer {
|
|
25
|
+
const buf = Buffer.alloc(size);
|
|
26
|
+
SQLITE_HEADER.copy(buf);
|
|
27
|
+
return buf;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createFormDataRequest(file: Buffer, fieldName = 'database'): Request {
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append(fieldName, new Blob([file]), 'test.db');
|
|
33
|
+
return new Request('http://localhost/api/db/upload', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
body: formData,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createOctetStreamRequest(file: Buffer): Request {
|
|
40
|
+
return new Request('http://localhost/api/db/upload', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
43
|
+
body: file,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('DB Upload API', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
process.env.ASTRO_DATABASE_FILE = '.astro/content.db';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts a valid SQLite file via multipart/form-data', async () => {
|
|
54
|
+
const { POST } = await import('./upload.js');
|
|
55
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
56
|
+
|
|
57
|
+
const response = await POST({ request } as any);
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
|
|
60
|
+
expect(response.status).toBe(200);
|
|
61
|
+
expect(data.success).toBe(true);
|
|
62
|
+
expect(data.size).toBeGreaterThan(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts a valid SQLite file via application/octet-stream', async () => {
|
|
66
|
+
const { POST } = await import('./upload.js');
|
|
67
|
+
const request = createOctetStreamRequest(createSqliteBuffer());
|
|
68
|
+
|
|
69
|
+
const response = await POST({ request } as any);
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
|
|
72
|
+
expect(response.status).toBe(200);
|
|
73
|
+
expect(data.success).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects non-SQLite files', async () => {
|
|
77
|
+
const { POST } = await import('./upload.js');
|
|
78
|
+
const badFile = Buffer.from('not a sqlite file');
|
|
79
|
+
const request = createFormDataRequest(badFile);
|
|
80
|
+
|
|
81
|
+
const response = await POST({ request } as any);
|
|
82
|
+
const data = await response.json();
|
|
83
|
+
|
|
84
|
+
expect(response.status).toBe(400);
|
|
85
|
+
expect(data.error).toContain('not a SQLite database');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects missing database field', async () => {
|
|
89
|
+
const { POST } = await import('./upload.js');
|
|
90
|
+
const formData = new FormData();
|
|
91
|
+
formData.append('wrongfield', new Blob([createSqliteBuffer()]), 'test.db');
|
|
92
|
+
const request = new Request('http://localhost/api/db/upload', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body: formData,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const response = await POST({ request } as any);
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
|
|
100
|
+
expect(response.status).toBe(400);
|
|
101
|
+
expect(data.error).toContain('No database file');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects invalid content type', async () => {
|
|
105
|
+
const { POST } = await import('./upload.js');
|
|
106
|
+
const request = new Request('http://localhost/api/db/upload', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
109
|
+
body: 'hello',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const response = await POST({ request } as any);
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
|
|
115
|
+
expect(response.status).toBe(400);
|
|
116
|
+
expect(data.error).toContain('Invalid content type');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('creates a backup before writing', async () => {
|
|
120
|
+
const { POST } = await import('./upload.js');
|
|
121
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
122
|
+
|
|
123
|
+
await POST({ request } as any);
|
|
124
|
+
|
|
125
|
+
expect(fs.copyFile).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reconnects the DB client after successful upload', async () => {
|
|
129
|
+
const { POST } = await import('./upload.js');
|
|
130
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
131
|
+
|
|
132
|
+
await POST({ request } as any);
|
|
133
|
+
|
|
134
|
+
expect(mockClose).toHaveBeenCalledOnce();
|
|
135
|
+
expect(mockReconnect).toHaveBeenCalledOnce();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects oversized file via multipart/form-data', async () => {
|
|
139
|
+
const { POST } = await import('./upload.js');
|
|
140
|
+
const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
|
|
141
|
+
const request = createFormDataRequest(oversized);
|
|
142
|
+
|
|
143
|
+
const response = await POST({ request } as any);
|
|
144
|
+
const data = await response.json();
|
|
145
|
+
|
|
146
|
+
expect(response.status).toBe(413);
|
|
147
|
+
expect(data.error).toContain('too large');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects oversized file via application/octet-stream', async () => {
|
|
151
|
+
const { POST } = await import('./upload.js');
|
|
152
|
+
const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
|
|
153
|
+
const request = createOctetStreamRequest(oversized);
|
|
154
|
+
|
|
155
|
+
const response = await POST({ request } as any);
|
|
156
|
+
const data = await response.json();
|
|
157
|
+
|
|
158
|
+
expect(response.status).toBe(413);
|
|
159
|
+
expect(data.error).toContain('too large');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('still succeeds if reconnect is not available', async () => {
|
|
163
|
+
const { POST } = await import('./upload.js');
|
|
164
|
+
mockClose.mockImplementation(() => { throw new Error('not available'); });
|
|
165
|
+
|
|
166
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
167
|
+
const response = await POST({ request } as any);
|
|
168
|
+
const data = await response.json();
|
|
169
|
+
|
|
170
|
+
expect(response.status).toBe(200);
|
|
171
|
+
expect(data.success).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/api/db/upload.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { jsonResponse, jsonError } from '../_utils';
|
|
5
|
+
// @ts-ignore - resolved by Astro build pipeline
|
|
6
|
+
import { db } from 'astro:db';
|
|
5
7
|
|
|
6
8
|
const MAX_DB_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
7
9
|
|
|
@@ -64,6 +66,17 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
64
66
|
|
|
65
67
|
await fs.writeFile(dbPath, dbBuffer);
|
|
66
68
|
|
|
69
|
+
// Reconnect DB client to pick up the new file
|
|
70
|
+
try {
|
|
71
|
+
const client = (db as any).$client;
|
|
72
|
+
if (client?.close && client?.reconnect) {
|
|
73
|
+
await client.close();
|
|
74
|
+
await client.reconnect();
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Reconnect not available — manual restart needed
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
return jsonResponse({
|
|
68
81
|
success: true,
|
|
69
82
|
size: dbBuffer.length,
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
isAuthenticated?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { isAuthenticated } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
{isAuthenticated && (
|
|
10
|
+
<div id="editor-toolbar">
|
|
11
|
+
<!-- FAB Button -->
|
|
12
|
+
<button
|
|
13
|
+
id="editor-fab"
|
|
14
|
+
type="button"
|
|
15
|
+
aria-label="Neuen Artikel erstellen"
|
|
16
|
+
title="Neuen Artikel erstellen"
|
|
17
|
+
>
|
|
18
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
19
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
20
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
21
|
+
</svg>
|
|
22
|
+
</button>
|
|
23
|
+
|
|
24
|
+
<!-- Panel -->
|
|
25
|
+
<div id="editor-panel" hidden>
|
|
26
|
+
<div class="editor-panel-header">
|
|
27
|
+
<h3>Neuer Artikel</h3>
|
|
28
|
+
<button type="button" id="editor-panel-close" aria-label="Schließen">
|
|
29
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
30
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
31
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
32
|
+
</svg>
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<form id="editor-create-form">
|
|
37
|
+
<label for="editor-input-topic">Thema oder Keyword</label>
|
|
38
|
+
<input
|
|
39
|
+
type="text"
|
|
40
|
+
id="editor-input-topic"
|
|
41
|
+
name="input"
|
|
42
|
+
placeholder="z.B. Laserreinigung"
|
|
43
|
+
required
|
|
44
|
+
autocomplete="off"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
<details id="editor-details">
|
|
48
|
+
<summary>Zusätzliche Hinweise</summary>
|
|
49
|
+
<textarea
|
|
50
|
+
id="editor-input-notes"
|
|
51
|
+
name="notes"
|
|
52
|
+
rows="3"
|
|
53
|
+
placeholder="z.B. Fokus auf Kostenersparnis, Vergleich mit Sandstrahlen erwähnen"
|
|
54
|
+
></textarea>
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
<div class="editor-panel-actions">
|
|
58
|
+
<button type="submit" id="editor-submit-btn">
|
|
59
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
60
|
+
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
|
61
|
+
</svg>
|
|
62
|
+
Generieren
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div id="editor-progress" hidden>
|
|
67
|
+
<div class="editor-progress-spinner"></div>
|
|
68
|
+
<span id="editor-progress-text">Artikel wird generiert...</span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div id="editor-error" hidden></div>
|
|
72
|
+
</form>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<style>
|
|
78
|
+
#editor-toolbar {
|
|
79
|
+
position: fixed;
|
|
80
|
+
bottom: 2rem;
|
|
81
|
+
right: 2rem;
|
|
82
|
+
z-index: 9999;
|
|
83
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#editor-fab {
|
|
87
|
+
width: 56px;
|
|
88
|
+
height: 56px;
|
|
89
|
+
border-radius: 50%;
|
|
90
|
+
background: #1e293b;
|
|
91
|
+
color: white;
|
|
92
|
+
border: none;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
|
98
|
+
transition: transform 0.2s, background 0.2s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#editor-fab:hover {
|
|
102
|
+
background: #334155;
|
|
103
|
+
transform: scale(1.05);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#editor-fab.active {
|
|
107
|
+
transform: rotate(45deg);
|
|
108
|
+
background: #475569;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#editor-panel {
|
|
112
|
+
position: absolute;
|
|
113
|
+
bottom: 72px;
|
|
114
|
+
right: 0;
|
|
115
|
+
width: 360px;
|
|
116
|
+
background: white;
|
|
117
|
+
border-radius: 12px;
|
|
118
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
|
|
119
|
+
border: 1px solid #e2e8f0;
|
|
120
|
+
overflow: hidden;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.editor-panel-header {
|
|
124
|
+
display: flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: space-between;
|
|
127
|
+
padding: 16px 20px;
|
|
128
|
+
border-bottom: 1px solid #e2e8f0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.editor-panel-header h3 {
|
|
132
|
+
margin: 0;
|
|
133
|
+
font-size: 16px;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
color: #1e293b;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.editor-panel-header button {
|
|
139
|
+
background: none;
|
|
140
|
+
border: none;
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
color: #94a3b8;
|
|
143
|
+
padding: 4px;
|
|
144
|
+
border-radius: 4px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.editor-panel-header button:hover {
|
|
148
|
+
color: #475569;
|
|
149
|
+
background: #f1f5f9;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#editor-create-form {
|
|
153
|
+
padding: 20px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#editor-create-form label {
|
|
157
|
+
display: block;
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
font-weight: 500;
|
|
160
|
+
color: #475569;
|
|
161
|
+
margin-bottom: 6px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#editor-create-form input,
|
|
165
|
+
#editor-create-form textarea {
|
|
166
|
+
width: 100%;
|
|
167
|
+
padding: 10px 12px;
|
|
168
|
+
border: 1px solid #cbd5e1;
|
|
169
|
+
border-radius: 8px;
|
|
170
|
+
font-size: 14px;
|
|
171
|
+
color: #1e293b;
|
|
172
|
+
background: #f8fafc;
|
|
173
|
+
transition: border-color 0.2s;
|
|
174
|
+
box-sizing: border-box;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#editor-create-form input:focus,
|
|
178
|
+
#editor-create-form textarea:focus {
|
|
179
|
+
outline: none;
|
|
180
|
+
border-color: #3b82f6;
|
|
181
|
+
background: white;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#editor-create-form textarea {
|
|
185
|
+
resize: vertical;
|
|
186
|
+
font-family: inherit;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#editor-details {
|
|
190
|
+
margin-top: 12px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#editor-details summary {
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
color: #64748b;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
user-select: none;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#editor-details summary:hover {
|
|
201
|
+
color: #3b82f6;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#editor-details textarea {
|
|
205
|
+
margin-top: 8px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.editor-panel-actions {
|
|
209
|
+
margin-top: 16px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#editor-submit-btn {
|
|
213
|
+
width: 100%;
|
|
214
|
+
padding: 10px 16px;
|
|
215
|
+
background: #1e293b;
|
|
216
|
+
color: white;
|
|
217
|
+
border: none;
|
|
218
|
+
border-radius: 8px;
|
|
219
|
+
font-size: 14px;
|
|
220
|
+
font-weight: 500;
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
display: flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
justify-content: center;
|
|
225
|
+
gap: 8px;
|
|
226
|
+
transition: background 0.2s;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#editor-submit-btn:hover {
|
|
230
|
+
background: #334155;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#editor-submit-btn:disabled {
|
|
234
|
+
opacity: 0.5;
|
|
235
|
+
cursor: not-allowed;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#editor-progress {
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
gap: 10px;
|
|
242
|
+
margin-top: 16px;
|
|
243
|
+
padding: 12px;
|
|
244
|
+
background: #f0f9ff;
|
|
245
|
+
border-radius: 8px;
|
|
246
|
+
font-size: 13px;
|
|
247
|
+
color: #1e40af;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.editor-progress-spinner {
|
|
251
|
+
width: 18px;
|
|
252
|
+
height: 18px;
|
|
253
|
+
border: 2px solid #bfdbfe;
|
|
254
|
+
border-top-color: #3b82f6;
|
|
255
|
+
border-radius: 50%;
|
|
256
|
+
animation: editor-spin 0.8s linear infinite;
|
|
257
|
+
flex-shrink: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@keyframes editor-spin {
|
|
261
|
+
to { transform: rotate(360deg); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#editor-error {
|
|
265
|
+
margin-top: 12px;
|
|
266
|
+
padding: 10px 12px;
|
|
267
|
+
background: #fef2f2;
|
|
268
|
+
border-radius: 8px;
|
|
269
|
+
font-size: 13px;
|
|
270
|
+
color: #dc2626;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
[hidden] {
|
|
274
|
+
display: none !important;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@media (max-width: 480px) {
|
|
278
|
+
#editor-panel {
|
|
279
|
+
width: calc(100vw - 2rem);
|
|
280
|
+
right: -1rem;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
</style>
|
|
284
|
+
|
|
285
|
+
<script>
|
|
286
|
+
const fab = document.getElementById('editor-fab') as HTMLButtonElement;
|
|
287
|
+
const panel = document.getElementById('editor-panel') as HTMLElement;
|
|
288
|
+
const closeBtn = document.getElementById('editor-panel-close') as HTMLButtonElement;
|
|
289
|
+
const form = document.getElementById('editor-create-form') as HTMLFormElement;
|
|
290
|
+
const submitBtn = document.getElementById('editor-submit-btn') as HTMLButtonElement;
|
|
291
|
+
const progress = document.getElementById('editor-progress') as HTMLElement;
|
|
292
|
+
const progressText = document.getElementById('editor-progress-text') as HTMLElement;
|
|
293
|
+
const errorDiv = document.getElementById('editor-error') as HTMLElement;
|
|
294
|
+
const topicInput = document.getElementById('editor-input-topic') as HTMLInputElement;
|
|
295
|
+
const notesInput = document.getElementById('editor-input-notes') as HTMLTextAreaElement;
|
|
296
|
+
|
|
297
|
+
if (fab && panel) {
|
|
298
|
+
const togglePanel = () => {
|
|
299
|
+
const isOpen = !panel.hidden;
|
|
300
|
+
panel.hidden = isOpen;
|
|
301
|
+
fab.classList.toggle('active', !isOpen);
|
|
302
|
+
if (!isOpen) {
|
|
303
|
+
topicInput?.focus();
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
fab.addEventListener('click', togglePanel);
|
|
308
|
+
closeBtn?.addEventListener('click', togglePanel);
|
|
309
|
+
|
|
310
|
+
// Close on Escape
|
|
311
|
+
document.addEventListener('keydown', (e) => {
|
|
312
|
+
if (e.key === 'Escape' && !panel.hidden) {
|
|
313
|
+
togglePanel();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
form?.addEventListener('submit', async (e) => {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
const input = topicInput.value.trim();
|
|
320
|
+
if (!input) return;
|
|
321
|
+
|
|
322
|
+
submitBtn.disabled = true;
|
|
323
|
+
errorDiv.hidden = true;
|
|
324
|
+
progress.hidden = false;
|
|
325
|
+
progressText.textContent = 'Artikel wird generiert...';
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch('/api/articles/create', {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({
|
|
332
|
+
input,
|
|
333
|
+
notes: notesInput?.value.trim() || undefined,
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (!res.ok) {
|
|
338
|
+
const data = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
|
339
|
+
throw new Error(data.error || `Fehler ${res.status}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const data = await res.json();
|
|
343
|
+
progressText.textContent = 'Weiterleitung...';
|
|
344
|
+
window.location.href = `/articles/${data.slug}`;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
errorDiv.textContent = err instanceof Error ? err.message : 'Generierung fehlgeschlagen';
|
|
347
|
+
errorDiv.hidden = false;
|
|
348
|
+
progress.hidden = true;
|
|
349
|
+
submitBtn.disabled = false;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
</script>
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
articleId: string;
|
|
4
|
+
markdown: string;
|
|
5
|
+
isAuthenticated?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<div class="inline-editor-wrapper" data-article-id={articleId} data-authenticated={isAuthenticated ? 'true' : 'false'}>
|
|
12
|
+
<div class="inline-editor-content">
|
|
13
|
+
{isAuthenticated && (
|
|
14
|
+
<div class="inline-editor-toolbar">
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
class="inline-edit-btn"
|
|
18
|
+
aria-label="Inhalt bearbeiten"
|
|
19
|
+
title="Inhalt bearbeiten"
|
|
20
|
+
>
|
|
21
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
22
|
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
23
|
+
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
24
|
+
</svg>
|
|
25
|
+
</button>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
class="inline-regenerate-btn"
|
|
29
|
+
aria-label="Text neu generieren"
|
|
30
|
+
title="Text neu generieren"
|
|
31
|
+
>
|
|
32
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
33
|
+
<path d="M23 4v6h-6" />
|
|
34
|
+
<path d="M1 20v-6h6" />
|
|
35
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
|
36
|
+
</svg>
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
class="inline-apply-btn"
|
|
41
|
+
hidden
|
|
42
|
+
aria-label="Änderungen übernehmen"
|
|
43
|
+
title="Speichern"
|
|
44
|
+
>
|
|
45
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
|
46
|
+
<polyline points="20 6 9 17 4 12" />
|
|
47
|
+
</svg>
|
|
48
|
+
</button>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
class="inline-cancel-btn"
|
|
52
|
+
hidden
|
|
53
|
+
aria-label="Abbrechen"
|
|
54
|
+
title="Abbrechen"
|
|
55
|
+
>
|
|
56
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
57
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
58
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
59
|
+
</svg>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<!-- Rendered content (slot) -->
|
|
65
|
+
<div class="inline-editor-rendered">
|
|
66
|
+
<slot />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<!-- Markdown editor (hidden by default) -->
|
|
70
|
+
<div class="inline-editor-editing" hidden>
|
|
71
|
+
<textarea class="inline-editor-textarea">{markdown}</textarea>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<!-- Regenerated preview (hidden by default) -->
|
|
75
|
+
<div class="inline-editor-preview" hidden></div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<style>
|
|
80
|
+
.inline-editor-wrapper {
|
|
81
|
+
position: relative;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.inline-editor-toolbar {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 4px;
|
|
88
|
+
margin-bottom: 12px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.inline-editor-toolbar button {
|
|
92
|
+
padding: 6px;
|
|
93
|
+
border-radius: 6px;
|
|
94
|
+
border: 1px solid #e2e8f0;
|
|
95
|
+
background: #f8fafc;
|
|
96
|
+
color: #64748b;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
transition: all 0.15s;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.inline-editor-toolbar button:hover {
|
|
105
|
+
background: #e2e8f0;
|
|
106
|
+
color: #334155;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.inline-edit-btn.active {
|
|
110
|
+
background: #3b82f6 !important;
|
|
111
|
+
color: white !important;
|
|
112
|
+
border-color: #3b82f6 !important;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.inline-apply-btn {
|
|
116
|
+
background: #dcfce7 !important;
|
|
117
|
+
color: #16a34a !important;
|
|
118
|
+
border-color: #bbf7d0 !important;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.inline-apply-btn:hover {
|
|
122
|
+
background: #bbf7d0 !important;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.inline-cancel-btn {
|
|
126
|
+
background: #fef2f2 !important;
|
|
127
|
+
color: #dc2626 !important;
|
|
128
|
+
border-color: #fecaca !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.inline-cancel-btn:hover {
|
|
132
|
+
background: #fecaca !important;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.inline-editor-textarea {
|
|
136
|
+
width: 100%;
|
|
137
|
+
min-height: 500px;
|
|
138
|
+
padding: 16px;
|
|
139
|
+
border: 2px solid #3b82f6;
|
|
140
|
+
border-radius: 8px;
|
|
141
|
+
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
|
|
142
|
+
font-size: 14px;
|
|
143
|
+
line-height: 1.6;
|
|
144
|
+
color: #1e293b;
|
|
145
|
+
background: #fafbfc;
|
|
146
|
+
resize: vertical;
|
|
147
|
+
box-sizing: border-box;
|
|
148
|
+
tab-size: 2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.inline-editor-textarea:focus {
|
|
152
|
+
outline: none;
|
|
153
|
+
border-color: #2563eb;
|
|
154
|
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.loading svg {
|
|
158
|
+
animation: inline-spin 1s linear infinite;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@keyframes inline-spin {
|
|
162
|
+
to { transform: rotate(360deg); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
[hidden] {
|
|
166
|
+
display: none !important;
|
|
167
|
+
}
|
|
168
|
+
</style>
|
|
169
|
+
|
|
170
|
+
<script>
|
|
171
|
+
document.querySelectorAll('.inline-editor-wrapper').forEach((wrapper) => {
|
|
172
|
+
const el = wrapper as HTMLElement;
|
|
173
|
+
if (el.dataset.authenticated !== 'true') return;
|
|
174
|
+
|
|
175
|
+
const articleId = el.dataset.articleId!;
|
|
176
|
+
const editBtn = el.querySelector('.inline-edit-btn') as HTMLButtonElement;
|
|
177
|
+
const regenBtn = el.querySelector('.inline-regenerate-btn') as HTMLButtonElement;
|
|
178
|
+
const applyBtn = el.querySelector('.inline-apply-btn') as HTMLButtonElement;
|
|
179
|
+
const cancelBtn = el.querySelector('.inline-cancel-btn') as HTMLButtonElement;
|
|
180
|
+
const rendered = el.querySelector('.inline-editor-rendered') as HTMLElement;
|
|
181
|
+
const editing = el.querySelector('.inline-editor-editing') as HTMLElement;
|
|
182
|
+
const preview = el.querySelector('.inline-editor-preview') as HTMLElement;
|
|
183
|
+
const textarea = el.querySelector('.inline-editor-textarea') as HTMLTextAreaElement;
|
|
184
|
+
|
|
185
|
+
type EditorMode = 'view' | 'edit' | 'preview';
|
|
186
|
+
let mode: EditorMode = 'view';
|
|
187
|
+
let previewData: { title?: string; description?: string; content?: string } | null = null;
|
|
188
|
+
|
|
189
|
+
const setMode = (newMode: EditorMode) => {
|
|
190
|
+
mode = newMode;
|
|
191
|
+
rendered.hidden = mode !== 'view';
|
|
192
|
+
editing.hidden = mode !== 'edit';
|
|
193
|
+
preview.hidden = mode !== 'preview';
|
|
194
|
+
|
|
195
|
+
editBtn.classList.toggle('active', mode === 'edit');
|
|
196
|
+
editBtn.hidden = mode === 'preview';
|
|
197
|
+
regenBtn.hidden = mode !== 'view';
|
|
198
|
+
applyBtn.hidden = mode === 'view';
|
|
199
|
+
cancelBtn.hidden = mode === 'view';
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Edit mode
|
|
203
|
+
editBtn.addEventListener('click', () => {
|
|
204
|
+
if (mode === 'edit') {
|
|
205
|
+
setMode('view');
|
|
206
|
+
} else {
|
|
207
|
+
setMode('edit');
|
|
208
|
+
textarea.focus();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Cancel
|
|
213
|
+
cancelBtn.addEventListener('click', () => {
|
|
214
|
+
setMode('view');
|
|
215
|
+
previewData = null;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Save (from edit or preview mode)
|
|
219
|
+
applyBtn.addEventListener('click', async () => {
|
|
220
|
+
applyBtn.disabled = true;
|
|
221
|
+
applyBtn.classList.add('loading');
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const body: Record<string, string> = {};
|
|
225
|
+
|
|
226
|
+
if (mode === 'edit') {
|
|
227
|
+
body.content = textarea.value;
|
|
228
|
+
} else if (mode === 'preview' && previewData) {
|
|
229
|
+
if (previewData.title) body.title = previewData.title;
|
|
230
|
+
if (previewData.description) body.description = previewData.description;
|
|
231
|
+
if (previewData.content) body.content = previewData.content;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const res = await fetch(`/api/articles/${articleId}/apply`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json' },
|
|
237
|
+
body: JSON.stringify(body),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!res.ok) throw new Error('Speichern fehlgeschlagen');
|
|
241
|
+
window.location.reload();
|
|
242
|
+
} catch {
|
|
243
|
+
alert('Speichern fehlgeschlagen');
|
|
244
|
+
applyBtn.disabled = false;
|
|
245
|
+
applyBtn.classList.remove('loading');
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Regenerate text
|
|
250
|
+
regenBtn.addEventListener('click', async () => {
|
|
251
|
+
regenBtn.disabled = true;
|
|
252
|
+
regenBtn.classList.add('loading');
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(`/api/articles/${articleId}/regenerate-text`, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
});
|
|
258
|
+
if (!res.ok) throw new Error('Generierung fehlgeschlagen');
|
|
259
|
+
|
|
260
|
+
const data = await res.json();
|
|
261
|
+
previewData = { title: data.title, description: data.description, content: data.content };
|
|
262
|
+
|
|
263
|
+
// Show preview as raw markdown
|
|
264
|
+
preview.innerHTML = '';
|
|
265
|
+
const pre = document.createElement('pre');
|
|
266
|
+
pre.style.whiteSpace = 'pre-wrap';
|
|
267
|
+
pre.style.fontFamily = "'JetBrains Mono', ui-monospace, monospace";
|
|
268
|
+
pre.style.fontSize = '14px';
|
|
269
|
+
pre.style.lineHeight = '1.6';
|
|
270
|
+
pre.style.padding = '16px';
|
|
271
|
+
pre.style.background = '#f0fdf4';
|
|
272
|
+
pre.style.border = '2px solid #86efac';
|
|
273
|
+
pre.style.borderRadius = '8px';
|
|
274
|
+
pre.textContent = `# ${data.title}\n\n${data.description}\n\n${data.content}`;
|
|
275
|
+
preview.appendChild(pre);
|
|
276
|
+
|
|
277
|
+
setMode('preview');
|
|
278
|
+
} catch {
|
|
279
|
+
alert('Text-Generierung fehlgeschlagen');
|
|
280
|
+
} finally {
|
|
281
|
+
regenBtn.disabled = false;
|
|
282
|
+
regenBtn.classList.remove('loading');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Tab key inserts spaces in textarea
|
|
287
|
+
textarea.addEventListener('keydown', (e) => {
|
|
288
|
+
if (e.key === 'Tab') {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
const start = textarea.selectionStart;
|
|
291
|
+
const end = textarea.selectionEnd;
|
|
292
|
+
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
|
|
293
|
+
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Ctrl+S to save in edit mode
|
|
298
|
+
textarea.addEventListener('keydown', (e) => {
|
|
299
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
if (mode === 'edit') {
|
|
302
|
+
applyBtn.click();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
</script>
|
package/src/index.ts
CHANGED
|
@@ -116,6 +116,11 @@ export default function ncaAiCms(
|
|
|
116
116
|
entrypoint: 'nca-ai-cms-astro-plugin/api/scheduler/[id].ts',
|
|
117
117
|
prerender: false,
|
|
118
118
|
});
|
|
119
|
+
injectRoute({
|
|
120
|
+
pattern: '/api/articles/create',
|
|
121
|
+
entrypoint: 'nca-ai-cms-astro-plugin/api/articles/create.ts',
|
|
122
|
+
prerender: false,
|
|
123
|
+
});
|
|
119
124
|
injectRoute({
|
|
120
125
|
pattern: '/api/articles/[id]',
|
|
121
126
|
entrypoint: 'nca-ai-cms-astro-plugin/api/articles/[id].ts',
|
|
@@ -27,6 +27,7 @@ export interface UpdateContentOptions {
|
|
|
27
27
|
title?: string;
|
|
28
28
|
description?: string;
|
|
29
29
|
content?: string;
|
|
30
|
+
imageAlt?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export class ArticleService {
|
|
@@ -147,6 +148,7 @@ export class ArticleService {
|
|
|
147
148
|
...data,
|
|
148
149
|
...(options.title && { title: options.title }),
|
|
149
150
|
...(options.description && { description: options.description }),
|
|
151
|
+
...(options.imageAlt && { imageAlt: options.imageAlt }),
|
|
150
152
|
};
|
|
151
153
|
|
|
152
154
|
// Use new content or keep existing
|
package/update.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
+
# v1.1.0
|
|
2
|
+
|
|
3
|
+
## Feature: Frontend editing — EditorToolbar, InlineEditor, create endpoint
|
|
4
|
+
|
|
5
|
+
### New: `EditorToolbar.astro` component
|
|
6
|
+
- Floating action button (FAB) in bottom-right corner, visible when authenticated
|
|
7
|
+
- Click opens a compact panel with topic input + optional notes textarea
|
|
8
|
+
- Submits to new `/api/articles/create` endpoint
|
|
9
|
+
- Shows progress spinner during generation (~30-60s)
|
|
10
|
+
- On success, redirects to the new article page
|
|
11
|
+
- Responsive: full-width panel on mobile
|
|
12
|
+
- Keyboard support: Escape to close
|
|
13
|
+
|
|
14
|
+
### New: `InlineEditor.astro` component
|
|
15
|
+
- Wraps article content with edit/regenerate controls when authenticated
|
|
16
|
+
- Pencil button switches rendered HTML to a monospace markdown textarea
|
|
17
|
+
- Regenerate button fetches new content via `/api/articles/{id}/regenerate-text` and shows preview
|
|
18
|
+
- Apply button saves changes via `/api/articles/{id}/apply`
|
|
19
|
+
- Cancel button reverts to rendered view
|
|
20
|
+
- Keyboard support: Ctrl+S to save, Tab inserts spaces in textarea
|
|
21
|
+
|
|
22
|
+
### New: `POST /api/articles/create` endpoint
|
|
23
|
+
- Generates content + hero image and saves both in one call
|
|
24
|
+
- Request: `{ input: string, notes?: string }`
|
|
25
|
+
- Response: `{ success: true, slug: string, articleId: string, title: string }`
|
|
26
|
+
- Notes are appended as "Hinweise" to the generation prompt
|
|
27
|
+
- Image generation failure is non-fatal — article saves without image
|
|
28
|
+
- Returns slug path for client-side redirect
|
|
29
|
+
|
|
30
|
+
### Changed: `ArticleService.updateContent()` now supports `imageAlt`
|
|
31
|
+
- `UpdateContentOptions` interface extended with optional `imageAlt` field
|
|
32
|
+
- Frontmatter update includes imageAlt when provided
|
|
33
|
+
|
|
34
|
+
### New export: `./components/frontend/*`
|
|
35
|
+
- All frontend components (EditorToolbar, InlineEditor, HeroImage, etc.) are now importable
|
|
36
|
+
|
|
37
|
+
### Integration in project
|
|
38
|
+
|
|
39
|
+
In `Layout.astro`:
|
|
40
|
+
```astro
|
|
41
|
+
---
|
|
42
|
+
import EditorToolbar from 'nca-ai-cms-astro-plugin/components/frontend/EditorToolbar.astro';
|
|
43
|
+
const isAuthenticated = !!Astro.cookies.get('editor-auth')?.value;
|
|
44
|
+
---
|
|
45
|
+
<!-- before </body> -->
|
|
46
|
+
<EditorToolbar isAuthenticated={isAuthenticated} />
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
In `articles/[...slug].astro`:
|
|
50
|
+
```astro
|
|
51
|
+
---
|
|
52
|
+
import InlineEditor from 'nca-ai-cms-astro-plugin/components/frontend/InlineEditor.astro';
|
|
53
|
+
import HeroImage from 'nca-ai-cms-astro-plugin/components/frontend/HeroImage.astro';
|
|
54
|
+
---
|
|
55
|
+
<HeroImage articleId={articleSlug} alt={article.imageAlt || article.title} isAuthenticated={isAuthenticated} />
|
|
56
|
+
<InlineEditor articleId={articleSlug} markdown={rawContent} isAuthenticated={isAuthenticated}>
|
|
57
|
+
<div class="prose" set:html={htmlContent} />
|
|
58
|
+
</InlineEditor>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Replace the custom regenerate buttons and scripts in the article page — the plugin components handle everything.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
# v1.0.18
|
|
66
|
+
|
|
67
|
+
## Fix: DB upload reconnects client after file replacement
|
|
68
|
+
- After writing the new SQLite file, the libsql client is closed and reconnected via `db.$client.close()` / `db.$client.reconnect()`
|
|
69
|
+
- Close and reconnect calls are now properly awaited to prevent race conditions with async DB operations
|
|
70
|
+
- Changes from uploaded database are immediately visible without node restart
|
|
71
|
+
- Graceful fallback: if reconnect is not available, upload still succeeds (manual restart needed)
|
|
72
|
+
|
|
73
|
+
## Tests: DB upload endpoint coverage
|
|
74
|
+
- 10 tests covering upload endpoint — validation, backup, reconnect behavior, size limits
|
|
75
|
+
- Added size limit rejection tests for both `multipart/form-data` and `application/octet-stream` content types
|
|
76
|
+
- Tests verify 50 MB max file size is enforced with proper 413 status response
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
1
80
|
# v1.0.17
|
|
2
81
|
|
|
3
82
|
## Feature: Database download/upload via Editor UI
|