nca-ai-cms-astro-plugin 1.0.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.
Files changed (73) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/README.md +87 -0
  3. package/package.json +53 -0
  4. package/src/api/_utils.ts +20 -0
  5. package/src/api/articles/[id]/apply.ts +89 -0
  6. package/src/api/articles/[id]/regenerate-image.ts +49 -0
  7. package/src/api/articles/[id]/regenerate-text.ts +57 -0
  8. package/src/api/articles/[id].ts +53 -0
  9. package/src/api/auth/check.ts +6 -0
  10. package/src/api/auth/login.ts +43 -0
  11. package/src/api/auth/logout.ts +6 -0
  12. package/src/api/generate-content.ts +43 -0
  13. package/src/api/generate-image.ts +33 -0
  14. package/src/api/prompts.ts +45 -0
  15. package/src/api/save-image.ts +38 -0
  16. package/src/api/save.ts +49 -0
  17. package/src/api/scheduler/[id].ts +31 -0
  18. package/src/api/scheduler/generate.ts +94 -0
  19. package/src/api/scheduler/publish.ts +96 -0
  20. package/src/api/scheduler.ts +51 -0
  21. package/src/components/Editor.tsx +115 -0
  22. package/src/components/editor/GenerateTab.tsx +384 -0
  23. package/src/components/editor/PlannerTab.tsx +345 -0
  24. package/src/components/editor/SettingsTab.tsx +185 -0
  25. package/src/components/editor/styles.ts +597 -0
  26. package/src/components/editor/types.ts +49 -0
  27. package/src/components/editor/useTabNavigation.ts +69 -0
  28. package/src/config.d.ts +4 -0
  29. package/src/db/tables.ts +39 -0
  30. package/src/domain/entities/Article.test.ts +138 -0
  31. package/src/domain/entities/Article.ts +90 -0
  32. package/src/domain/entities/ScheduledPost.test.ts +228 -0
  33. package/src/domain/entities/ScheduledPost.ts +152 -0
  34. package/src/domain/entities/Source.test.ts +57 -0
  35. package/src/domain/entities/Source.ts +43 -0
  36. package/src/domain/entities/index.ts +9 -0
  37. package/src/domain/index.ts +16 -0
  38. package/src/domain/value-objects/ArticleFinder.test.ts +104 -0
  39. package/src/domain/value-objects/ArticleFinder.ts +61 -0
  40. package/src/domain/value-objects/SEOMetadata.test.ts +48 -0
  41. package/src/domain/value-objects/SEOMetadata.ts +19 -0
  42. package/src/domain/value-objects/Slug.test.ts +51 -0
  43. package/src/domain/value-objects/Slug.ts +33 -0
  44. package/src/domain/value-objects/index.ts +4 -0
  45. package/src/index.ts +146 -0
  46. package/src/middleware.ts +30 -0
  47. package/src/pages/editor.astro +22 -0
  48. package/src/pages/login.astro +117 -0
  49. package/src/services/ArticleService.test.ts +148 -0
  50. package/src/services/ArticleService.ts +150 -0
  51. package/src/services/AutoPublisher.ts +122 -0
  52. package/src/services/ContentFetcher.ts +89 -0
  53. package/src/services/ContentGenerator.ts +320 -0
  54. package/src/services/FileWriter.test.ts +80 -0
  55. package/src/services/FileWriter.ts +59 -0
  56. package/src/services/ImageConverter.ts +15 -0
  57. package/src/services/ImageGenerator.ts +108 -0
  58. package/src/services/PromptService.ts +84 -0
  59. package/src/services/SchedulerDBAdapter.ts +75 -0
  60. package/src/services/SchedulerService.test.ts +286 -0
  61. package/src/services/SchedulerService.ts +149 -0
  62. package/src/services/index.ts +27 -0
  63. package/src/utils/authUtils.test.ts +60 -0
  64. package/src/utils/authUtils.ts +25 -0
  65. package/src/utils/envUtils.test.ts +40 -0
  66. package/src/utils/envUtils.ts +26 -0
  67. package/src/utils/index.ts +7 -0
  68. package/src/utils/markdown.test.ts +65 -0
  69. package/src/utils/markdown.ts +13 -0
  70. package/src/utils/sanitize.test.ts +180 -0
  71. package/src/utils/sanitize.ts +98 -0
  72. package/tsconfig.json +22 -0
  73. package/vitest.config.ts +14 -0
@@ -0,0 +1,345 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import type { ScheduledPostData } from './types';
3
+ import { styles } from './styles';
4
+
5
+ function getStatusColor(status: string): string {
6
+ switch (status) {
7
+ case 'published':
8
+ return 'var(--color-success, #4ade80)';
9
+ case 'generated':
10
+ return 'var(--color-primary, #e63946)';
11
+ case 'failed':
12
+ return 'var(--color-error, #f87171)';
13
+ default:
14
+ return 'var(--color-text-muted, #b8b5b0)';
15
+ }
16
+ }
17
+
18
+ function getStatusLabel(status: string): string {
19
+ switch (status) {
20
+ case 'published':
21
+ return 'Veröffentlicht';
22
+ case 'generated':
23
+ return 'Generiert';
24
+ case 'scheduled':
25
+ return 'Geplant';
26
+ case 'failed':
27
+ return 'Fehlgeschlagen';
28
+ default:
29
+ return status;
30
+ }
31
+ }
32
+
33
+ function formatDate(dateStr: string): string {
34
+ try {
35
+ return new Date(dateStr).toLocaleDateString('de-DE', {
36
+ day: '2-digit',
37
+ month: '2-digit',
38
+ year: 'numeric',
39
+ hour: '2-digit',
40
+ minute: '2-digit',
41
+ });
42
+ } catch {
43
+ return dateStr;
44
+ }
45
+ }
46
+
47
+ export function PlannerTab() {
48
+ const [posts, setPosts] = useState<ScheduledPostData[]>([]);
49
+ const [loading, setLoading] = useState(true);
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [newInput, setNewInput] = useState('');
52
+ const [newInputType] = useState('keyword');
53
+ const [newDate, setNewDate] = useState('');
54
+ const [adding, setAdding] = useState(false);
55
+ const [actionLoading, setActionLoading] = useState<string | null>(null);
56
+
57
+ const loadPosts = useCallback(async () => {
58
+ setLoading(true);
59
+ setError(null);
60
+ try {
61
+ const res = await fetch('/api/scheduler');
62
+ if (!res.ok) throw new Error('Fehler beim Laden der geplanten Beiträge');
63
+ const data = (await res.json()) as ScheduledPostData[];
64
+ setPosts(data);
65
+ } catch (err) {
66
+ setError(
67
+ err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten',
68
+ );
69
+ } finally {
70
+ setLoading(false);
71
+ }
72
+ }, []);
73
+
74
+ const autoPublishDue = useCallback(async () => {
75
+ try {
76
+ await fetch('/api/scheduler', {
77
+ method: 'PATCH',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ action: 'auto-publish' }),
80
+ });
81
+ await loadPosts();
82
+ } catch {
83
+ // silent fail for auto-publish
84
+ }
85
+ }, [loadPosts]);
86
+
87
+ useEffect(() => {
88
+ loadPosts().then(() => autoPublishDue());
89
+ }, [loadPosts, autoPublishDue]);
90
+
91
+ const handleAdd = async () => {
92
+ if (!newInput.trim() || !newDate) return;
93
+ setAdding(true);
94
+ setError(null);
95
+ try {
96
+ const res = await fetch('/api/scheduler', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({
100
+ input: newInput,
101
+ inputType: newInputType,
102
+ scheduledDate: newDate,
103
+ }),
104
+ });
105
+ if (!res.ok) throw new Error('Fehler beim Hinzufügen');
106
+ setNewInput('');
107
+ setNewDate('');
108
+ await loadPosts();
109
+ } catch (err) {
110
+ setError(
111
+ err instanceof Error ? err.message : 'Fehler beim Hinzufügen',
112
+ );
113
+ } finally {
114
+ setAdding(false);
115
+ }
116
+ };
117
+
118
+ const handleGenerate = async (id: string) => {
119
+ setActionLoading(id);
120
+ setError(null);
121
+ try {
122
+ const res = await fetch('/api/scheduler', {
123
+ method: 'PATCH',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ action: 'generate', id }),
126
+ });
127
+ if (!res.ok) throw new Error('Fehler beim Generieren');
128
+ await loadPosts();
129
+ } catch (err) {
130
+ setError(
131
+ err instanceof Error ? err.message : 'Fehler beim Generieren',
132
+ );
133
+ } finally {
134
+ setActionLoading(null);
135
+ }
136
+ };
137
+
138
+ const handlePublish = async (id: string) => {
139
+ setActionLoading(id);
140
+ setError(null);
141
+ try {
142
+ const res = await fetch('/api/scheduler', {
143
+ method: 'PATCH',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ action: 'publish', id }),
146
+ });
147
+ if (!res.ok) throw new Error('Fehler beim Veröffentlichen');
148
+ await loadPosts();
149
+ } catch (err) {
150
+ setError(
151
+ err instanceof Error
152
+ ? err.message
153
+ : 'Fehler beim Veröffentlichen',
154
+ );
155
+ } finally {
156
+ setActionLoading(null);
157
+ }
158
+ };
159
+
160
+ const handleDelete = async (id: string) => {
161
+ setActionLoading(id);
162
+ setError(null);
163
+ try {
164
+ const res = await fetch('/api/scheduler', {
165
+ method: 'DELETE',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ id }),
168
+ });
169
+ if (!res.ok) throw new Error('Fehler beim Löschen');
170
+ await loadPosts();
171
+ } catch (err) {
172
+ setError(
173
+ err instanceof Error ? err.message : 'Fehler beim Löschen',
174
+ );
175
+ } finally {
176
+ setActionLoading(null);
177
+ }
178
+ };
179
+
180
+ return (
181
+ <div style={styles.plannerContent}>
182
+ <div style={styles.plannerForm}>
183
+ <h3 style={{ ...styles.heading, fontSize: '1rem' }}>
184
+ Neuen Beitrag planen
185
+ </h3>
186
+ <div style={styles.plannerFormRow}>
187
+ <div style={styles.field}>
188
+ <label style={styles.label} htmlFor="planner-input">
189
+ Thema / Keyword
190
+ </label>
191
+ <input
192
+ id="planner-input"
193
+ type="text"
194
+ style={styles.input}
195
+ value={newInput}
196
+ onChange={(e) => setNewInput(e.target.value)}
197
+ placeholder="z.B. Next.js App Router..."
198
+ disabled={adding}
199
+ />
200
+ </div>
201
+ <div style={styles.field}>
202
+ <label style={styles.label} htmlFor="planner-date">
203
+ Veröffentlichungsdatum
204
+ </label>
205
+ <input
206
+ id="planner-date"
207
+ type="datetime-local"
208
+ style={styles.input}
209
+ value={newDate}
210
+ onChange={(e) => setNewDate(e.target.value)}
211
+ disabled={adding}
212
+ />
213
+ </div>
214
+ <button
215
+ type="button"
216
+ style={{
217
+ ...styles.generateButton,
218
+ width: 'auto',
219
+ whiteSpace: 'nowrap' as React.CSSProperties['whiteSpace'],
220
+ opacity: adding || !newInput.trim() || !newDate ? 0.6 : 1,
221
+ }}
222
+ onClick={handleAdd}
223
+ disabled={adding || !newInput.trim() || !newDate}
224
+ >
225
+ {adding ? 'Wird hinzugefügt...' : 'Hinzufügen'}
226
+ </button>
227
+ </div>
228
+ </div>
229
+
230
+ {error && <div style={styles.error}>{error}</div>}
231
+
232
+ {loading && (
233
+ <div style={styles.loadingBox}>Geplante Beiträge werden geladen...</div>
234
+ )}
235
+
236
+ {!loading && posts.length === 0 && (
237
+ <div style={styles.emptyState}>
238
+ Noch keine geplanten Beiträge. Erstelle deinen ersten oben.
239
+ </div>
240
+ )}
241
+
242
+ {!loading && posts.length > 0 && (
243
+ <div style={styles.plannerList}>
244
+ {posts.map((post) => (
245
+ <div key={post.id} style={styles.plannerCard}>
246
+ <div style={styles.plannerCardHeader}>
247
+ <div style={styles.plannerCardMeta}>
248
+ <span style={styles.plannerDate}>
249
+ {formatDate(post.scheduledDate)}
250
+ </span>
251
+ <span
252
+ style={{
253
+ ...styles.statusBadge,
254
+ background: getStatusColor(post.status),
255
+ color:
256
+ post.status === 'scheduled'
257
+ ? 'var(--color-text, #faf9f7)'
258
+ : '#000',
259
+ }}
260
+ >
261
+ {getStatusLabel(post.status)}
262
+ </span>
263
+ <span style={styles.plannerInputType}>
264
+ {post.inputType}
265
+ </span>
266
+ </div>
267
+ <div style={styles.plannerCardActions}>
268
+ {post.status === 'scheduled' && (
269
+ <button
270
+ type="button"
271
+ style={styles.editButton}
272
+ onClick={() => handleGenerate(post.id)}
273
+ disabled={actionLoading === post.id}
274
+ >
275
+ {actionLoading === post.id
276
+ ? 'Generiere...'
277
+ : 'Generieren'}
278
+ </button>
279
+ )}
280
+ {post.status === 'generated' && (
281
+ <button
282
+ type="button"
283
+ style={styles.saveButton}
284
+ onClick={() => handlePublish(post.id)}
285
+ disabled={actionLoading === post.id}
286
+ >
287
+ {actionLoading === post.id
288
+ ? 'Veröffentliche...'
289
+ : 'Veröffentlichen'}
290
+ </button>
291
+ )}
292
+ {post.status !== 'published' && (
293
+ <button
294
+ type="button"
295
+ style={styles.cancelButton}
296
+ onClick={() => handleDelete(post.id)}
297
+ disabled={actionLoading === post.id}
298
+ >
299
+ Löschen
300
+ </button>
301
+ )}
302
+ </div>
303
+ </div>
304
+
305
+ <div style={styles.plannerCardBody}>
306
+ <div style={styles.plannerInput}>
307
+ <strong>Eingabe:</strong> {post.input}
308
+ </div>
309
+
310
+ {post.generatedTitle && (
311
+ <div style={styles.plannerPreview}>
312
+ <strong>Titel:</strong> {post.generatedTitle}
313
+ {post.generatedDescription && (
314
+ <>
315
+ <br />
316
+ <strong>Beschreibung:</strong>{' '}
317
+ {post.generatedDescription}
318
+ </>
319
+ )}
320
+ </div>
321
+ )}
322
+
323
+ {post.generatedImageData && (
324
+ <div style={styles.plannerImagePreview}>
325
+ <img
326
+ src={post.generatedImageData}
327
+ alt={post.generatedImageAlt || 'Generiertes Bild'}
328
+ style={styles.plannerImage}
329
+ />
330
+ </div>
331
+ )}
332
+
333
+ {post.publishedPath && (
334
+ <div style={styles.plannerPublishedPath}>
335
+ {post.publishedPath}
336
+ </div>
337
+ )}
338
+ </div>
339
+ </div>
340
+ ))}
341
+ </div>
342
+ )}
343
+ </div>
344
+ );
345
+ }
@@ -0,0 +1,185 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import type { Prompt, SettingsSubTab } from './types';
3
+ import { styles } from './styles';
4
+
5
+ const SUB_TABS: { key: SettingsSubTab; label: string }[] = [
6
+ { key: 'homepage', label: 'Homepage' },
7
+ { key: 'content-ai', label: 'Content-KI' },
8
+ { key: 'analysis-ai', label: 'Analyse-KI' },
9
+ { key: 'image-ai', label: 'Bild-KI' },
10
+ { key: 'website', label: 'Website' },
11
+ ];
12
+
13
+ interface EditState {
14
+ id: string;
15
+ value: string;
16
+ }
17
+
18
+ export function SettingsTab() {
19
+ const [activeSubTab, setActiveSubTab] =
20
+ useState<SettingsSubTab>('homepage');
21
+ const [prompts, setPrompts] = useState<Prompt[]>([]);
22
+ const [loading, setLoading] = useState(true);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [editing, setEditing] = useState<EditState | null>(null);
25
+ const [saving, setSaving] = useState(false);
26
+
27
+ const loadPrompts = useCallback(async () => {
28
+ setLoading(true);
29
+ setError(null);
30
+ try {
31
+ const res = await fetch('/api/prompts');
32
+ if (!res.ok) throw new Error('Fehler beim Laden der Einstellungen');
33
+ const data = (await res.json()) as Prompt[];
34
+ setPrompts(data);
35
+ } catch (err) {
36
+ setError(
37
+ err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten',
38
+ );
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ loadPrompts();
46
+ }, [loadPrompts]);
47
+
48
+ const filteredPrompts = prompts.filter(
49
+ (p) => p.category === activeSubTab,
50
+ );
51
+
52
+ const handleEdit = (prompt: Prompt) => {
53
+ setEditing({ id: prompt.id, value: prompt.promptText });
54
+ };
55
+
56
+ const handleCancel = () => {
57
+ setEditing(null);
58
+ };
59
+
60
+ const handleSave = async () => {
61
+ if (!editing) return;
62
+ setSaving(true);
63
+ setError(null);
64
+ try {
65
+ const res = await fetch('/api/prompts', {
66
+ method: 'PUT',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({
69
+ id: editing.id,
70
+ promptText: editing.value,
71
+ }),
72
+ });
73
+ if (!res.ok) throw new Error('Fehler beim Speichern');
74
+ setPrompts((prev) =>
75
+ prev.map((p) =>
76
+ p.id === editing.id ? { ...p, promptText: editing.value } : p,
77
+ ),
78
+ );
79
+ setEditing(null);
80
+ } catch (err) {
81
+ setError(
82
+ err instanceof Error ? err.message : 'Fehler beim Speichern',
83
+ );
84
+ } finally {
85
+ setSaving(false);
86
+ }
87
+ };
88
+
89
+ return (
90
+ <div style={styles.settingsContent}>
91
+ <div style={styles.subTabNav} role="tablist">
92
+ {SUB_TABS.map((tab) => (
93
+ <button
94
+ key={tab.key}
95
+ type="button"
96
+ role="tab"
97
+ aria-selected={activeSubTab === tab.key}
98
+ style={
99
+ activeSubTab === tab.key
100
+ ? styles.subTabActive
101
+ : styles.subTab
102
+ }
103
+ onClick={() => {
104
+ setActiveSubTab(tab.key);
105
+ setEditing(null);
106
+ }}
107
+ >
108
+ {tab.label}
109
+ </button>
110
+ ))}
111
+ </div>
112
+
113
+ {loading && (
114
+ <div style={styles.loadingBox}>Einstellungen werden geladen...</div>
115
+ )}
116
+
117
+ {error && <div style={styles.error}>{error}</div>}
118
+
119
+ {!loading && filteredPrompts.length === 0 && (
120
+ <div style={styles.emptyState}>
121
+ Keine Einstellungen in dieser Kategorie.
122
+ </div>
123
+ )}
124
+
125
+ {!loading && filteredPrompts.length > 0 && (
126
+ <div style={styles.cardGrid}>
127
+ {filteredPrompts.map((prompt) => (
128
+ <div key={prompt.id} style={styles.settingsCard}>
129
+ <div style={styles.settingsCardHeader}>
130
+ <div style={styles.settingsItemLabel}>{prompt.name}</div>
131
+ <div style={styles.settingsItemCategory}>
132
+ {prompt.category}
133
+ </div>
134
+ </div>
135
+
136
+ {editing?.id === prompt.id ? (
137
+ <div style={styles.editArea}>
138
+ <textarea
139
+ style={styles.textarea}
140
+ value={editing.value}
141
+ onChange={(e) =>
142
+ setEditing({ ...editing, value: e.target.value })
143
+ }
144
+ />
145
+ <div style={styles.editButtons}>
146
+ <button
147
+ type="button"
148
+ style={styles.saveButton}
149
+ onClick={handleSave}
150
+ disabled={saving}
151
+ >
152
+ {saving ? 'Speichere...' : 'Speichern'}
153
+ </button>
154
+ <button
155
+ type="button"
156
+ style={styles.cancelButton}
157
+ onClick={handleCancel}
158
+ >
159
+ Abbrechen
160
+ </button>
161
+ </div>
162
+ </div>
163
+ ) : (
164
+ <>
165
+ <div style={styles.settingsItemValue}>
166
+ {prompt.promptText}
167
+ </div>
168
+ <div style={{ padding: '0 1rem 1rem' }}>
169
+ <button
170
+ type="button"
171
+ style={styles.editButton}
172
+ onClick={() => handleEdit(prompt)}
173
+ >
174
+ Bearbeiten
175
+ </button>
176
+ </div>
177
+ </>
178
+ )}
179
+ </div>
180
+ ))}
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }