nca-ai-cms-astro-plugin 1.0.18 → 1.1.1

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.1",
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,393 @@
1
+ ---
2
+ interface Props {
3
+ isAuthenticated?: boolean;
4
+ currentPath?: string;
5
+ }
6
+
7
+ const { isAuthenticated, currentPath = '' } = Astro.props;
8
+
9
+ // Extract context from current path for smart defaults
10
+ // /services/gleitschleifen → "Gleitschleifen"
11
+ // /services/laserreinigung → "Laserreinigung"
12
+ let pageContext = '';
13
+ const serviceMatch = currentPath.match(/^\/services\/([^/]+)/);
14
+ if (serviceMatch) {
15
+ const slug = serviceMatch[1];
16
+ pageContext = slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
17
+ }
18
+ ---
19
+
20
+ {isAuthenticated && (
21
+ <div id="editor-toolbar" data-page-context={pageContext}>
22
+ <!-- FAB Button -->
23
+ <button
24
+ id="editor-fab"
25
+ type="button"
26
+ aria-label="Neuen Artikel erstellen"
27
+ title="Neuen Artikel erstellen"
28
+ >
29
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
30
+ <line x1="12" y1="5" x2="12" y2="19" />
31
+ <line x1="5" y1="12" x2="19" y2="12" />
32
+ </svg>
33
+ </button>
34
+
35
+ <!-- Panel -->
36
+ <div id="editor-panel" hidden>
37
+ <div class="editor-panel-header">
38
+ <h3>Neuer Artikel</h3>
39
+ <button type="button" id="editor-panel-close" aria-label="Schließen">
40
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
41
+ <line x1="18" y1="6" x2="6" y2="18" />
42
+ <line x1="6" y1="6" x2="18" y2="18" />
43
+ </svg>
44
+ </button>
45
+ </div>
46
+
47
+ {pageContext && (
48
+ <div class="editor-context-badge">
49
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
50
+ <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
51
+ <circle cx="12" cy="10" r="3" />
52
+ </svg>
53
+ <span>Kontext: {pageContext}</span>
54
+ </div>
55
+ )}
56
+
57
+ <form id="editor-create-form">
58
+ <label for="editor-input-topic">Thema oder Keyword</label>
59
+ <input
60
+ type="text"
61
+ id="editor-input-topic"
62
+ name="input"
63
+ placeholder={pageContext ? `z.B. ${pageContext} Kosten, ${pageContext} Vorteile` : 'z.B. Laserreinigung'}
64
+ required
65
+ autocomplete="off"
66
+ />
67
+
68
+ <details id="editor-details">
69
+ <summary>Zusätzliche Hinweise</summary>
70
+ <textarea
71
+ id="editor-input-notes"
72
+ name="notes"
73
+ rows="3"
74
+ placeholder="z.B. Fokus auf Kostenersparnis, Vergleich mit Sandstrahlen erwähnen"
75
+ ></textarea>
76
+ </details>
77
+
78
+ <div class="editor-panel-actions">
79
+ <button type="submit" id="editor-submit-btn">
80
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
81
+ <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
82
+ </svg>
83
+ Generieren
84
+ </button>
85
+ </div>
86
+
87
+ <div id="editor-progress" hidden>
88
+ <div class="editor-progress-spinner"></div>
89
+ <span id="editor-progress-text">Artikel wird generiert...</span>
90
+ </div>
91
+
92
+ <div id="editor-error" hidden></div>
93
+ </form>
94
+ </div>
95
+ </div>
96
+ )}
97
+
98
+ <style>
99
+ #editor-toolbar {
100
+ position: fixed;
101
+ bottom: 2rem;
102
+ right: 2rem;
103
+ z-index: 9999;
104
+ font-family: system-ui, -apple-system, sans-serif;
105
+ }
106
+
107
+ #editor-fab {
108
+ width: 56px;
109
+ height: 56px;
110
+ border-radius: 50%;
111
+ background: #1e293b;
112
+ color: white;
113
+ border: none;
114
+ cursor: pointer;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
119
+ transition: transform 0.2s, background 0.2s;
120
+ }
121
+
122
+ #editor-fab:hover {
123
+ background: #334155;
124
+ transform: scale(1.05);
125
+ }
126
+
127
+ #editor-fab.active {
128
+ transform: rotate(45deg);
129
+ background: #475569;
130
+ }
131
+
132
+ #editor-panel {
133
+ position: absolute;
134
+ bottom: 72px;
135
+ right: 0;
136
+ width: 360px;
137
+ background: white;
138
+ border-radius: 12px;
139
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
140
+ border: 1px solid #e2e8f0;
141
+ overflow: hidden;
142
+ }
143
+
144
+ .editor-panel-header {
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ padding: 16px 20px;
149
+ border-bottom: 1px solid #e2e8f0;
150
+ }
151
+
152
+ .editor-panel-header h3 {
153
+ margin: 0;
154
+ font-size: 16px;
155
+ font-weight: 600;
156
+ color: #1e293b;
157
+ }
158
+
159
+ .editor-panel-header button {
160
+ background: none;
161
+ border: none;
162
+ cursor: pointer;
163
+ color: #94a3b8;
164
+ padding: 4px;
165
+ border-radius: 4px;
166
+ }
167
+
168
+ .editor-panel-header button:hover {
169
+ color: #475569;
170
+ background: #f1f5f9;
171
+ }
172
+
173
+ .editor-context-badge {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 6px;
177
+ margin: 0 20px;
178
+ padding: 8px 12px;
179
+ background: #eff6ff;
180
+ border-radius: 6px;
181
+ font-size: 12px;
182
+ font-weight: 500;
183
+ color: #1d4ed8;
184
+ margin-top: 12px;
185
+ }
186
+
187
+ #editor-create-form {
188
+ padding: 20px;
189
+ }
190
+
191
+ #editor-create-form label {
192
+ display: block;
193
+ font-size: 13px;
194
+ font-weight: 500;
195
+ color: #475569;
196
+ margin-bottom: 6px;
197
+ }
198
+
199
+ #editor-create-form input,
200
+ #editor-create-form textarea {
201
+ width: 100%;
202
+ padding: 10px 12px;
203
+ border: 1px solid #cbd5e1;
204
+ border-radius: 8px;
205
+ font-size: 14px;
206
+ color: #1e293b;
207
+ background: #f8fafc;
208
+ transition: border-color 0.2s;
209
+ box-sizing: border-box;
210
+ }
211
+
212
+ #editor-create-form input:focus,
213
+ #editor-create-form textarea:focus {
214
+ outline: none;
215
+ border-color: #3b82f6;
216
+ background: white;
217
+ }
218
+
219
+ #editor-create-form textarea {
220
+ resize: vertical;
221
+ font-family: inherit;
222
+ }
223
+
224
+ #editor-details {
225
+ margin-top: 12px;
226
+ }
227
+
228
+ #editor-details summary {
229
+ font-size: 13px;
230
+ color: #64748b;
231
+ cursor: pointer;
232
+ user-select: none;
233
+ }
234
+
235
+ #editor-details summary:hover {
236
+ color: #3b82f6;
237
+ }
238
+
239
+ #editor-details textarea {
240
+ margin-top: 8px;
241
+ }
242
+
243
+ .editor-panel-actions {
244
+ margin-top: 16px;
245
+ }
246
+
247
+ #editor-submit-btn {
248
+ width: 100%;
249
+ padding: 10px 16px;
250
+ background: #1e293b;
251
+ color: white;
252
+ border: none;
253
+ border-radius: 8px;
254
+ font-size: 14px;
255
+ font-weight: 500;
256
+ cursor: pointer;
257
+ display: flex;
258
+ align-items: center;
259
+ justify-content: center;
260
+ gap: 8px;
261
+ transition: background 0.2s;
262
+ }
263
+
264
+ #editor-submit-btn:hover {
265
+ background: #334155;
266
+ }
267
+
268
+ #editor-submit-btn:disabled {
269
+ opacity: 0.5;
270
+ cursor: not-allowed;
271
+ }
272
+
273
+ #editor-progress {
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 10px;
277
+ margin-top: 16px;
278
+ padding: 12px;
279
+ background: #f0f9ff;
280
+ border-radius: 8px;
281
+ font-size: 13px;
282
+ color: #1e40af;
283
+ }
284
+
285
+ .editor-progress-spinner {
286
+ width: 18px;
287
+ height: 18px;
288
+ border: 2px solid #bfdbfe;
289
+ border-top-color: #3b82f6;
290
+ border-radius: 50%;
291
+ animation: editor-spin 0.8s linear infinite;
292
+ flex-shrink: 0;
293
+ }
294
+
295
+ @keyframes editor-spin {
296
+ to { transform: rotate(360deg); }
297
+ }
298
+
299
+ #editor-error {
300
+ margin-top: 12px;
301
+ padding: 10px 12px;
302
+ background: #fef2f2;
303
+ border-radius: 8px;
304
+ font-size: 13px;
305
+ color: #dc2626;
306
+ }
307
+
308
+ [hidden] {
309
+ display: none !important;
310
+ }
311
+
312
+ @media (max-width: 480px) {
313
+ #editor-panel {
314
+ width: calc(100vw - 2rem);
315
+ right: -1rem;
316
+ }
317
+ }
318
+ </style>
319
+
320
+ <script>
321
+ const toolbar = document.getElementById('editor-toolbar') as HTMLElement;
322
+ const fab = document.getElementById('editor-fab') as HTMLButtonElement;
323
+ const panel = document.getElementById('editor-panel') as HTMLElement;
324
+ const closeBtn = document.getElementById('editor-panel-close') as HTMLButtonElement;
325
+ const form = document.getElementById('editor-create-form') as HTMLFormElement;
326
+ const submitBtn = document.getElementById('editor-submit-btn') as HTMLButtonElement;
327
+ const progress = document.getElementById('editor-progress') as HTMLElement;
328
+ const progressText = document.getElementById('editor-progress-text') as HTMLElement;
329
+ const errorDiv = document.getElementById('editor-error') as HTMLElement;
330
+ const topicInput = document.getElementById('editor-input-topic') as HTMLInputElement;
331
+ const notesInput = document.getElementById('editor-input-notes') as HTMLTextAreaElement;
332
+
333
+ if (fab && panel && toolbar) {
334
+ const pageContext = toolbar.dataset.pageContext || '';
335
+
336
+ const togglePanel = () => {
337
+ const isOpen = !panel.hidden;
338
+ panel.hidden = isOpen;
339
+ fab.classList.toggle('active', !isOpen);
340
+ if (!isOpen) {
341
+ topicInput?.focus();
342
+ }
343
+ };
344
+
345
+ fab.addEventListener('click', togglePanel);
346
+ closeBtn?.addEventListener('click', togglePanel);
347
+
348
+
349
+ form?.addEventListener('submit', async (e) => {
350
+ e.preventDefault();
351
+ const input = topicInput.value.trim();
352
+ if (!input) return;
353
+
354
+ submitBtn.disabled = true;
355
+ errorDiv.hidden = true;
356
+ progress.hidden = false;
357
+ progressText.textContent = 'Artikel wird generiert...';
358
+
359
+ try {
360
+ // Auto-add page context to notes if on a service page
361
+ let notes = notesInput?.value.trim() || '';
362
+ if (pageContext && !input.toLowerCase().includes(pageContext.toLowerCase())) {
363
+ notes = notes
364
+ ? `Kontext: ${pageContext}. ${notes}`
365
+ : `Artikel im Kontext von ${pageContext}`;
366
+ }
367
+
368
+ const res = await fetch('/api/articles/create', {
369
+ method: 'POST',
370
+ headers: { 'Content-Type': 'application/json' },
371
+ body: JSON.stringify({
372
+ input,
373
+ notes: notes || undefined,
374
+ }),
375
+ });
376
+
377
+ if (!res.ok) {
378
+ const data = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }));
379
+ throw new Error(data.error || `Fehler ${res.status}`);
380
+ }
381
+
382
+ const data = await res.json();
383
+ progressText.textContent = 'Weiterleitung...';
384
+ window.location.href = `/articles/${data.slug}`;
385
+ } catch (err) {
386
+ errorDiv.textContent = err instanceof Error ? err.message : 'Generierung fehlgeschlagen';
387
+ errorDiv.hidden = false;
388
+ progress.hidden = true;
389
+ submitBtn.disabled = false;
390
+ }
391
+ });
392
+ }
393
+ </script>
@@ -0,0 +1,346 @@
1
+ ---
2
+ interface Props {
3
+ articleId: string;
4
+ markdown: string;
5
+ title: string;
6
+ description: string;
7
+ isAuthenticated?: boolean;
8
+ }
9
+
10
+ const { articleId, markdown, title, description, isAuthenticated } = Astro.props;
11
+ ---
12
+
13
+ <div class="inline-editor-wrapper" data-article-id={articleId} data-authenticated={isAuthenticated ? 'true' : 'false'}>
14
+ <div class="inline-editor-content">
15
+ {isAuthenticated && (
16
+ <div class="inline-editor-toolbar">
17
+ <button
18
+ type="button"
19
+ class="inline-edit-btn"
20
+ aria-label="Inhalt bearbeiten"
21
+ title="Inhalt bearbeiten"
22
+ >
23
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
24
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
25
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
26
+ </svg>
27
+ </button>
28
+ <button
29
+ type="button"
30
+ class="inline-regenerate-btn"
31
+ aria-label="Text neu generieren"
32
+ title="Text neu generieren"
33
+ >
34
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
35
+ <path d="M23 4v6h-6" />
36
+ <path d="M1 20v-6h6" />
37
+ <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" />
38
+ </svg>
39
+ </button>
40
+ <button
41
+ type="button"
42
+ class="inline-apply-btn"
43
+ hidden
44
+ aria-label="Änderungen übernehmen"
45
+ title="Speichern"
46
+ >
47
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
48
+ <polyline points="20 6 9 17 4 12" />
49
+ </svg>
50
+ </button>
51
+ <button
52
+ type="button"
53
+ class="inline-cancel-btn"
54
+ hidden
55
+ aria-label="Abbrechen"
56
+ title="Abbrechen"
57
+ >
58
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
59
+ <line x1="18" y1="6" x2="6" y2="18" />
60
+ <line x1="6" y1="6" x2="18" y2="18" />
61
+ </svg>
62
+ </button>
63
+ </div>
64
+ )}
65
+
66
+ <!-- Rendered content (slot) -->
67
+ <div class="inline-editor-rendered">
68
+ <slot />
69
+ </div>
70
+
71
+ <!-- Editing form (hidden by default) -->
72
+ <div class="inline-editor-editing" hidden>
73
+ <div class="inline-editor-field">
74
+ <label class="inline-editor-label">Titel</label>
75
+ <input type="text" class="inline-editor-title-input" value={title} />
76
+ </div>
77
+ <div class="inline-editor-field">
78
+ <label class="inline-editor-label">Beschreibung</label>
79
+ <textarea class="inline-editor-desc-input" rows="2">{description}</textarea>
80
+ </div>
81
+ <div class="inline-editor-field">
82
+ <label class="inline-editor-label">Inhalt (Markdown)</label>
83
+ <textarea class="inline-editor-textarea">{markdown}</textarea>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Regenerated preview (hidden by default) -->
88
+ <div class="inline-editor-preview" hidden></div>
89
+ </div>
90
+ </div>
91
+
92
+ <style>
93
+ .inline-editor-wrapper {
94
+ position: relative;
95
+ }
96
+
97
+ .inline-editor-toolbar {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 4px;
101
+ margin-bottom: 12px;
102
+ }
103
+
104
+ .inline-editor-toolbar button {
105
+ padding: 6px;
106
+ border-radius: 6px;
107
+ border: 1px solid #e2e8f0;
108
+ background: #f8fafc;
109
+ color: #64748b;
110
+ cursor: pointer;
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ transition: all 0.15s;
115
+ }
116
+
117
+ .inline-editor-toolbar button:hover {
118
+ background: #e2e8f0;
119
+ color: #334155;
120
+ }
121
+
122
+ .inline-edit-btn.active {
123
+ background: #3b82f6 !important;
124
+ color: white !important;
125
+ border-color: #3b82f6 !important;
126
+ }
127
+
128
+ .inline-apply-btn {
129
+ background: #dcfce7 !important;
130
+ color: #16a34a !important;
131
+ border-color: #bbf7d0 !important;
132
+ }
133
+
134
+ .inline-apply-btn:hover {
135
+ background: #bbf7d0 !important;
136
+ }
137
+
138
+ .inline-cancel-btn {
139
+ background: #fef2f2 !important;
140
+ color: #dc2626 !important;
141
+ border-color: #fecaca !important;
142
+ }
143
+
144
+ .inline-cancel-btn:hover {
145
+ background: #fecaca !important;
146
+ }
147
+
148
+ .inline-editor-field {
149
+ margin-bottom: 12px;
150
+ }
151
+
152
+ .inline-editor-label {
153
+ display: block;
154
+ font-size: 12px;
155
+ font-weight: 600;
156
+ color: #64748b;
157
+ text-transform: uppercase;
158
+ letter-spacing: 0.05em;
159
+ margin-bottom: 4px;
160
+ }
161
+
162
+ .inline-editor-title-input {
163
+ width: 100%;
164
+ padding: 10px 12px;
165
+ border: 2px solid #3b82f6;
166
+ border-radius: 8px;
167
+ font-size: 1.5rem;
168
+ font-weight: 700;
169
+ color: #0f172a;
170
+ background: #fafbfc;
171
+ box-sizing: border-box;
172
+ }
173
+
174
+ .inline-editor-desc-input {
175
+ width: 100%;
176
+ padding: 10px 12px;
177
+ border: 2px solid #3b82f6;
178
+ border-radius: 8px;
179
+ font-size: 1rem;
180
+ color: #334155;
181
+ background: #fafbfc;
182
+ resize: vertical;
183
+ box-sizing: border-box;
184
+ font-family: inherit;
185
+ line-height: 1.5;
186
+ }
187
+
188
+ .inline-editor-textarea {
189
+ width: 100%;
190
+ min-height: 500px;
191
+ padding: 16px;
192
+ border: 2px solid #3b82f6;
193
+ border-radius: 8px;
194
+ font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
195
+ font-size: 14px;
196
+ line-height: 1.6;
197
+ color: #1e293b;
198
+ background: #fafbfc;
199
+ resize: vertical;
200
+ box-sizing: border-box;
201
+ tab-size: 2;
202
+ }
203
+
204
+ .inline-editor-title-input:focus,
205
+ .inline-editor-desc-input:focus,
206
+ .inline-editor-textarea:focus {
207
+ outline: none;
208
+ border-color: #2563eb;
209
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
210
+ }
211
+
212
+ .loading svg {
213
+ animation: inline-spin 1s linear infinite;
214
+ }
215
+
216
+ @keyframes inline-spin {
217
+ to { transform: rotate(360deg); }
218
+ }
219
+
220
+ [hidden] {
221
+ display: none !important;
222
+ }
223
+ </style>
224
+
225
+ <script>
226
+ document.querySelectorAll('.inline-editor-wrapper').forEach((wrapper) => {
227
+ const el = wrapper as HTMLElement;
228
+ if (el.dataset.authenticated !== 'true') return;
229
+
230
+ const articleId = el.dataset.articleId!;
231
+ const editBtn = el.querySelector('.inline-edit-btn') as HTMLButtonElement;
232
+ const regenBtn = el.querySelector('.inline-regenerate-btn') as HTMLButtonElement;
233
+ const applyBtn = el.querySelector('.inline-apply-btn') as HTMLButtonElement;
234
+ const cancelBtn = el.querySelector('.inline-cancel-btn') as HTMLButtonElement;
235
+ const rendered = el.querySelector('.inline-editor-rendered') as HTMLElement;
236
+ const editing = el.querySelector('.inline-editor-editing') as HTMLElement;
237
+ const preview = el.querySelector('.inline-editor-preview') as HTMLElement;
238
+ const titleInput = el.querySelector('.inline-editor-title-input') as HTMLInputElement;
239
+ const descInput = el.querySelector('.inline-editor-desc-input') as HTMLTextAreaElement;
240
+ const textarea = el.querySelector('.inline-editor-textarea') as HTMLTextAreaElement;
241
+
242
+ type EditorMode = 'view' | 'edit' | 'preview';
243
+ let mode: EditorMode = 'view';
244
+ let previewData: { title?: string; description?: string; content?: string } | null = null;
245
+
246
+ const setMode = (newMode: EditorMode) => {
247
+ mode = newMode;
248
+ rendered.hidden = mode !== 'view';
249
+ editing.hidden = mode !== 'edit';
250
+ preview.hidden = mode !== 'preview';
251
+
252
+ editBtn.classList.toggle('active', mode === 'edit');
253
+ editBtn.hidden = mode === 'preview';
254
+ regenBtn.hidden = mode !== 'view';
255
+ applyBtn.hidden = mode === 'view';
256
+ cancelBtn.hidden = mode === 'view';
257
+ };
258
+
259
+ // Edit mode
260
+ editBtn.addEventListener('click', () => {
261
+ if (mode === 'edit') {
262
+ setMode('view');
263
+ } else {
264
+ setMode('edit');
265
+ titleInput.focus();
266
+ }
267
+ });
268
+
269
+ // Cancel
270
+ cancelBtn.addEventListener('click', () => {
271
+ setMode('view');
272
+ previewData = null;
273
+ });
274
+
275
+ // Save (from edit or preview mode)
276
+ applyBtn.addEventListener('click', async () => {
277
+ applyBtn.disabled = true;
278
+ applyBtn.classList.add('loading');
279
+
280
+ try {
281
+ const body: Record<string, string> = {};
282
+
283
+ if (mode === 'edit') {
284
+ body.title = titleInput.value.trim();
285
+ body.description = descInput.value.trim();
286
+ body.content = textarea.value;
287
+ } else if (mode === 'preview' && previewData) {
288
+ if (previewData.title) body.title = previewData.title;
289
+ if (previewData.description) body.description = previewData.description;
290
+ if (previewData.content) body.content = previewData.content;
291
+ }
292
+
293
+ const res = await fetch(`/api/articles/${articleId}/apply`, {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify(body),
297
+ });
298
+
299
+ if (!res.ok) throw new Error('Speichern fehlgeschlagen');
300
+ window.location.reload();
301
+ } catch {
302
+ alert('Speichern fehlgeschlagen');
303
+ applyBtn.disabled = false;
304
+ applyBtn.classList.remove('loading');
305
+ }
306
+ });
307
+
308
+ // Regenerate text
309
+ regenBtn.addEventListener('click', async () => {
310
+ regenBtn.disabled = true;
311
+ regenBtn.classList.add('loading');
312
+
313
+ try {
314
+ const res = await fetch(`/api/articles/${articleId}/regenerate-text`, {
315
+ method: 'POST',
316
+ });
317
+ if (!res.ok) throw new Error('Generierung fehlgeschlagen');
318
+
319
+ const data = await res.json();
320
+ previewData = { title: data.title, description: data.description, content: data.content };
321
+
322
+ // Show preview as raw markdown
323
+ preview.innerHTML = '';
324
+ const pre = document.createElement('pre');
325
+ pre.style.whiteSpace = 'pre-wrap';
326
+ pre.style.fontFamily = "'JetBrains Mono', ui-monospace, monospace";
327
+ pre.style.fontSize = '14px';
328
+ pre.style.lineHeight = '1.6';
329
+ pre.style.padding = '16px';
330
+ pre.style.background = '#f0fdf4';
331
+ pre.style.border = '2px solid #86efac';
332
+ pre.style.borderRadius = '8px';
333
+ pre.textContent = `# ${data.title}\n\n${data.description}\n\n${data.content}`;
334
+ preview.appendChild(pre);
335
+
336
+ setMode('preview');
337
+ } catch {
338
+ alert('Text-Generierung fehlgeschlagen');
339
+ } finally {
340
+ regenBtn.disabled = false;
341
+ regenBtn.classList.remove('loading');
342
+ }
343
+ });
344
+
345
+ });
346
+ </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',
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import EditorToolbar from '../components/frontend/EditorToolbar.astro';
3
+
2
4
  interface Props {
3
5
  title: string;
4
6
  description?: string;
@@ -1139,6 +1141,7 @@ const isAuthenticated = !!authCookie?.value;
1139
1141
  </div>
1140
1142
  </div>
1141
1143
  </footer>
1144
+ <EditorToolbar isAuthenticated={isAuthenticated} currentPath={currentPath} />
1142
1145
  <script>
1143
1146
  const logoutBtn = document.querySelector('.logout-btn');
1144
1147
  logoutBtn?.addEventListener('click', async () => {
@@ -3,7 +3,7 @@ import Layout from '../../layouts/Layout.astro';
3
3
  import { renderMarkdown } from '../../utils/markdown';
4
4
  import { escapeJsonLd } from '../../utils/sanitize';
5
5
  import HeroImage from '../../components/frontend/HeroImage.astro';
6
- import ArticleHeader from '../../components/frontend/ArticleHeader.astro';
6
+ import InlineEditor from '../../components/frontend/InlineEditor.astro';
7
7
  import SidebarCard from '../../components/frontend/SidebarCard.astro';
8
8
  import BackLink from '../../components/frontend/BackLink.astro';
9
9
  import { ArticleService } from '../../services/ArticleService';
@@ -28,8 +28,12 @@ if (!article) {
28
28
  return Astro.redirect('/');
29
29
  }
30
30
 
31
+ // Fix escaped newlines and remove first H1 (already shown in header)
32
+ const fixedContent = (article.content || '').replace(/\\n/g, '\n');
33
+ const rawContent = fixedContent.replace(/^#\s+.+\n+/, '');
34
+
31
35
  // Render markdown to HTML
32
- const htmlContent = await renderMarkdown(article.content || '');
36
+ const htmlContent = await renderMarkdown(rawContent);
33
37
 
34
38
  // Format the date nicely
35
39
  const formattedDate = article.date.toLocaleDateString('de-DE', {
@@ -68,18 +72,6 @@ const jsonLd = {
68
72
  set:html={escapeJsonLd(JSON.stringify(jsonLd))}
69
73
  />
70
74
  <article class="article-page">
71
- <!-- Article Header -->
72
- <div data-article-id={articleSlug}>
73
- <ArticleHeader
74
- category={article.tags[0]}
75
- date={formattedDate}
76
- datetime={article.date.toISOString()}
77
- lede={article.description}
78
- tags={article.tags}
79
- animate={true}
80
- />
81
- </div>
82
-
83
75
  <!-- Hero Image -->
84
76
  {
85
77
  article.image && (
@@ -91,52 +83,37 @@ const jsonLd = {
91
83
  )
92
84
  }
93
85
 
94
- <!-- Article Body -->
86
+ <!-- Article Content (header + body, all editable via InlineEditor) -->
95
87
  <div class="article-body">
96
- <div class="content-column" data-article-id={articleSlug}>
97
- {
98
- isAuthenticated && (
99
- <div class="content-actions">
100
- <button
101
- class="icon-btn regenerate-text-icon"
102
- type="button"
103
- aria-label="Text neu generieren"
104
- >
105
- <svg
106
- width="18"
107
- height="18"
108
- viewBox="0 0 24 24"
109
- fill="none"
110
- stroke="currentColor"
111
- stroke-width="2"
112
- >
113
- <path d="M23 4v6h-6" />
114
- <path d="M1 20v-6h6" />
115
- <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" />
116
- </svg>
117
- </button>
118
- <button
119
- class="apply-text-btn"
120
- type="button"
121
- hidden
122
- aria-label="Speichern"
123
- >
124
- <svg
125
- width="18"
126
- height="18"
127
- viewBox="0 0 24 24"
128
- fill="none"
129
- stroke="currentColor"
130
- stroke-width="3"
131
- >
132
- <polyline points="20 6 9 17 4 12" />
133
- </svg>
134
- </button>
88
+ <div class="content-column">
89
+ <InlineEditor
90
+ articleId={articleSlug}
91
+ markdown={rawContent}
92
+ title={article.title}
93
+ description={article.description}
94
+ isAuthenticated={isAuthenticated}
95
+ >
96
+ <header class="article-header" data-animate>
97
+ <div class="article-meta">
98
+ {article.tags[0] && <span class="article-category">{article.tags[0]}</span>}
99
+ <time datetime={article.date.toISOString()}>
100
+ {formattedDate}
101
+ </time>
135
102
  </div>
136
- )
137
- }
138
- <div class="article-content prose" set:html={htmlContent} />
139
- <div class="content-preview prose" hidden></div>
103
+
104
+ <h1>{article.title}</h1>
105
+
106
+ <p class="article-lede">{article.description}</p>
107
+
108
+ {article.tags.length > 0 && (
109
+ <div class="article-tags">
110
+ {article.tags.map((tag: string) => <span class="tag">{tag}</span>)}
111
+ </div>
112
+ )}
113
+ </header>
114
+
115
+ <div class="article-content prose" set:html={htmlContent} />
116
+ </InlineEditor>
140
117
  </div>
141
118
 
142
119
  <!-- Sidebar -->
@@ -157,110 +134,6 @@ const jsonLd = {
157
134
  </Layout>
158
135
 
159
136
  <script>
160
- const contentColumn = document.querySelector(
161
- '.content-column'
162
- ) as HTMLElement;
163
- if (contentColumn) {
164
- const articleId = contentColumn.dataset.articleId;
165
- const regenerateBtn = contentColumn.querySelector(
166
- '.regenerate-text-icon'
167
- ) as HTMLButtonElement;
168
- const contentDiv = contentColumn.querySelector(
169
- '.article-content'
170
- ) as HTMLElement;
171
- const previewDiv = contentColumn.querySelector(
172
- '.content-preview'
173
- ) as HTMLElement;
174
- const applyBtn = contentColumn.querySelector(
175
- '.apply-text-btn'
176
- ) as HTMLButtonElement;
177
-
178
- let previewData: {
179
- title: string;
180
- description: string;
181
- content: string;
182
- } | null = null;
183
-
184
- const buildPreviewDom = (
185
- title: string,
186
- description: string,
187
- content: string
188
- ) => {
189
- const fragment = document.createDocumentFragment();
190
-
191
- const h1 = document.createElement('h1');
192
- h1.textContent = title;
193
- fragment.appendChild(h1);
194
-
195
- const p = document.createElement('p');
196
- const em = document.createElement('em');
197
- em.textContent = description;
198
- p.appendChild(em);
199
- fragment.appendChild(p);
200
-
201
- const body = document.createElement('div');
202
- body.textContent = content.replace(/^#\s+.+\n/, '');
203
- body.style.whiteSpace = 'pre-wrap';
204
- fragment.appendChild(body);
205
-
206
- return fragment;
207
- };
208
-
209
- const showPreview = (fragment: DocumentFragment) => {
210
- previewDiv.replaceChildren(fragment);
211
- contentDiv.hidden = true;
212
- previewDiv.hidden = false;
213
- applyBtn.hidden = false;
214
- regenerateBtn.hidden = true;
215
- };
216
-
217
- const generateText = async () => {
218
- regenerateBtn.disabled = true;
219
- regenerateBtn.classList.add('loading');
220
- try {
221
- const res = await fetch(`/api/articles/${articleId}/regenerate-text`, {
222
- method: 'POST',
223
- });
224
- if (!res.ok) throw new Error('Generation failed');
225
- const data = await res.json();
226
- previewData = {
227
- title: data.title,
228
- description: data.description,
229
- content: data.content,
230
- };
231
- showPreview(
232
- buildPreviewDom(data.title, data.description, data.content)
233
- );
234
- } catch {
235
- alert('Text-Generierung fehlgeschlagen');
236
- regenerateBtn.disabled = false;
237
- regenerateBtn.classList.remove('loading');
238
- }
239
- };
240
-
241
- const applyChanges = async () => {
242
- if (!previewData) return;
243
- applyBtn.disabled = true;
244
- applyBtn.classList.add('loading');
245
- try {
246
- const res = await fetch(`/api/articles/${articleId}/apply`, {
247
- method: 'POST',
248
- headers: { 'Content-Type': 'application/json' },
249
- body: JSON.stringify(previewData),
250
- });
251
- if (!res.ok) throw new Error('Save failed');
252
- window.location.reload();
253
- } catch {
254
- alert('Speichern fehlgeschlagen');
255
- applyBtn.disabled = false;
256
- applyBtn.classList.remove('loading');
257
- }
258
- };
259
-
260
- regenerateBtn?.addEventListener('click', generateText);
261
- applyBtn?.addEventListener('click', applyChanges);
262
- }
263
-
264
137
  document.querySelectorAll('.prose pre').forEach((pre) => {
265
138
  pre.setAttribute('tabindex', '0');
266
139
  pre.setAttribute('role', 'region');
@@ -279,50 +152,57 @@ const jsonLd = {
279
152
  padding: 0 var(--gutter);
280
153
  }
281
154
 
282
- .article-body {
283
- display: grid;
284
- grid-template-columns: minmax(0, 1fr) 280px;
285
- gap: var(--space-16);
286
- max-width: 1100px;
287
- }
288
-
289
- .content-column {
290
- min-width: 0;
291
- overflow: hidden;
155
+ .article-header {
156
+ margin-bottom: var(--space-8);
292
157
  }
293
158
 
294
- .content-actions {
159
+ .article-meta {
295
160
  display: flex;
296
161
  align-items: center;
297
- gap: var(--space-2);
162
+ gap: var(--space-3);
298
163
  margin-bottom: var(--space-4);
164
+ font-size: var(--text-sm);
165
+ color: var(--color-text-muted);
299
166
  }
300
167
 
301
- .apply-text-btn {
302
- display: flex;
303
- align-items: center;
304
- justify-content: center;
305
- width: 36px;
306
- height: 36px;
307
- background: var(--color-success, #4ade80);
308
- border: none;
309
- border-radius: var(--radius-md);
310
- color: #000;
311
- cursor: pointer;
312
- transition: background 0.2s ease;
168
+ .article-category {
169
+ text-transform: uppercase;
170
+ letter-spacing: 0.05em;
171
+ font-weight: 600;
172
+ color: var(--color-accent);
173
+ }
174
+
175
+ .article-header h1 {
176
+ font-size: var(--text-4xl);
177
+ font-weight: 800;
178
+ line-height: 1.15;
179
+ color: var(--color-text);
180
+ margin-bottom: var(--space-4);
313
181
  }
314
182
 
315
- .apply-text-btn:hover {
316
- background: #22c55e;
183
+ .article-lede {
184
+ font-size: var(--text-lg);
185
+ color: var(--color-text-muted);
186
+ line-height: 1.6;
187
+ margin-bottom: var(--space-6);
317
188
  }
318
189
 
319
- .apply-text-btn:disabled {
320
- opacity: 0.6;
321
- cursor: not-allowed;
190
+ .article-tags {
191
+ display: flex;
192
+ flex-wrap: wrap;
193
+ gap: var(--space-2);
322
194
  }
323
195
 
324
- .apply-text-btn.loading svg {
325
- animation: spin 1s linear infinite;
196
+ .article-body {
197
+ display: grid;
198
+ grid-template-columns: minmax(0, 1fr) 280px;
199
+ gap: var(--space-16);
200
+ max-width: 1100px;
201
+ }
202
+
203
+ .content-column {
204
+ min-width: 0;
205
+ overflow: hidden;
326
206
  }
327
207
 
328
208
  .article-sidebar {
@@ -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,107 @@
1
+ # v1.1.1
2
+
3
+ ## Enhancement: Inline editing for title + description, context-aware toolbar
4
+
5
+ ### InlineEditor: title and description editing
6
+ - New `title` and `description` props (required)
7
+ - Edit mode now shows three fields: title input, description textarea, markdown textarea
8
+ - All three saved in one apply call to `/api/articles/{id}/apply`
9
+ - Title field gets focus first when entering edit mode
10
+ - Ctrl+S works from any field (not just markdown textarea)
11
+
12
+ ### EditorToolbar: page context awareness
13
+ - New `currentPath` prop — detects service pages and extracts topic
14
+ - On `/services/gleitschleifen`: shows "Kontext: Gleitschleifen" badge in panel
15
+ - Placeholder adapts: "z.B. Gleitschleifen Kosten, Gleitschleifen Vorteile"
16
+ - Context auto-added to generation notes when topic doesn't already include it
17
+ - Article generated from a service page gets that service as context in the prompt
18
+
19
+ ### Integration update
20
+
21
+ InlineEditor now requires `title` and `description`:
22
+ ```astro
23
+ <InlineEditor
24
+ articleId={articleSlug}
25
+ markdown={rawContent}
26
+ title={article.title}
27
+ description={article.description}
28
+ isAuthenticated={isAuthenticated}
29
+ >
30
+ <!-- header + content as slot -->
31
+ </InlineEditor>
32
+ ```
33
+
34
+ EditorToolbar now accepts `currentPath`:
35
+ ```astro
36
+ <EditorToolbar isAuthenticated={isAuthenticated} currentPath={Astro.url.pathname} />
37
+ ```
38
+
39
+ ---
40
+
41
+ # v1.1.0
42
+
43
+ ## Feature: Frontend editing — EditorToolbar, InlineEditor, create endpoint
44
+
45
+ ### New: `EditorToolbar.astro` component
46
+ - Floating action button (FAB) in bottom-right corner, visible when authenticated
47
+ - Click opens a compact panel with topic input + optional notes textarea
48
+ - Submits to new `/api/articles/create` endpoint
49
+ - Shows progress spinner during generation (~30-60s)
50
+ - On success, redirects to the new article page
51
+ - Responsive: full-width panel on mobile
52
+ - Keyboard support: Escape to close
53
+
54
+ ### New: `InlineEditor.astro` component
55
+ - Wraps article content with edit/regenerate controls when authenticated
56
+ - Pencil button switches rendered HTML to a monospace markdown textarea
57
+ - Regenerate button fetches new content via `/api/articles/{id}/regenerate-text` and shows preview
58
+ - Apply button saves changes via `/api/articles/{id}/apply`
59
+ - Cancel button reverts to rendered view
60
+ - Keyboard support: Ctrl+S to save, Tab inserts spaces in textarea
61
+
62
+ ### New: `POST /api/articles/create` endpoint
63
+ - Generates content + hero image and saves both in one call
64
+ - Request: `{ input: string, notes?: string }`
65
+ - Response: `{ success: true, slug: string, articleId: string, title: string }`
66
+ - Notes are appended as "Hinweise" to the generation prompt
67
+ - Image generation failure is non-fatal — article saves without image
68
+ - Returns slug path for client-side redirect
69
+
70
+ ### Changed: `ArticleService.updateContent()` now supports `imageAlt`
71
+ - `UpdateContentOptions` interface extended with optional `imageAlt` field
72
+ - Frontmatter update includes imageAlt when provided
73
+
74
+ ### New export: `./components/frontend/*`
75
+ - All frontend components (EditorToolbar, InlineEditor, HeroImage, etc.) are now importable
76
+
77
+ ### Integration in project
78
+
79
+ In `Layout.astro`:
80
+ ```astro
81
+ ---
82
+ import EditorToolbar from 'nca-ai-cms-astro-plugin/components/frontend/EditorToolbar.astro';
83
+ const isAuthenticated = !!Astro.cookies.get('editor-auth')?.value;
84
+ ---
85
+ <!-- before </body> -->
86
+ <EditorToolbar isAuthenticated={isAuthenticated} />
87
+ ```
88
+
89
+ In `articles/[...slug].astro`:
90
+ ```astro
91
+ ---
92
+ import InlineEditor from 'nca-ai-cms-astro-plugin/components/frontend/InlineEditor.astro';
93
+ import HeroImage from 'nca-ai-cms-astro-plugin/components/frontend/HeroImage.astro';
94
+ ---
95
+ <HeroImage articleId={articleSlug} alt={article.imageAlt || article.title} isAuthenticated={isAuthenticated} />
96
+ <InlineEditor articleId={articleSlug} markdown={rawContent} isAuthenticated={isAuthenticated}>
97
+ <div class="prose" set:html={htmlContent} />
98
+ </InlineEditor>
99
+ ```
100
+
101
+ Replace the custom regenerate buttons and scripts in the article page — the plugin components handle everything.
102
+
103
+ ---
104
+
1
105
  # v1.0.18
2
106
 
3
107
  ## Fix: DB upload reconnects client after file replacement