nca-ai-cms-astro-plugin 1.0.7 → 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/README.md +52 -0
- package/package.json +1 -1
- package/src/components/frontend/ArticleHeader.astro +75 -0
- package/src/components/frontend/BackLink.astro +25 -0
- package/src/components/frontend/DeleteAction.astro +143 -0
- package/src/components/frontend/HeroImage.astro +226 -0
- package/src/components/frontend/MetaDisplay.astro +66 -0
- package/src/components/frontend/SidebarCard.astro +12 -0
- package/src/components/frontend/TagList.astro +25 -0
- package/src/index.ts +30 -1
- package/src/layouts/Layout.astro +1150 -0
- package/src/pages/api/article-image/[...path].ts +52 -0
- package/src/pages/articles/[...slug].astro +378 -0
- package/src/pages/index.astro +755 -0
- package/src/pages/robots.txt.ts +26 -0
- package/src/pages/sitemap.xml.ts +58 -0
- package/src/services/ContentGenerator.ts +15 -14
- package/src/services/PromptService.ts +20 -6
- package/update.md +17 -0
package/README.md
CHANGED
|
@@ -88,6 +88,58 @@ ncaAiCms({
|
|
|
88
88
|
|
|
89
89
|
All `/api/*` and `/editor` routes are protected by cookie-based authentication.
|
|
90
90
|
|
|
91
|
+
## Settings Best Practice
|
|
92
|
+
|
|
93
|
+
The editor has a **Settings** tab with two groups of fields (Homepage and Website) that control how AI generates content. It also has three prompt categories (Content-KI, Analyse-KI, Bild-KI) where you create reusable prompts. Filling these in with values tailored to your business dramatically improves output quality.
|
|
94
|
+
|
|
95
|
+
### Settings fields
|
|
96
|
+
|
|
97
|
+
| Tab | Field | Key | Description |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| Homepage | Hero Ueberschrift | `hero_headline` | Main headline shown on your homepage |
|
|
100
|
+
| Homepage | Hero Text | `hero_text` | Supporting text below the hero headline |
|
|
101
|
+
| Homepage | Zielgruppe | `target_audience` | Who your content is for (e.g. "CTOs at mid-size SaaS companies") |
|
|
102
|
+
| Homepage | Tonalitaet | `tone` | Voice and tone for generated content (e.g. "professional but approachable") |
|
|
103
|
+
| Homepage | Kernbotschaft | `core_message` | The one key message your site should communicate |
|
|
104
|
+
| Website | CTA Link | `cta_url` | Default call-to-action URL (e.g. "/contact" or "/demo") |
|
|
105
|
+
| Website | CTA Stil | `cta_style` | Style or label for the CTA button (e.g. "Jetzt starten") |
|
|
106
|
+
| Website | CTA Prompt | `cta_prompt` | Prompt text used to generate CTA copy |
|
|
107
|
+
| Website | Core Tags | `core_tags` | Comma-separated keywords for your site (e.g. "AI, CMS, Astro, Open Source") |
|
|
108
|
+
| Website | Markenrichtlinien | `brand_guidelines` | Brand rules the AI should follow (colors, dos/don'ts, terminology) |
|
|
109
|
+
|
|
110
|
+
### Get values for your business with one AI prompt
|
|
111
|
+
|
|
112
|
+
Copy the prompt below into any AI chat (ChatGPT, Claude, Gemini) and replace the placeholder with a description of your business. You will get ready-to-paste values for every field.
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
I run the following business/website:
|
|
116
|
+
[Describe your business in 1-2 sentences, e.g. "An open-source community that organizes charity coding events for nonprofits in Germany."]
|
|
117
|
+
|
|
118
|
+
Please generate values for each of the following content management settings.
|
|
119
|
+
Return them as a simple list so I can copy-paste each value into the corresponding field.
|
|
120
|
+
|
|
121
|
+
1. hero_headline — A compelling hero headline (max ~10 words)
|
|
122
|
+
2. hero_text — Supporting hero text (2-3 sentences)
|
|
123
|
+
3. target_audience — Target audience description (one sentence)
|
|
124
|
+
4. tone — Tone of voice for all generated content (2-4 descriptive words)
|
|
125
|
+
5. core_message — Core message / value proposition (1-2 sentences)
|
|
126
|
+
6. cta_url — Suggested CTA link path (e.g. /contact)
|
|
127
|
+
7. cta_style — CTA button label text (2-4 words)
|
|
128
|
+
8. cta_prompt — Short prompt the AI uses to generate CTA copy (one sentence)
|
|
129
|
+
9. core_tags — 5-8 comma-separated keywords/tags for the site
|
|
130
|
+
10. brand_guidelines — Brand guidelines for AI-generated content (3-5 bullet points covering tone, terminology, and things to avoid)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Prompt categories
|
|
134
|
+
|
|
135
|
+
The three prompt tabs let you create reusable prompts that the AI uses when generating or analysing content.
|
|
136
|
+
|
|
137
|
+
| Category | Purpose | Tips for good prompts |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| **Content-KI** | Controls how the AI writes blog articles and text | Define word count, structure (intro/sections/CTA), target keywords, and writing style. The more specific, the better. |
|
|
140
|
+
| **Analyse-KI** | Controls how the AI analyses existing text | Specify what to check — SEO, readability, accessibility, keyword density — and ask for concrete improvement suggestions. |
|
|
141
|
+
| **Bild-KI** | Controls how the AI generates images | Describe the visual style, color palette, composition, and aspect ratio. Mention what should *not* appear in the image. |
|
|
142
|
+
|
|
91
143
|
## Development
|
|
92
144
|
|
|
93
145
|
```bash
|
package/package.json
CHANGED
|
@@ -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,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'() {
|