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 +3 -2
- package/src/api/articles/create.ts +103 -0
- package/src/components/frontend/EditorToolbar.astro +393 -0
- package/src/components/frontend/InlineEditor.astro +346 -0
- package/src/index.ts +5 -0
- package/src/layouts/Layout.astro +3 -0
- package/src/pages/articles/[...slug].astro +73 -193
- package/src/services/ArticleService.ts +2 -0
- package/update.md +104 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nca-ai-cms-astro-plugin",
|
|
3
|
-
"version": "1.
|
|
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',
|
package/src/layouts/Layout.astro
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
86
|
+
<!-- Article Content (header + body, all editable via InlineEditor) -->
|
|
95
87
|
<div class="article-body">
|
|
96
|
-
<div class="content-column"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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-
|
|
283
|
-
|
|
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
|
-
.
|
|
159
|
+
.article-meta {
|
|
295
160
|
display: flex;
|
|
296
161
|
align-items: center;
|
|
297
|
-
gap: var(--space-
|
|
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
|
-
.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
.
|
|
316
|
-
|
|
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
|
-
.
|
|
320
|
-
|
|
321
|
-
|
|
190
|
+
.article-tags {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-wrap: wrap;
|
|
193
|
+
gap: var(--space-2);
|
|
322
194
|
}
|
|
323
195
|
|
|
324
|
-
.
|
|
325
|
-
|
|
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
|