nca-ai-cms-astro-plugin 1.0.8 → 1.0.9

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.8",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -0,0 +1,75 @@
1
+ ---
2
+ import MetaDisplay from './MetaDisplay.astro';
3
+ import TagList from './TagList.astro';
4
+
5
+ interface Props {
6
+ title?: string;
7
+ category?: string;
8
+ date: string;
9
+ datetime?: string;
10
+ lede?: string;
11
+ tags?: string[];
12
+ animate?: boolean;
13
+ }
14
+
15
+ const {
16
+ category,
17
+ date,
18
+ datetime,
19
+ lede,
20
+ tags = [],
21
+ animate = false,
22
+ } = Astro.props;
23
+ ---
24
+
25
+ <header class="article-header">
26
+ <div class:list={['article-header-content', { 'animate-fadeInUp': animate }]}>
27
+ <MetaDisplay category={category} date={date} datetime={datetime} />
28
+ {lede && <p class="article-lede">{lede}</p>}
29
+ {tags.length > 1 && <TagList tags={tags.slice(1)} />}
30
+ </div>
31
+ </header>
32
+
33
+ <style>
34
+ .article-header {
35
+ padding: var(--space-16) 0 var(--space-12);
36
+ max-width: 900px;
37
+ }
38
+
39
+ .article-header-content {
40
+ opacity: 1;
41
+ }
42
+
43
+ .article-header-content.animate-fadeInUp {
44
+ opacity: 0;
45
+ }
46
+
47
+ .article-lede {
48
+ font-family: var(--font-body);
49
+ font-size: var(--text-xl);
50
+ color: var(--color-text);
51
+ line-height: var(--leading-relaxed);
52
+ max-width: 720px;
53
+ margin-bottom: var(--space-6);
54
+ }
55
+
56
+ @media (max-width: 768px) {
57
+ .article-header {
58
+ padding: var(--space-10) 0 var(--space-8);
59
+ }
60
+
61
+ .article-lede {
62
+ font-size: var(--text-lg);
63
+ }
64
+ }
65
+
66
+ @media (max-width: 480px) {
67
+ .article-header {
68
+ padding: var(--space-8) 0 var(--space-6);
69
+ }
70
+
71
+ .article-lede {
72
+ font-size: var(--text-base);
73
+ }
74
+ }
75
+ </style>
@@ -0,0 +1,25 @@
1
+ ---
2
+ interface Props {
3
+ href: string;
4
+ text: string;
5
+ }
6
+
7
+ const { href, text } = Astro.props;
8
+ ---
9
+
10
+ <a href={href} class="back-link">
11
+ <svg
12
+ width="20"
13
+ height="20"
14
+ viewBox="0 0 24 24"
15
+ fill="none"
16
+ stroke="currentColor"
17
+ stroke-width="2"
18
+ stroke-linecap="round"
19
+ stroke-linejoin="round"
20
+ >
21
+ <line x1="19" y1="12" x2="5" y2="12"></line>
22
+ <polyline points="12 19 5 12 12 5"></polyline>
23
+ </svg>
24
+ <span>{text}</span>
25
+ </a>
@@ -0,0 +1,143 @@
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
+ });
64
+ response.ok
65
+ ? removeCard()
66
+ : (alert('Löschen fehlgeschlagen'), resetState());
67
+ } catch {
68
+ alert('Löschen fehlgeschlagen');
69
+ resetState();
70
+ }
71
+ };
72
+
73
+ icon?.addEventListener('click', (e) => {
74
+ e.preventDefault();
75
+ e.stopPropagation();
76
+ showConfirm();
77
+ });
78
+
79
+ confirmBtn?.addEventListener('click', (e) => {
80
+ e.preventDefault();
81
+ e.stopPropagation();
82
+ deleteArticle();
83
+ });
84
+ });
85
+ </script>
86
+
87
+ <style>
88
+ .delete-action {
89
+ position: absolute;
90
+ top: var(--space-3, 0.75rem);
91
+ right: var(--space-3, 0.75rem);
92
+ z-index: 10;
93
+ }
94
+
95
+ .delete-icon {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ width: 36px;
100
+ height: 36px;
101
+ background: var(--color-surface);
102
+ border: 1px solid var(--color-border);
103
+ border-radius: var(--radius-md);
104
+ color: var(--color-text-muted);
105
+ cursor: pointer;
106
+ transition:
107
+ color 0.2s ease,
108
+ background 0.2s ease,
109
+ border-color 0.2s ease;
110
+ }
111
+
112
+ .delete-icon:hover {
113
+ color: var(--color-error);
114
+ background: var(--color-error-muted);
115
+ border-color: var(--color-error);
116
+ }
117
+
118
+ .delete-icon:disabled {
119
+ opacity: 0.5;
120
+ cursor: not-allowed;
121
+ }
122
+
123
+ .confirm-yes {
124
+ padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
125
+ font-family: var(--font-ui);
126
+ font-size: var(--text-sm, 0.875rem);
127
+ font-weight: 600;
128
+ border: none;
129
+ border-radius: var(--radius-sm, 4px);
130
+ cursor: pointer;
131
+ background: var(--color-error, #f87171);
132
+ color: white;
133
+ }
134
+
135
+ .confirm-yes:hover {
136
+ background: #ef4444;
137
+ }
138
+
139
+ .confirm-yes:disabled {
140
+ opacity: 0.6;
141
+ cursor: not-allowed;
142
+ }
143
+ </style>
@@ -0,0 +1,226 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+
6
+ interface Props {
7
+ articleId: string;
8
+ alt: string;
9
+ isAuthenticated?: boolean;
10
+ }
11
+
12
+ const { articleId, alt, isAuthenticated } = Astro.props;
13
+ const slug = articleId.split('/').pop() || articleId;
14
+
15
+ // Get file mtime for cache busting
16
+ let mtime = '';
17
+ try {
18
+ const heroPath = path.join(
19
+ process.cwd(),
20
+ 'nca-ai-cms-content',
21
+ articleId,
22
+ 'hero.webp'
23
+ );
24
+ const stats = await fs.stat(heroPath);
25
+ mtime = stats.mtimeMs.toString(36);
26
+ } catch {}
27
+
28
+ const imageSrc = `/api/article-image/${articleId}/hero.webp${mtime ? `?v=${mtime}` : ''}`;
29
+ ---
30
+
31
+ <figure class="article-hero" data-article-id={slug} data-image-src={imageSrc}>
32
+ <div class="article-hero-image">
33
+ <Image
34
+ src={imageSrc}
35
+ alt={alt}
36
+ width={1200}
37
+ height={675}
38
+ loading="eager"
39
+ class="hero-img"
40
+ />
41
+ {
42
+ isAuthenticated && (
43
+ <>
44
+ <button
45
+ class="regenerate-btn"
46
+ type="button"
47
+ aria-label="Bild neu generieren"
48
+ >
49
+ <svg
50
+ width="20"
51
+ height="20"
52
+ viewBox="0 0 24 24"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ stroke-width="2"
56
+ >
57
+ <path d="M23 4v6h-6" />
58
+ <path d="M1 20v-6h6" />
59
+ <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" />
60
+ </svg>
61
+ </button>
62
+ <button
63
+ class="confirm-btn"
64
+ type="button"
65
+ hidden
66
+ aria-label="Speichern"
67
+ >
68
+ <svg
69
+ width="20"
70
+ height="20"
71
+ viewBox="0 0 24 24"
72
+ fill="none"
73
+ stroke="currentColor"
74
+ stroke-width="3"
75
+ >
76
+ <polyline points="20 6 9 17 4 12" />
77
+ </svg>
78
+ </button>
79
+ </>
80
+ )
81
+ }
82
+ </div>
83
+ {alt && <figcaption class="hero-caption">{alt}</figcaption>}
84
+ </figure>
85
+
86
+ <script>
87
+ const container = document.querySelector('.article-hero') as HTMLElement;
88
+ if (container) {
89
+ const articleId = container.dataset.articleId;
90
+ const img = container.querySelector('.hero-img') as HTMLImageElement;
91
+ const regenerateBtn = container.querySelector(
92
+ '.regenerate-btn'
93
+ ) as HTMLButtonElement;
94
+ const confirmBtn = container.querySelector(
95
+ '.confirm-btn'
96
+ ) as HTMLButtonElement;
97
+
98
+ let pendingImage: { url: string; alt: string } | null = null;
99
+
100
+ regenerateBtn?.addEventListener('click', async () => {
101
+ regenerateBtn.disabled = true;
102
+ regenerateBtn.classList.add('loading');
103
+
104
+ try {
105
+ const res = await fetch(`/api/articles/${articleId}/regenerate-image`, {
106
+ method: 'POST',
107
+ });
108
+ if (!res.ok) throw new Error('Failed');
109
+
110
+ const data = await res.json();
111
+ pendingImage = { url: data.url, alt: data.alt };
112
+
113
+ // Show preview
114
+ img.src = data.url;
115
+ regenerateBtn.hidden = true;
116
+ confirmBtn.hidden = false;
117
+ } catch {
118
+ alert('Bild-Generierung fehlgeschlagen');
119
+ regenerateBtn.disabled = false;
120
+ regenerateBtn.classList.remove('loading');
121
+ }
122
+ });
123
+
124
+ confirmBtn?.addEventListener('click', async () => {
125
+ if (!pendingImage) return;
126
+ confirmBtn.disabled = true;
127
+ confirmBtn.classList.add('loading');
128
+
129
+ try {
130
+ const res = await fetch(`/api/articles/${articleId}/apply`, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({
134
+ imageUrl: pendingImage.url,
135
+ imageAlt: pendingImage.alt,
136
+ }),
137
+ });
138
+ if (!res.ok) throw new Error('Failed');
139
+
140
+ // Hard reload
141
+ window.location.reload();
142
+ } catch {
143
+ alert('Speichern fehlgeschlagen');
144
+ confirmBtn.disabled = false;
145
+ confirmBtn.classList.remove('loading');
146
+ }
147
+ });
148
+ }
149
+ </script>
150
+
151
+ <style>
152
+ .article-hero {
153
+ margin: 0 0 var(--space-12, 3rem);
154
+ width: 100%;
155
+ }
156
+
157
+ .article-hero-image {
158
+ position: relative;
159
+ }
160
+
161
+ .article-hero-image img {
162
+ width: 100%;
163
+ max-height: 520px;
164
+ object-fit: cover;
165
+ display: block;
166
+ }
167
+
168
+ .regenerate-btn,
169
+ .confirm-btn {
170
+ position: absolute;
171
+ top: 1rem;
172
+ right: 1rem;
173
+ width: 44px;
174
+ height: 44px;
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: center;
178
+ border-radius: 8px;
179
+ cursor: pointer;
180
+ transition: all 0.2s;
181
+ }
182
+
183
+ .regenerate-btn {
184
+ background: rgba(20, 20, 22, 0.9);
185
+ border: 1px solid var(--color-border);
186
+ color: var(--color-text-muted);
187
+ }
188
+
189
+ .regenerate-btn:hover {
190
+ color: var(--color-accent);
191
+ border-color: var(--color-accent);
192
+ }
193
+
194
+ .confirm-btn {
195
+ background: var(--color-success);
196
+ border: none;
197
+ color: #000;
198
+ }
199
+
200
+ .confirm-btn:hover {
201
+ background: #22c55e;
202
+ }
203
+
204
+ .regenerate-btn:disabled,
205
+ .confirm-btn:disabled {
206
+ opacity: 0.5;
207
+ cursor: not-allowed;
208
+ }
209
+
210
+ .loading svg {
211
+ animation: spin 1s linear infinite;
212
+ }
213
+
214
+ [hidden] {
215
+ display: none !important;
216
+ }
217
+
218
+ .hero-caption {
219
+ max-width: 720px;
220
+ margin: 1rem auto 0;
221
+ padding: 0 1rem;
222
+ font-style: italic;
223
+ color: var(--color-text);
224
+ font-size: var(--text-base, 1rem);
225
+ }
226
+ </style>
@@ -0,0 +1,66 @@
1
+ ---
2
+ interface Props {
3
+ category?: string;
4
+ date: string;
5
+ datetime?: string;
6
+ }
7
+
8
+ const { category, date, datetime } = Astro.props;
9
+ ---
10
+
11
+ <div class="article-meta">
12
+ {category && <span class="article-category">{category}</span>}
13
+ <span class="article-meta-divider" aria-hidden="true"></span>
14
+ {
15
+ datetime ? (
16
+ <time datetime={datetime} class="article-date">
17
+ {date}
18
+ </time>
19
+ ) : (
20
+ <span class="article-date">{date}</span>
21
+ )
22
+ }
23
+ </div>
24
+
25
+ <style>
26
+ .article-meta {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: var(--space-4);
30
+ margin-bottom: var(--space-6);
31
+ }
32
+
33
+ .article-category {
34
+ font-family: var(--font-ui);
35
+ font-size: var(--text-base);
36
+ font-weight: 700;
37
+ text-transform: uppercase;
38
+ letter-spacing: var(--tracking-widest);
39
+ color: var(--color-text);
40
+ padding: var(--space-1) var(--space-3);
41
+ background: var(--color-primary-muted);
42
+ border-radius: var(--radius-sm);
43
+ }
44
+
45
+ .article-meta-divider {
46
+ width: 32px;
47
+ height: 1px;
48
+ background: var(--color-border-accent);
49
+ }
50
+
51
+ .article-date {
52
+ font-family: var(--font-ui);
53
+ font-size: var(--text-sm);
54
+ color: var(--color-text);
55
+ }
56
+
57
+ @media (max-width: 480px) {
58
+ .article-meta {
59
+ gap: var(--space-2);
60
+ }
61
+
62
+ .article-meta-divider {
63
+ width: 16px;
64
+ }
65
+ }
66
+ </style>
@@ -0,0 +1,12 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ }
5
+
6
+ const { title } = Astro.props;
7
+ ---
8
+
9
+ <div class="sidebar-card">
10
+ <h4 class="sidebar-title">{title}</h4>
11
+ <slot />
12
+ </div>
@@ -0,0 +1,25 @@
1
+ ---
2
+ interface Props {
3
+ tags: string[];
4
+ }
5
+
6
+ const { tags } = Astro.props;
7
+ ---
8
+
9
+ {
10
+ tags.length > 0 && (
11
+ <div class="tag-list">
12
+ {tags.map((tag: string) => (
13
+ <span class="tag">{tag}</span>
14
+ ))}
15
+ </div>
16
+ )
17
+ }
18
+
19
+ <style>
20
+ .tag-list {
21
+ display: flex;
22
+ flex-wrap: wrap;
23
+ gap: var(--space-2);
24
+ }
25
+ </style>
package/src/index.ts CHANGED
@@ -153,7 +153,17 @@ export default function ncaAiCms(
153
153
  prerender: false,
154
154
  });
155
155
 
156
- // Inject pages
156
+ // Inject frontend pages
157
+ injectRoute({
158
+ pattern: '/',
159
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/index.astro',
160
+ prerender: false,
161
+ });
162
+ injectRoute({
163
+ pattern: '/articles/[...slug]',
164
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/articles/[...slug].astro',
165
+ prerender: false,
166
+ });
157
167
  injectRoute({
158
168
  pattern: '/login',
159
169
  entrypoint: 'nca-ai-cms-astro-plugin/pages/login.astro',
@@ -164,6 +174,25 @@ export default function ncaAiCms(
164
174
  entrypoint: 'nca-ai-cms-astro-plugin/pages/editor.astro',
165
175
  prerender: false,
166
176
  });
177
+
178
+ // Inject article image serving
179
+ injectRoute({
180
+ pattern: '/api/article-image/[...path]',
181
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/api/article-image/[...path].ts',
182
+ prerender: false,
183
+ });
184
+
185
+ // Inject SEO routes
186
+ injectRoute({
187
+ pattern: '/robots.txt',
188
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/robots.txt.ts',
189
+ prerender: false,
190
+ });
191
+ injectRoute({
192
+ pattern: '/sitemap.xml',
193
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/sitemap.xml.ts',
194
+ prerender: false,
195
+ });
167
196
  },
168
197
 
169
198
  'astro:server:start'() {