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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nca-ai-cms-astro-plugin",
3
- "version": "1.0.17",
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
+ });
@@ -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