nca-ai-cms-astro-plugin 1.1.0 → 1.1.2
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 +1 -1
- package/src/components/frontend/EditorToolbar.astro +51 -11
- package/src/components/frontend/InlineEditor.astro +110 -21
- package/src/layouts/Layout.astro +3 -0
- package/src/pages/articles/[...slug].astro +73 -193
- package/src/pages/index.astro +0 -17
- package/update.md +51 -0
- package/src/components/frontend/DeleteAction.astro +0 -144
package/package.json
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
interface Props {
|
|
3
3
|
isAuthenticated?: boolean;
|
|
4
|
+
currentPath?: string;
|
|
4
5
|
}
|
|
5
6
|
|
|
6
|
-
const { isAuthenticated } = Astro.props;
|
|
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
|
+
}
|
|
7
18
|
---
|
|
8
19
|
|
|
9
20
|
{isAuthenticated && (
|
|
10
|
-
<div id="editor-toolbar">
|
|
21
|
+
<div id="editor-toolbar" data-page-context={pageContext}>
|
|
11
22
|
<!-- FAB Button -->
|
|
12
23
|
<button
|
|
13
24
|
id="editor-fab"
|
|
@@ -33,13 +44,23 @@ const { isAuthenticated } = Astro.props;
|
|
|
33
44
|
</button>
|
|
34
45
|
</div>
|
|
35
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
|
+
|
|
36
57
|
<form id="editor-create-form">
|
|
37
58
|
<label for="editor-input-topic">Thema oder Keyword</label>
|
|
38
59
|
<input
|
|
39
60
|
type="text"
|
|
40
61
|
id="editor-input-topic"
|
|
41
62
|
name="input"
|
|
42
|
-
placeholder=
|
|
63
|
+
placeholder={pageContext ? `z.B. ${pageContext} Kosten, ${pageContext} Vorteile` : 'z.B. Laserreinigung'}
|
|
43
64
|
required
|
|
44
65
|
autocomplete="off"
|
|
45
66
|
/>
|
|
@@ -149,6 +170,20 @@ const { isAuthenticated } = Astro.props;
|
|
|
149
170
|
background: #f1f5f9;
|
|
150
171
|
}
|
|
151
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
|
+
|
|
152
187
|
#editor-create-form {
|
|
153
188
|
padding: 20px;
|
|
154
189
|
}
|
|
@@ -283,6 +318,7 @@ const { isAuthenticated } = Astro.props;
|
|
|
283
318
|
</style>
|
|
284
319
|
|
|
285
320
|
<script>
|
|
321
|
+
const toolbar = document.getElementById('editor-toolbar') as HTMLElement;
|
|
286
322
|
const fab = document.getElementById('editor-fab') as HTMLButtonElement;
|
|
287
323
|
const panel = document.getElementById('editor-panel') as HTMLElement;
|
|
288
324
|
const closeBtn = document.getElementById('editor-panel-close') as HTMLButtonElement;
|
|
@@ -294,7 +330,9 @@ const { isAuthenticated } = Astro.props;
|
|
|
294
330
|
const topicInput = document.getElementById('editor-input-topic') as HTMLInputElement;
|
|
295
331
|
const notesInput = document.getElementById('editor-input-notes') as HTMLTextAreaElement;
|
|
296
332
|
|
|
297
|
-
if (fab && panel) {
|
|
333
|
+
if (fab && panel && toolbar) {
|
|
334
|
+
const pageContext = toolbar.dataset.pageContext || '';
|
|
335
|
+
|
|
298
336
|
const togglePanel = () => {
|
|
299
337
|
const isOpen = !panel.hidden;
|
|
300
338
|
panel.hidden = isOpen;
|
|
@@ -307,12 +345,6 @@ const { isAuthenticated } = Astro.props;
|
|
|
307
345
|
fab.addEventListener('click', togglePanel);
|
|
308
346
|
closeBtn?.addEventListener('click', togglePanel);
|
|
309
347
|
|
|
310
|
-
// Close on Escape
|
|
311
|
-
document.addEventListener('keydown', (e) => {
|
|
312
|
-
if (e.key === 'Escape' && !panel.hidden) {
|
|
313
|
-
togglePanel();
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
348
|
|
|
317
349
|
form?.addEventListener('submit', async (e) => {
|
|
318
350
|
e.preventDefault();
|
|
@@ -325,12 +357,20 @@ const { isAuthenticated } = Astro.props;
|
|
|
325
357
|
progressText.textContent = 'Artikel wird generiert...';
|
|
326
358
|
|
|
327
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
|
+
|
|
328
368
|
const res = await fetch('/api/articles/create', {
|
|
329
369
|
method: 'POST',
|
|
330
370
|
headers: { 'Content-Type': 'application/json' },
|
|
331
371
|
body: JSON.stringify({
|
|
332
372
|
input,
|
|
333
|
-
notes:
|
|
373
|
+
notes: notes || undefined,
|
|
334
374
|
}),
|
|
335
375
|
});
|
|
336
376
|
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
interface Props {
|
|
3
3
|
articleId: string;
|
|
4
4
|
markdown: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
5
7
|
isAuthenticated?: boolean;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
10
|
+
const { articleId, markdown, title, description, isAuthenticated } = Astro.props;
|
|
9
11
|
---
|
|
10
12
|
|
|
11
13
|
<div class="inline-editor-wrapper" data-article-id={articleId} data-authenticated={isAuthenticated ? 'true' : 'false'}>
|
|
@@ -58,6 +60,18 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
58
60
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
59
61
|
</svg>
|
|
60
62
|
</button>
|
|
63
|
+
<span class="inline-toolbar-spacer"></span>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
class="inline-delete-btn"
|
|
67
|
+
aria-label="Artikel löschen"
|
|
68
|
+
title="Artikel löschen"
|
|
69
|
+
>
|
|
70
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
71
|
+
<polyline points="3 6 5 6 21 6" />
|
|
72
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
73
|
+
</svg>
|
|
74
|
+
</button>
|
|
61
75
|
</div>
|
|
62
76
|
)}
|
|
63
77
|
|
|
@@ -66,9 +80,20 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
66
80
|
<slot />
|
|
67
81
|
</div>
|
|
68
82
|
|
|
69
|
-
<!--
|
|
83
|
+
<!-- Editing form (hidden by default) -->
|
|
70
84
|
<div class="inline-editor-editing" hidden>
|
|
71
|
-
<
|
|
85
|
+
<div class="inline-editor-field">
|
|
86
|
+
<label class="inline-editor-label">Titel</label>
|
|
87
|
+
<input type="text" class="inline-editor-title-input" value={title} />
|
|
88
|
+
</div>
|
|
89
|
+
<div class="inline-editor-field">
|
|
90
|
+
<label class="inline-editor-label">Beschreibung</label>
|
|
91
|
+
<textarea class="inline-editor-desc-input" rows="2">{description}</textarea>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="inline-editor-field">
|
|
94
|
+
<label class="inline-editor-label">Inhalt (Markdown)</label>
|
|
95
|
+
<textarea class="inline-editor-textarea">{markdown}</textarea>
|
|
96
|
+
</div>
|
|
72
97
|
</div>
|
|
73
98
|
|
|
74
99
|
<!-- Regenerated preview (hidden by default) -->
|
|
@@ -132,6 +157,62 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
132
157
|
background: #fecaca !important;
|
|
133
158
|
}
|
|
134
159
|
|
|
160
|
+
.inline-toolbar-spacer {
|
|
161
|
+
flex: 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.inline-delete-btn {
|
|
165
|
+
background: transparent !important;
|
|
166
|
+
color: #94a3b8 !important;
|
|
167
|
+
border-color: transparent !important;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.inline-delete-btn:hover {
|
|
171
|
+
background: #fef2f2 !important;
|
|
172
|
+
color: #dc2626 !important;
|
|
173
|
+
border-color: #fecaca !important;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.inline-editor-field {
|
|
177
|
+
margin-bottom: 12px;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.inline-editor-label {
|
|
181
|
+
display: block;
|
|
182
|
+
font-size: 12px;
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
color: #64748b;
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
letter-spacing: 0.05em;
|
|
187
|
+
margin-bottom: 4px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.inline-editor-title-input {
|
|
191
|
+
width: 100%;
|
|
192
|
+
padding: 10px 12px;
|
|
193
|
+
border: 2px solid #3b82f6;
|
|
194
|
+
border-radius: 8px;
|
|
195
|
+
font-size: 1.5rem;
|
|
196
|
+
font-weight: 700;
|
|
197
|
+
color: #0f172a;
|
|
198
|
+
background: #fafbfc;
|
|
199
|
+
box-sizing: border-box;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.inline-editor-desc-input {
|
|
203
|
+
width: 100%;
|
|
204
|
+
padding: 10px 12px;
|
|
205
|
+
border: 2px solid #3b82f6;
|
|
206
|
+
border-radius: 8px;
|
|
207
|
+
font-size: 1rem;
|
|
208
|
+
color: #334155;
|
|
209
|
+
background: #fafbfc;
|
|
210
|
+
resize: vertical;
|
|
211
|
+
box-sizing: border-box;
|
|
212
|
+
font-family: inherit;
|
|
213
|
+
line-height: 1.5;
|
|
214
|
+
}
|
|
215
|
+
|
|
135
216
|
.inline-editor-textarea {
|
|
136
217
|
width: 100%;
|
|
137
218
|
min-height: 500px;
|
|
@@ -148,6 +229,8 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
148
229
|
tab-size: 2;
|
|
149
230
|
}
|
|
150
231
|
|
|
232
|
+
.inline-editor-title-input:focus,
|
|
233
|
+
.inline-editor-desc-input:focus,
|
|
151
234
|
.inline-editor-textarea:focus {
|
|
152
235
|
outline: none;
|
|
153
236
|
border-color: #2563eb;
|
|
@@ -177,9 +260,12 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
177
260
|
const regenBtn = el.querySelector('.inline-regenerate-btn') as HTMLButtonElement;
|
|
178
261
|
const applyBtn = el.querySelector('.inline-apply-btn') as HTMLButtonElement;
|
|
179
262
|
const cancelBtn = el.querySelector('.inline-cancel-btn') as HTMLButtonElement;
|
|
263
|
+
const deleteBtn = el.querySelector('.inline-delete-btn') as HTMLButtonElement;
|
|
180
264
|
const rendered = el.querySelector('.inline-editor-rendered') as HTMLElement;
|
|
181
265
|
const editing = el.querySelector('.inline-editor-editing') as HTMLElement;
|
|
182
266
|
const preview = el.querySelector('.inline-editor-preview') as HTMLElement;
|
|
267
|
+
const titleInput = el.querySelector('.inline-editor-title-input') as HTMLInputElement;
|
|
268
|
+
const descInput = el.querySelector('.inline-editor-desc-input') as HTMLTextAreaElement;
|
|
183
269
|
const textarea = el.querySelector('.inline-editor-textarea') as HTMLTextAreaElement;
|
|
184
270
|
|
|
185
271
|
type EditorMode = 'view' | 'edit' | 'preview';
|
|
@@ -197,6 +283,7 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
197
283
|
regenBtn.hidden = mode !== 'view';
|
|
198
284
|
applyBtn.hidden = mode === 'view';
|
|
199
285
|
cancelBtn.hidden = mode === 'view';
|
|
286
|
+
deleteBtn.hidden = mode !== 'view';
|
|
200
287
|
};
|
|
201
288
|
|
|
202
289
|
// Edit mode
|
|
@@ -205,7 +292,7 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
205
292
|
setMode('view');
|
|
206
293
|
} else {
|
|
207
294
|
setMode('edit');
|
|
208
|
-
|
|
295
|
+
titleInput.focus();
|
|
209
296
|
}
|
|
210
297
|
});
|
|
211
298
|
|
|
@@ -224,6 +311,8 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
224
311
|
const body: Record<string, string> = {};
|
|
225
312
|
|
|
226
313
|
if (mode === 'edit') {
|
|
314
|
+
body.title = titleInput.value.trim();
|
|
315
|
+
body.description = descInput.value.trim();
|
|
227
316
|
body.content = textarea.value;
|
|
228
317
|
} else if (mode === 'preview' && previewData) {
|
|
229
318
|
if (previewData.title) body.title = previewData.title;
|
|
@@ -283,25 +372,25 @@ const { articleId, markdown, isAuthenticated } = Astro.props;
|
|
|
283
372
|
}
|
|
284
373
|
});
|
|
285
374
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
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
|
-
});
|
|
375
|
+
// Delete article
|
|
376
|
+
deleteBtn?.addEventListener('click', async () => {
|
|
377
|
+
if (!confirm('Artikel wirklich löschen? Dies kann nicht rückgängig gemacht werden.')) return;
|
|
378
|
+
deleteBtn.disabled = true;
|
|
296
379
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
380
|
+
try {
|
|
381
|
+
deleteBtn.classList.add('loading');
|
|
382
|
+
const res = await fetch(`/api/articles/${articleId}`, {
|
|
383
|
+
method: 'DELETE',
|
|
384
|
+
credentials: 'same-origin',
|
|
385
|
+
});
|
|
386
|
+
if (!res.ok) throw new Error('Löschen fehlgeschlagen');
|
|
387
|
+
window.location.href = '/';
|
|
388
|
+
} catch {
|
|
389
|
+
alert('Artikel konnte nicht gelöscht werden');
|
|
390
|
+
deleteBtn.disabled = false;
|
|
391
|
+
deleteBtn.classList.remove('loading');
|
|
304
392
|
}
|
|
305
393
|
});
|
|
394
|
+
|
|
306
395
|
});
|
|
307
396
|
</script>
|
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 {
|
package/src/pages/index.astro
CHANGED
|
@@ -4,7 +4,6 @@ import * as fs from 'fs/promises';
|
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { db, SiteSettings, eq } from 'astro:db';
|
|
6
6
|
import Layout from '../layouts/Layout.astro';
|
|
7
|
-
import DeleteAction from '../components/frontend/DeleteAction.astro';
|
|
8
7
|
import { ArticleService } from '../services/ArticleService';
|
|
9
8
|
import { escapeJsonLd } from '../utils/sanitize';
|
|
10
9
|
|
|
@@ -128,14 +127,6 @@ const homeJsonLd = {
|
|
|
128
127
|
href={`/articles/${featuredArticle.articleId}`}
|
|
129
128
|
class="featured-card"
|
|
130
129
|
>
|
|
131
|
-
{isAuthenticated && (
|
|
132
|
-
<DeleteAction
|
|
133
|
-
articleId={
|
|
134
|
-
featuredArticle.articleId.split('/').pop() ||
|
|
135
|
-
featuredArticle.articleId
|
|
136
|
-
}
|
|
137
|
-
/>
|
|
138
|
-
)}
|
|
139
130
|
<div class="featured-card-image img-zoom">
|
|
140
131
|
{featuredArticle.image ? (
|
|
141
132
|
<Image
|
|
@@ -198,14 +189,6 @@ const homeJsonLd = {
|
|
|
198
189
|
{ 'article-card--wide': index % 5 === 0 },
|
|
199
190
|
]}
|
|
200
191
|
>
|
|
201
|
-
{isAuthenticated && (
|
|
202
|
-
<DeleteAction
|
|
203
|
-
articleId={
|
|
204
|
-
article.articleId.split('/').pop() ||
|
|
205
|
-
article.articleId
|
|
206
|
-
}
|
|
207
|
-
/>
|
|
208
|
-
)}
|
|
209
192
|
<div class="article-card-image img-zoom">
|
|
210
193
|
{article.image ? (
|
|
211
194
|
<Image
|
package/update.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
+
# v1.1.2
|
|
2
|
+
|
|
3
|
+
## Feature: Delete button in InlineEditor
|
|
4
|
+
|
|
5
|
+
- Trash icon in the toolbar, right-aligned, subtle gray until hover (then red)
|
|
6
|
+
- Confirm dialog before deleting: "Artikel wirklich löschen?"
|
|
7
|
+
- Calls `DELETE /api/articles/{id}` then redirects to homepage
|
|
8
|
+
- Only visible when authenticated
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# v1.1.1
|
|
13
|
+
|
|
14
|
+
## Enhancement: Inline editing for title + description, context-aware toolbar
|
|
15
|
+
|
|
16
|
+
### InlineEditor: title and description editing
|
|
17
|
+
- New `title` and `description` props (required)
|
|
18
|
+
- Edit mode now shows three fields: title input, description textarea, markdown textarea
|
|
19
|
+
- All three saved in one apply call to `/api/articles/{id}/apply`
|
|
20
|
+
- Title field gets focus first when entering edit mode
|
|
21
|
+
- Ctrl+S works from any field (not just markdown textarea)
|
|
22
|
+
|
|
23
|
+
### EditorToolbar: page context awareness
|
|
24
|
+
- New `currentPath` prop — detects service pages and extracts topic
|
|
25
|
+
- On `/services/gleitschleifen`: shows "Kontext: Gleitschleifen" badge in panel
|
|
26
|
+
- Placeholder adapts: "z.B. Gleitschleifen Kosten, Gleitschleifen Vorteile"
|
|
27
|
+
- Context auto-added to generation notes when topic doesn't already include it
|
|
28
|
+
- Article generated from a service page gets that service as context in the prompt
|
|
29
|
+
|
|
30
|
+
### Integration update
|
|
31
|
+
|
|
32
|
+
InlineEditor now requires `title` and `description`:
|
|
33
|
+
```astro
|
|
34
|
+
<InlineEditor
|
|
35
|
+
articleId={articleSlug}
|
|
36
|
+
markdown={rawContent}
|
|
37
|
+
title={article.title}
|
|
38
|
+
description={article.description}
|
|
39
|
+
isAuthenticated={isAuthenticated}
|
|
40
|
+
>
|
|
41
|
+
<!-- header + content as slot -->
|
|
42
|
+
</InlineEditor>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
EditorToolbar now accepts `currentPath`:
|
|
46
|
+
```astro
|
|
47
|
+
<EditorToolbar isAuthenticated={isAuthenticated} currentPath={Astro.url.pathname} />
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
1
52
|
# v1.1.0
|
|
2
53
|
|
|
3
54
|
## Feature: Frontend editing — EditorToolbar, InlineEditor, create endpoint
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
interface Props {
|
|
3
|
-
articleId: string;
|
|
4
|
-
}
|
|
5
|
-
const { articleId } = Astro.props;
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
<div class="delete-action" data-article-id={articleId}>
|
|
9
|
-
<button class="delete-icon" aria-label="Artikel löschen" type="button">
|
|
10
|
-
<svg
|
|
11
|
-
width="18"
|
|
12
|
-
height="18"
|
|
13
|
-
viewBox="0 0 24 24"
|
|
14
|
-
fill="none"
|
|
15
|
-
stroke="currentColor"
|
|
16
|
-
stroke-width="2"
|
|
17
|
-
stroke-linecap="round"
|
|
18
|
-
stroke-linejoin="round"
|
|
19
|
-
>
|
|
20
|
-
<polyline points="3 6 5 6 21 6"></polyline>
|
|
21
|
-
<path
|
|
22
|
-
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
|
23
|
-
></path>
|
|
24
|
-
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
25
|
-
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
26
|
-
</svg>
|
|
27
|
-
</button>
|
|
28
|
-
<button class="confirm-yes" type="button" hidden>Ja</button>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
|
-
<script>
|
|
32
|
-
document.querySelectorAll('.delete-action').forEach((container) => {
|
|
33
|
-
const articleId = (container as HTMLElement).dataset.articleId;
|
|
34
|
-
const icon = container.querySelector('.delete-icon') as HTMLButtonElement;
|
|
35
|
-
const confirmBtn = container.querySelector(
|
|
36
|
-
'.confirm-yes'
|
|
37
|
-
) as HTMLButtonElement;
|
|
38
|
-
|
|
39
|
-
const showConfirm = () => {
|
|
40
|
-
icon.hidden = true;
|
|
41
|
-
confirmBtn.hidden = false;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const resetState = () => {
|
|
45
|
-
confirmBtn.hidden = true;
|
|
46
|
-
confirmBtn.disabled = false;
|
|
47
|
-
confirmBtn.textContent = 'Ja';
|
|
48
|
-
icon.hidden = false;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const removeCard = () => {
|
|
52
|
-
const card = container.closest('.article-card, .featured-card');
|
|
53
|
-
card ? card.remove() : window.location.reload();
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const deleteArticle = async () => {
|
|
57
|
-
confirmBtn.disabled = true;
|
|
58
|
-
confirmBtn.textContent = '...';
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const response = await fetch(`/api/articles/${articleId}`, {
|
|
62
|
-
method: 'DELETE',
|
|
63
|
-
credentials: 'same-origin',
|
|
64
|
-
});
|
|
65
|
-
response.ok
|
|
66
|
-
? removeCard()
|
|
67
|
-
: (alert('Löschen fehlgeschlagen'), resetState());
|
|
68
|
-
} catch {
|
|
69
|
-
alert('Löschen fehlgeschlagen');
|
|
70
|
-
resetState();
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
icon?.addEventListener('click', (e) => {
|
|
75
|
-
e.preventDefault();
|
|
76
|
-
e.stopPropagation();
|
|
77
|
-
showConfirm();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
confirmBtn?.addEventListener('click', (e) => {
|
|
81
|
-
e.preventDefault();
|
|
82
|
-
e.stopPropagation();
|
|
83
|
-
deleteArticle();
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
</script>
|
|
87
|
-
|
|
88
|
-
<style>
|
|
89
|
-
.delete-action {
|
|
90
|
-
position: absolute;
|
|
91
|
-
top: var(--space-3, 0.75rem);
|
|
92
|
-
right: var(--space-3, 0.75rem);
|
|
93
|
-
z-index: 10;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
.delete-icon {
|
|
97
|
-
display: flex;
|
|
98
|
-
align-items: center;
|
|
99
|
-
justify-content: center;
|
|
100
|
-
width: 36px;
|
|
101
|
-
height: 36px;
|
|
102
|
-
background: var(--color-surface);
|
|
103
|
-
border: 1px solid var(--color-border);
|
|
104
|
-
border-radius: var(--radius-md);
|
|
105
|
-
color: var(--color-text-muted);
|
|
106
|
-
cursor: pointer;
|
|
107
|
-
transition:
|
|
108
|
-
color 0.2s ease,
|
|
109
|
-
background 0.2s ease,
|
|
110
|
-
border-color 0.2s ease;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
.delete-icon:hover {
|
|
114
|
-
color: var(--color-error);
|
|
115
|
-
background: var(--color-error-muted);
|
|
116
|
-
border-color: var(--color-error);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
.delete-icon:disabled {
|
|
120
|
-
opacity: 0.5;
|
|
121
|
-
cursor: not-allowed;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
.confirm-yes {
|
|
125
|
-
padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
|
126
|
-
font-family: var(--font-ui);
|
|
127
|
-
font-size: var(--text-sm, 0.875rem);
|
|
128
|
-
font-weight: 600;
|
|
129
|
-
border: none;
|
|
130
|
-
border-radius: var(--radius-sm, 4px);
|
|
131
|
-
cursor: pointer;
|
|
132
|
-
background: var(--color-error, #f87171);
|
|
133
|
-
color: white;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
.confirm-yes:hover {
|
|
137
|
-
background: #ef4444;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
.confirm-yes:disabled {
|
|
141
|
-
opacity: 0.6;
|
|
142
|
-
cursor: not-allowed;
|
|
143
|
-
}
|
|
144
|
-
</style>
|