nca-ai-cms-astro-plugin 1.0.18 → 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.18",
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,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,67 @@
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
+
1
65
  # v1.0.18
2
66
 
3
67
  ## Fix: DB upload reconnects client after file replacement