ferst-core 0.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.
@@ -0,0 +1,64 @@
1
+ ---
2
+ import type { CollectionEntry } from 'astro:content';
3
+ import Card from './Card.astro';
4
+ import PostCard from './PostCard.astro';
5
+
6
+ interface Props {
7
+ posts: CollectionEntry<'posts'>[];
8
+ }
9
+
10
+ const { posts } = Astro.props;
11
+ ---
12
+
13
+ <ul class="latest-list">
14
+ {
15
+ posts.map((post) => (
16
+ <li class="latest-list__item">
17
+ <Card padding="0" class="latest-list__card">
18
+ <PostCard post={post} withImage />
19
+ </Card>
20
+ </li>
21
+ ))
22
+ }
23
+ </ul>
24
+
25
+ <style>
26
+ .latest-list {
27
+ list-style: none;
28
+ padding: 0;
29
+ margin: 0;
30
+ display: grid;
31
+ grid-template-columns: 1fr;
32
+ gap: 1rem;
33
+ }
34
+
35
+ @media (min-width: 48em) {
36
+ .latest-list {
37
+ grid-template-columns: repeat(2, 1fr);
38
+ gap: 1.25rem;
39
+ }
40
+ }
41
+
42
+ @media (min-width: 64em) {
43
+ .latest-list {
44
+ grid-template-columns: repeat(3, 1fr);
45
+ gap: 1.5rem;
46
+ }
47
+ }
48
+
49
+ /* display: grid (not flex/block) so this reliably stretches its one child
50
+ (.latest-list__card) to the full row height — a plain block wrapper
51
+ doesn't pass a grid-stretched height down to a nested child the same
52
+ way. Kept as a separate element from the Card itself so the
53
+ reveal-on-scroll transform/opacity (armReveal, applied in
54
+ [...page].astro) doesn't collide with Card's own hover transition on
55
+ the same element. */
56
+ .latest-list__item {
57
+ display: grid;
58
+ }
59
+
60
+ .latest-list__card :global(.post-card) {
61
+ border-bottom: none;
62
+ padding: 0;
63
+ }
64
+ </style>
@@ -0,0 +1,81 @@
1
+ ---
2
+ import Button from './Button.astro';
3
+
4
+ interface Props {
5
+ currentPage: number;
6
+ lastPage: number;
7
+ prevUrl: string | undefined;
8
+ nextUrl: string | undefined;
9
+ }
10
+
11
+ const { currentPage, lastPage, prevUrl, nextUrl } = Astro.props;
12
+ ---
13
+
14
+ <nav class="pagination-nav" aria-label="Pagination navigation">
15
+ <div class="pagination-nav__controls">
16
+ {prevUrl ? (
17
+ <Button href={prevUrl} variant="primary" size="sm" rel="prev" class="pagination-nav__btn" style="border-radius: 8px;">
18
+ ← Prev
19
+ </Button>
20
+ ) : (
21
+ <Button variant="primary" size="sm" disabled aria-disabled="true" class="pagination-nav__btn" style="border-radius: 8px;">
22
+ ← Prev
23
+ </Button>
24
+ )}
25
+
26
+ <span class="pagination-nav__info">
27
+ Page {currentPage} of {lastPage}
28
+ </span>
29
+
30
+ {nextUrl ? (
31
+ <Button href={nextUrl} variant="primary" size="sm" rel="next" class="pagination-nav__btn" style="border-radius: 8px;">
32
+ Next →
33
+ </Button>
34
+ ) : (
35
+ <Button variant="primary" size="sm" disabled aria-disabled="true" class="pagination-nav__btn" style="border-radius: 8px;">
36
+ Next →
37
+ </Button>
38
+ )}
39
+ </div>
40
+ </nav>
41
+
42
+ <style>
43
+ .pagination-nav {
44
+ display: flex;
45
+ justify-content: center;
46
+ }
47
+
48
+ .pagination-nav__controls {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ gap: clamp(1.5rem, 3vw, 2.5rem);
53
+ flex-wrap: wrap;
54
+ }
55
+
56
+ .pagination-nav__info {
57
+ font-family: 'Inter', sans-serif;
58
+ font-size: clamp(0.875rem, 1.5vw, 1rem);
59
+ line-height: 1.4;
60
+ color: var(--muted);
61
+ white-space: nowrap;
62
+ }
63
+
64
+ :global([data-theme='dark']) .pagination-nav__info {
65
+ color: var(--text-light);
66
+ }
67
+
68
+ /* Mobile: tighter spacing */
69
+ @media (max-width: 47.9375em) {
70
+ .pagination-nav__controls {
71
+ gap: 0.75rem;
72
+ }
73
+
74
+ .pagination-nav__info {
75
+ font-size: 0.8rem;
76
+ order: 3;
77
+ width: 100%;
78
+ text-align: center;
79
+ }
80
+ }
81
+ </style>
@@ -0,0 +1,262 @@
1
+ ---
2
+ import type { CollectionEntry } from 'astro:content';
3
+ import { opensAttachmentDirectly } from '../content/filters';
4
+ import { plainTextExcerpt } from '../utils/excerpt';
5
+ import { isFreshPost } from '../utils/freshness';
6
+ import { formatDate } from '../utils/formatDate';
7
+
8
+ interface Props {
9
+ post: CollectionEntry<'posts'>;
10
+ withImage?: boolean;
11
+ showExcerpt?: boolean;
12
+ /** Whole-tile click (stretched-link on the title, no separate "Read" link) —
13
+ used by /posts/. */
14
+ linkWholeCard?: boolean;
15
+ }
16
+
17
+ const { post, withImage = false, showExcerpt = true, linkWholeCard = true } = Astro.props;
18
+ // A body-less post with an attachment (the typical newsletter shape) has
19
+ // nothing to show on its detail page, so the tile opens the attachment
20
+ // directly instead — same interaction as before the newsletters collection
21
+ // existed. Any post with a body still opens /posts/<slug>/ normally, even if
22
+ // it also carries an attachment (offered there as a separate link).
23
+ const directAttachment = opensAttachmentDirectly(post);
24
+ const href = directAttachment ? post.data.attachment! : `/posts/${post.slug}`;
25
+ const linkTarget = directAttachment ? '_blank' : undefined;
26
+ const linkRel = directAttachment ? 'noopener noreferrer' : undefined;
27
+ const displayTags = post.data.tags;
28
+ const isFresh = isFreshPost(post.data.date);
29
+ const dateLabel = formatDate(post.data.date);
30
+ const hasImage = Boolean(post.data.image);
31
+ const excerpt = showExcerpt ? plainTextExcerpt(post.body ?? '', withImage ? 400 : 280) : '';
32
+ ---
33
+
34
+ <article class="post-card" class:list={{ 'post-card--with-image': withImage }}>
35
+ {isFresh && (
36
+ <span class="new-flag" data-fresh-since={post.data.date.toISOString()}>New</span>
37
+ )}
38
+ {withImage && (
39
+ hasImage ? (
40
+ <img
41
+ src={post.data.image}
42
+ alt={post.data.title}
43
+ class="post-card__img"
44
+ loading="lazy"
45
+ decoding="async"
46
+ />
47
+ ) : (
48
+ <div class="post-card__placeholder" aria-hidden="true"></div>
49
+ )
50
+ )}
51
+ <div class="post-content">
52
+ {displayTags.length > 0 && (
53
+ <div class="post-tags" aria-label="Post tags">
54
+ {displayTags.map((tag) => (
55
+ <span class="post-tag">{tag}</span>
56
+ ))}
57
+ </div>
58
+ )}
59
+
60
+ <h3>{linkWholeCard ? <a href={href} target={linkTarget} rel={linkRel}>{post.data.title}</a> : post.data.title}</h3>
61
+
62
+ {excerpt ? <p>{excerpt}</p> : null}
63
+
64
+ <div class="post-card__footer">
65
+ <time datetime={post.data.date.toISOString()}>{dateLabel}</time>
66
+ {linkWholeCard ? (
67
+ <svg class="post-card__chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
68
+ <polyline points="9 6 15 12 9 18"/>
69
+ </svg>
70
+ ) : (
71
+ <a href={href} target={linkTarget} rel={linkRel} aria-label={`Read ${post.data.title}`}>
72
+ <svg class="post-card__chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
73
+ <polyline points="9 6 15 12 9 18"/>
74
+ </svg>
75
+ </a>
76
+ )}
77
+ </div>
78
+ </div>
79
+ </article>
80
+
81
+ <style>
82
+ .post-card {
83
+ position: relative;
84
+ padding: 0;
85
+ border-bottom: none;
86
+ overflow: hidden;
87
+ display: flex;
88
+ flex-direction: column;
89
+ flex: 1;
90
+ }
91
+
92
+ .post-content {
93
+ display: flex;
94
+ flex-direction: column;
95
+ flex: 1;
96
+ min-height: 0;
97
+ gap: 0.75rem;
98
+ padding: clamp(0.875rem, 2.2vw, 1.25rem);
99
+ color: var(--news-card-body);
100
+ }
101
+
102
+ .post-tags {
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ justify-content: flex-end;
106
+ gap: 0.35rem;
107
+ margin: 0;
108
+ width: 100%;
109
+ }
110
+
111
+ .post-tag {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ font-family: 'Poppins', sans-serif;
115
+ font-size: 0.65rem;
116
+ font-weight: 600;
117
+ text-transform: uppercase;
118
+ letter-spacing: 0.12em;
119
+ color: var(--brown);
120
+ background: rgba(180, 145, 70, 0.22);
121
+ border-radius: 4px;
122
+ padding: 0.15rem 0.45rem;
123
+ line-height: 1.2;
124
+ }
125
+ :global(html[data-theme="dark"]) .post-tag {
126
+ color: var(--text-light);
127
+ background: rgba(255, 255, 255, 0.12);
128
+ }
129
+
130
+ .post-content h3 {
131
+ /* Full title, never truncated — the admin's exact wording is shown
132
+ in full, however many lines it takes. */
133
+ margin-block: 0;
134
+ }
135
+
136
+ /* Stretched-link pattern: the title anchor's ::after is expanded to cover
137
+ the whole card (positioned against .post-card, the nearest positioned
138
+ ancestor), so clicking anywhere on the tile opens the post — no JS
139
+ needed. The footer "Read" link sits above it via z-index so it stays
140
+ independently clickable/focusable. */
141
+ .post-card h3 a {
142
+ color: inherit;
143
+ text-decoration: none;
144
+ }
145
+
146
+ .post-card h3 a::after {
147
+ content: '';
148
+ position: absolute;
149
+ inset: 0;
150
+ z-index: 1;
151
+ }
152
+
153
+ .new-flag {
154
+ position: absolute;
155
+ top: clamp(0.5rem, 1.6vw, 0.75rem);
156
+ left: clamp(0.5rem, 1.6vw, 0.75rem);
157
+ z-index: 2;
158
+ display: inline-block;
159
+ font-family: 'Poppins', sans-serif;
160
+ font-size: 0.6875rem;
161
+ font-weight: 700;
162
+ letter-spacing: 0.12em;
163
+ text-transform: uppercase;
164
+ color: #fff;
165
+ background: var(--accent);
166
+ border-radius: 4px;
167
+ padding: 0.22rem 0.55rem;
168
+ line-height: 1.2;
169
+ transform: rotate(-4deg);
170
+ transform-origin: top left;
171
+ box-shadow:
172
+ 0 2px 6px rgba(0, 0, 0, 0.18),
173
+ 0 1px 2px rgba(0, 0, 0, 0.08);
174
+ pointer-events: none;
175
+ }
176
+ :global(html[data-theme="dark"]) .new-flag {
177
+ background: var(--gold);
178
+ color: #2A1F12;
179
+ }
180
+
181
+ .post-content > p {
182
+ font-size: clamp(0.8125rem, 0.7rem + 0.5vw, 0.9375rem);
183
+ line-height: 1.55;
184
+ display: -webkit-box;
185
+ -webkit-line-clamp: 3;
186
+ -webkit-box-orient: vertical;
187
+ overflow: hidden;
188
+ }
189
+
190
+ /* Date on the left, chevron on the right — chevron keeps the same
191
+ slide-right-on-hover language as the homepage's Latest from the
192
+ Notice rows (.notice-row / .notice-chevron). */
193
+ .post-card__footer {
194
+ display: flex;
195
+ justify-content: space-between;
196
+ align-items: center;
197
+ gap: 0.4rem;
198
+ margin-top: auto;
199
+ }
200
+
201
+ .post-card__footer time {
202
+ flex-shrink: 0;
203
+ }
204
+
205
+ .post-card__footer a {
206
+ position: relative;
207
+ z-index: 2;
208
+ display: inline-flex;
209
+ align-items: center;
210
+ }
211
+
212
+ .post-card__chevron {
213
+ flex-shrink: 0;
214
+ color: var(--muted);
215
+ transition: transform 0.15s ease, color 0.15s ease;
216
+ }
217
+
218
+ /* Decorative in linkWholeCard mode (the real link is the title's
219
+ stretched-link), so it reacts to hover on the whole card. */
220
+ .post-card:hover .post-card__chevron {
221
+ transform: translateX(3px);
222
+ color: var(--gold);
223
+ }
224
+
225
+ /* With-image variant: opt-in via the `withImage` prop, used by the
226
+ /posts/ listing. Image always sits above the text, at every screen
227
+ size. */
228
+ .post-card--with-image .post-card__img,
229
+ .post-card--with-image .post-card__placeholder {
230
+ display: block;
231
+ width: 100%;
232
+ aspect-ratio: 16 / 9;
233
+ border-radius: 10px 10px 0 0;
234
+ margin: 0;
235
+ }
236
+
237
+ .post-card--with-image .post-card__img {
238
+ object-fit: cover;
239
+ object-position: center;
240
+ }
241
+
242
+ .post-card--with-image .post-card__placeholder {
243
+ background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 100%);
244
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
245
+ }
246
+
247
+ :global(html[data-theme="dark"]) .post-card--with-image .post-card__placeholder {
248
+ background: linear-gradient(135deg, #4A6741 0%, #2C3B2A 100%);
249
+ }
250
+
251
+ .post-card--with-image .post-content > p {
252
+ -webkit-line-clamp: 5;
253
+ }
254
+
255
+ .post-card--with-image .post-tags {
256
+ justify-content: flex-start;
257
+ }
258
+
259
+ .post-card--with-image .post-content {
260
+ padding-top: clamp(1.5rem, 3.5vw, 2rem);
261
+ }
262
+ </style>
@@ -0,0 +1,83 @@
1
+ ---
2
+ interface Props {
3
+ availableTags: string[];
4
+ activeTag?: string;
5
+ tagCounts: Record<string, number>;
6
+ totalCount: number;
7
+ }
8
+
9
+ const { availableTags, activeTag = '', tagCounts, totalCount } = Astro.props;
10
+ ---
11
+
12
+ <div class="tag-filters" role="group" aria-label="Filter posts by category">
13
+ <a
14
+ href="/posts/"
15
+ class:list={['tag-filter', { active: !activeTag }]}
16
+ aria-current={!activeTag ? 'true' : undefined}
17
+ >
18
+ All Posts ({totalCount})
19
+ </a>
20
+
21
+ {availableTags.map((tag) => (
22
+ <a
23
+ href={`/posts/tag/${tag}/`}
24
+ class:list={['tag-filter', { active: activeTag === tag }]}
25
+ aria-current={activeTag === tag ? 'true' : undefined}
26
+ >
27
+ {tag} ({tagCounts[tag] ?? 0})
28
+ </a>
29
+ ))}
30
+ </div>
31
+
32
+ <style>
33
+ .tag-filters {
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ gap: 0.55rem;
37
+ }
38
+
39
+ .tag-filter {
40
+ font-family: 'Poppins', sans-serif;
41
+ font-weight: 600;
42
+ font-size: 0.75rem;
43
+ letter-spacing: 0.03em;
44
+ padding: 0.5rem 1rem;
45
+ border-radius: 999px;
46
+ background: rgba(200, 151, 58, 0.1);
47
+ color: var(--fg);
48
+ border: 1px solid transparent;
49
+ text-decoration: none;
50
+ text-transform: capitalize;
51
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
52
+ }
53
+
54
+ .tag-filter:hover {
55
+ border-color: var(--gold);
56
+ color: var(--gold);
57
+ }
58
+
59
+ .tag-filter:focus-visible {
60
+ outline: 2px solid var(--gold);
61
+ outline-offset: 2px;
62
+ }
63
+
64
+ .tag-filter.active {
65
+ background: var(--gold);
66
+ color: #2A1F12;
67
+ }
68
+
69
+ :global([data-theme='dark']) .tag-filter {
70
+ background: rgba(255, 255, 255, 0.06);
71
+ color: var(--text-light);
72
+ }
73
+
74
+ :global([data-theme='dark']) .tag-filter:hover {
75
+ border-color: var(--gold);
76
+ color: var(--gold);
77
+ }
78
+
79
+ :global([data-theme='dark']) .tag-filter.active {
80
+ background: var(--gold);
81
+ color: #2A1F12;
82
+ }
83
+ </style>
@@ -0,0 +1,63 @@
1
+ ---
2
+ /** Secondary/footer logo: theme-aware variants and the link it wraps come
3
+ * from CMS-backed `siteSettings` and are passed in from `Base.astro`. When
4
+ * missing the component falls back to the local `public/uploads` assets so
5
+ * legacy JSON still renders a logo.
6
+ */
7
+ interface Props {
8
+ logoLight?: string;
9
+ logoDark?: string;
10
+ link?: { name: string; url: string };
11
+ }
12
+
13
+ const { logoLight: propLogoLight, logoDark: propLogoDark, link } = Astro.props as Props;
14
+ const linkName = link?.name ?? '';
15
+ const linkUrl = link?.url ?? '';
16
+ const label = linkName ? `${linkName} (opens in new tab)` : '';
17
+
18
+ const srcLight = propLogoLight ?? propLogoDark ?? '';
19
+ const srcDark = propLogoDark ?? propLogoLight ?? '';
20
+ ---
21
+
22
+ <div class="secondary-logo">
23
+ <a href={linkUrl} target="_blank" rel="noopener noreferrer" aria-label={label}>
24
+ <img
25
+ id="secondary-logo-img"
26
+ class="secondary-logo__img"
27
+ src={srcLight}
28
+ data-src-light={srcLight}
29
+ data-src-dark={srcDark}
30
+ alt=""
31
+ width="150"
32
+ height="82"
33
+ loading="lazy"
34
+ decoding="async"
35
+ aria-hidden="true"
36
+ />
37
+ </a>
38
+ </div>
39
+
40
+ <script is:inline define:vars={{ srcLight, srcDark }}>
41
+ (function () {
42
+ var html = document.documentElement;
43
+ var img = document.getElementById('secondary-logo-img');
44
+ if (!img) return;
45
+
46
+ function sync() {
47
+ var t = html.dataset.theme === 'dark' ? 'dark' : 'light';
48
+ var dataLight = img.getAttribute('data-src-light');
49
+ var dataDark = img.getAttribute('data-src-dark');
50
+ if (t === 'dark') {
51
+ img.src = dataDark || dataLight || srcDark;
52
+ } else {
53
+ img.src = dataLight || dataDark || srcLight;
54
+ }
55
+ }
56
+
57
+ sync();
58
+ var colorSchemeMq = window.matchMedia('(prefers-color-scheme: dark)');
59
+ colorSchemeMq.addEventListener('change', sync);
60
+ var observer = new MutationObserver(function () { sync(); });
61
+ observer.observe(html, { attributes: true, attributeFilter: ['data-theme'] });
62
+ })();
63
+ </script>