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
|
|
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
|