stack-site-builder 1.14.0 → 1.16.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,445 @@
1
+ ---
2
+ // Things-style app/product landing, rendered entirely from an `apps` entry's
3
+ // frontmatter (`template: 'landing'`): hero (icon / store buttons / Product
4
+ // Hunt badge), alternating feature rows (with optional device frame and
5
+ // auto-rotating screenshot carousels), a video "themes" showcase, a highlights
6
+ // grid, pricing tiers, a closing CTA and legal links. Sections render only
7
+ // when their data is present, so a minimal landing is just hero + features.
8
+ //
9
+ // All media (icons, screenshots, videos, the frame art) are `public/` paths;
10
+ // `description`-like strings may carry authored <br> tags (rendered as HTML).
11
+ import { site } from '@aas-data/site';
12
+ import type { AppEntry } from '../lib/apps';
13
+ import type { Lang } from '../i18n/ui';
14
+
15
+ interface Props {
16
+ entry: AppEntry;
17
+ lang: Lang;
18
+ }
19
+
20
+ const { entry } = Astro.props;
21
+ const d = entry.data;
22
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
23
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
24
+ const frame = withBase(d.phoneFrameImage);
25
+ // Optional site contact for the legal "support" link (read defensively — not
26
+ // every site declares an email).
27
+ const email = (site as { email?: string }).email;
28
+
29
+ const storeButtons = d.stores
30
+ ? [
31
+ { href: d.stores.appstore, brand: 'App Store', label: d.stores.appstoreLabel },
32
+ { href: d.stores.playstore, brand: 'Google Play', label: d.stores.playstoreLabel },
33
+ ].filter((b) => b.href)
34
+ : [];
35
+
36
+ // Alternation matches the Hugo original: odd rows get the tinted background,
37
+ // even rows put the visual on the left.
38
+ const altBg = (i: number) => i % 2 === 1;
39
+ ---
40
+
41
+ <div class="aas-landing">
42
+ {/* ---- hero ---- */}
43
+ <section
44
+ class:list={[
45
+ 'aas-bleed py-16 text-center',
46
+ d.heroBg && `aas-hero-bg-${d.heroBg}`,
47
+ ]}
48
+ >
49
+ <div class="mx-auto max-w-[75rem] px-5">
50
+ {d.icon && <img src={withBase(d.icon)} alt="" class="mx-auto h-24 w-24 rounded-[22%] shadow-lg" />}
51
+ <h1 class="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">{d.title}</h1>
52
+ {d.subtitle && <p class="mt-3 text-xl text-[var(--aas-muted)]" set:html={d.subtitle} />}
53
+ {
54
+ storeButtons.length > 0 && (
55
+ <div class="mt-7 flex flex-wrap items-start justify-center gap-3">
56
+ {storeButtons.map((b) => (
57
+ <span class="inline-flex flex-col items-center gap-1.5">
58
+ <a
59
+ href={b.href === '#' ? undefined : b.href}
60
+ {...(b.href !== '#' ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
61
+ class:list={[
62
+ 'rounded-full bg-[var(--aas-accent)] px-6 py-2.5 font-semibold text-white no-underline',
63
+ b.href === '#' && 'pointer-events-none opacity-40',
64
+ ]}
65
+ >
66
+ {b.brand}
67
+ </a>
68
+ {b.label && <span class="text-xs text-[var(--aas-muted)]">{b.label}</span>}
69
+ </span>
70
+ ))}
71
+ </div>
72
+ )
73
+ }
74
+ {d.tagline && <p class="mt-5 text-sm text-[var(--aas-muted)]" set:html={d.tagline} />}
75
+ {
76
+ d.productHunt && (
77
+ <p class="mt-6">
78
+ <a href={d.productHunt.url} target="_blank" rel="noopener noreferrer" class="inline-block">
79
+ <img src={d.productHunt.image} alt={d.productHunt.alt} width="250" height="54" loading="lazy" />
80
+ </a>
81
+ </p>
82
+ )
83
+ }
84
+ </div>
85
+ </section>
86
+
87
+ {/* ---- alternating feature rows ---- */}
88
+ {
89
+ d.features.map((f, i) => {
90
+ const imgs = f.images.length > 0 ? f.images : f.image ? [f.image] : [];
91
+ const isSvg = imgs.length === 1 && /\.svg$/i.test(imgs[0]);
92
+ return (
93
+ <section class:list={['aas-bleed py-16', altBg(i) && 'bg-[var(--aas-panel)]']}>
94
+ <div
95
+ class:list={[
96
+ 'mx-auto grid max-w-[75rem] items-center gap-10 px-5 md:grid-cols-2',
97
+ ]}
98
+ >
99
+ <div class:list={[!altBg(i) && 'md:order-2']}>
100
+ {f.label && (
101
+ <p class="text-sm font-semibold tracking-wide text-[var(--aas-accent)] uppercase">{f.label}</p>
102
+ )}
103
+ <h2 class="mt-2 text-3xl font-bold tracking-tight">{f.title}</h2>
104
+ <p class="mt-4 leading-relaxed text-[var(--aas-muted)]" set:html={f.description} />
105
+ </div>
106
+ <div class:list={['flex justify-center', !altBg(i) && 'md:order-1']}>
107
+ {imgs.length > 1 ? (
108
+ <div class="aas-carousel w-full max-w-xs" data-carousel>
109
+ <div class="aas-carousel-track" data-track>
110
+ {imgs.map((src) => (
111
+ <div class="aas-carousel-slide">
112
+ {f.phoneFrame && frame ? (
113
+ <span class="aas-phone">
114
+ <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />
115
+ <img class="aas-phone-screen" src={withBase(src)} alt={f.title} loading="lazy" />
116
+ </span>
117
+ ) : (
118
+ <img src={withBase(src)} alt={f.title} class="w-full rounded-2xl" loading="lazy" />
119
+ )}
120
+ </div>
121
+ ))}
122
+ </div>
123
+ <div class="mt-3 flex justify-center gap-1.5" data-dots />
124
+ </div>
125
+ ) : imgs.length === 1 ? (
126
+ isSvg ? (
127
+ <img src={withBase(imgs[0])} alt={f.title} class="aas-feature-icon" loading="lazy" />
128
+ ) : f.phoneFrame && frame ? (
129
+ <span class="aas-phone max-w-xs">
130
+ <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />
131
+ <img class="aas-phone-screen" src={withBase(imgs[0])} alt={f.title} loading="lazy" />
132
+ </span>
133
+ ) : (
134
+ <img src={withBase(imgs[0])} alt={f.title} class="max-w-full rounded-2xl" loading="lazy" />
135
+ )
136
+ ) : null}
137
+ </div>
138
+ </div>
139
+ </section>
140
+ );
141
+ })
142
+ }
143
+
144
+ {/* ---- themes showcase (video tabs) ---- */}
145
+ {
146
+ d.themes.length > 0 && (
147
+ <section class:list={['aas-bleed py-16 text-center', altBg(d.features.length) && 'bg-[var(--aas-panel)]']}>
148
+ <div class="mx-auto max-w-[75rem] px-5" data-themes>
149
+ {d.themesTitle && <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{d.themesTitle}</h2>}
150
+ {d.themesSubtitle && <p class="mt-2 mb-10 text-[var(--aas-muted)]">{d.themesSubtitle}</p>}
151
+ <div class="mb-8 flex justify-center gap-2">
152
+ {d.themes.map((t, i) => (
153
+ <button
154
+ type="button"
155
+ class="aas-theme-tab rounded-full border border-[var(--aas-border)] px-4 py-2 text-lg"
156
+ data-theme={String(i)}
157
+ aria-pressed={i === 0 ? 'true' : 'false'}
158
+ title={t.name}
159
+ >
160
+ {t.icon ?? String(i + 1)}
161
+ </button>
162
+ ))}
163
+ </div>
164
+ <div class="grid items-center gap-10 md:grid-cols-2">
165
+ <div class="flex justify-center">
166
+ <span class="aas-phone max-w-xs">
167
+ {frame && <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />}
168
+ {d.themes.map((t, i) => (
169
+ <video
170
+ class:list={['aas-phone-screen aas-theme-video', i !== 0 && 'hidden']}
171
+ data-theme={String(i)}
172
+ autoplay={i === 0}
173
+ muted
174
+ playsinline
175
+ poster={withBase(t.poster)}
176
+ >
177
+ <source src={withBase(t.video)} type="video/mp4" />
178
+ </video>
179
+ ))}
180
+ </span>
181
+ </div>
182
+ <div class="text-left">
183
+ {d.themes.map((t, i) => (
184
+ <div class:list={['aas-theme-detail', i !== 0 && 'hidden']} data-theme={String(i)}>
185
+ <h3 class="text-2xl font-bold">{t.name}</h3>
186
+ {t.tabDesc && <p class="mt-1 text-sm text-[var(--aas-accent)]">{t.tabDesc}</p>}
187
+ <p class="mt-4 leading-relaxed text-[var(--aas-muted)]" set:html={t.description} />
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ {d.themesLandscape && (
193
+ <div class="mt-14">
194
+ <h3 class="text-xl font-bold">{d.themesLandscape.title}</h3>
195
+ <p class="mt-1 mb-6 text-[var(--aas-muted)]">{d.themesLandscape.description}</p>
196
+ <img
197
+ src={withBase(d.themesLandscape.image)}
198
+ alt={d.themesLandscape.title}
199
+ class="mx-auto max-w-full rounded-2xl sm:max-w-2xl"
200
+ loading="lazy"
201
+ />
202
+ </div>
203
+ )}
204
+ </div>
205
+ </section>
206
+ )
207
+ }
208
+
209
+ {/* ---- highlights grid ---- */}
210
+ {
211
+ d.highlights.length > 0 && (
212
+ <section class="aas-bleed bg-[var(--aas-panel)] py-16 text-center">
213
+ <div class="mx-auto max-w-[75rem] px-5">
214
+ {d.highlightsTitle && <h2 class="mb-10 text-3xl font-bold tracking-tight sm:text-4xl">{d.highlightsTitle}</h2>}
215
+ <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
216
+ {d.highlights.map((h) => (
217
+ <div class="rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-bg)] p-6">
218
+ {h.icon && <img src={withBase(h.icon)} alt="" class="aas-feature-glyph mx-auto mb-4 h-10 w-10" loading="lazy" />}
219
+ <h3 class="font-semibold">{h.title}</h3>
220
+ <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]" set:html={h.description} />
221
+ </div>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ </section>
226
+ )
227
+ }
228
+
229
+ {/* ---- pricing ---- */}
230
+ {
231
+ d.pricing.length > 0 && (
232
+ <section class="aas-bleed py-16 text-center">
233
+ <div class="mx-auto max-w-[75rem] px-5">
234
+ {d.pricingTitle && <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{d.pricingTitle}</h2>}
235
+ {d.pricingSubtitle && <p class="mt-2 text-[var(--aas-muted)]">{d.pricingSubtitle}</p>}
236
+ {d.pricingNotes.length > 0 && (
237
+ <p class="mt-2 text-sm text-[var(--aas-muted)] opacity-75">
238
+ {d.pricingNotes.map((n, i) => (
239
+ <>
240
+ {i > 0 && <br />}
241
+ {n}
242
+ </>
243
+ ))}
244
+ </p>
245
+ )}
246
+ <div class="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
247
+ {d.pricing.map((p) => (
248
+ <div
249
+ class:list={[
250
+ 'relative rounded-3xl border p-6 text-left',
251
+ p.featured
252
+ ? 'border-[var(--aas-accent)] bg-[var(--aas-panel)] shadow-lg'
253
+ : 'border-[var(--aas-border)] bg-[var(--aas-panel)]',
254
+ ]}
255
+ >
256
+ {p.featured && d.pricingBadge && (
257
+ <span class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-[var(--aas-accent)] px-3 py-0.5 text-xs font-semibold text-white">
258
+ {d.pricingBadge}
259
+ </span>
260
+ )}
261
+ <h3 class="font-semibold">{p.name}</h3>
262
+ <p class="mt-2 text-3xl font-bold">
263
+ {p.price}
264
+ {p.period && <span class="ml-1 text-sm font-normal text-[var(--aas-muted)]">{p.period}</span>}
265
+ </p>
266
+ <ul class="mt-4 space-y-2 text-sm text-[var(--aas-muted)]">
267
+ {p.items.map((it) => (
268
+ <li class="flex gap-2">
269
+ <span aria-hidden="true" class="text-[var(--aas-accent)]">✓</span>
270
+ {it}
271
+ </li>
272
+ ))}
273
+ </ul>
274
+ </div>
275
+ ))}
276
+ </div>
277
+ </div>
278
+ </section>
279
+ )
280
+ }
281
+
282
+ {/* ---- closing CTA ---- */}
283
+ {
284
+ (d.ctaTitle || d.ctaDescription) && (
285
+ <section class="aas-bleed bg-[var(--aas-panel)] py-16 text-center">
286
+ <div class="mx-auto max-w-[75rem] px-5">
287
+ {d.ctaTitle && <h2 class="text-3xl font-bold tracking-tight">{d.ctaTitle}</h2>}
288
+ {d.ctaDescription && <p class="mt-3 text-[var(--aas-muted)]" set:html={d.ctaDescription} />}
289
+ {storeButtons.length > 0 && (
290
+ <div class="mt-7 flex flex-wrap items-start justify-center gap-3">
291
+ {storeButtons.map((b) => (
292
+ <span class="inline-flex flex-col items-center gap-1.5">
293
+ <a
294
+ href={b.href === '#' ? undefined : b.href}
295
+ {...(b.href !== '#' ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
296
+ class:list={[
297
+ 'rounded-full bg-[var(--aas-accent)] px-6 py-2.5 font-semibold text-white no-underline',
298
+ b.href === '#' && 'pointer-events-none opacity-40',
299
+ ]}
300
+ >
301
+ {b.brand}
302
+ </a>
303
+ {b.label && <span class="text-xs text-[var(--aas-muted)]">{b.label}</span>}
304
+ </span>
305
+ ))}
306
+ </div>
307
+ )}
308
+ </div>
309
+ </section>
310
+ )
311
+ }
312
+
313
+ {/* ---- legal links ---- */}
314
+ {
315
+ d.legal && (
316
+ <section class="py-10 text-center text-sm">
317
+ <div class="flex flex-wrap justify-center gap-6 text-[var(--aas-muted)]">
318
+ {d.legal.privacy && <a href="privacy/" class="no-underline hover:text-[var(--aas-text)]">{d.legal.privacy}</a>}
319
+ {d.legal.terms && <a href="terms/" class="no-underline hover:text-[var(--aas-text)]">{d.legal.terms}</a>}
320
+ {d.legal.support && email && (
321
+ <a href={`mailto:${email}`} class="no-underline hover:text-[var(--aas-text)]">{d.legal.support}</a>
322
+ )}
323
+ </div>
324
+ </section>
325
+ )
326
+ }
327
+ </div>
328
+
329
+ <style>
330
+ /* Full-bleed section: escape BaseLayout's centered container. */
331
+ .aas-bleed {
332
+ position: relative;
333
+ left: 50%;
334
+ margin-left: -50vw;
335
+ margin-right: -50vw;
336
+ right: 50%;
337
+ width: 100vw;
338
+ }
339
+ /* Screenshot inside the device-frame art. The frame draws on top; the
340
+ screen sits inset behind it (tune per frame art via site CSS if needed). */
341
+ .aas-phone {
342
+ position: relative;
343
+ display: inline-block;
344
+ }
345
+ .aas-phone-frame {
346
+ position: relative;
347
+ z-index: 2;
348
+ width: 100%;
349
+ pointer-events: none;
350
+ }
351
+ .aas-phone :global(.aas-phone-screen) {
352
+ position: absolute;
353
+ z-index: 1;
354
+ inset: 2.5% 5.5%;
355
+ width: 89%;
356
+ height: 95%;
357
+ object-fit: cover;
358
+ border-radius: 9% / 4.5%;
359
+ }
360
+ .aas-feature-icon {
361
+ width: 10rem;
362
+ opacity: 0.9;
363
+ }
364
+ /* Monochrome icon files follow the text color in dark mode. */
365
+ :root[data-theme='dark'] .aas-feature-glyph,
366
+ :root[data-theme='dark'] .aas-feature-icon {
367
+ filter: invert(1) hue-rotate(180deg);
368
+ }
369
+ .aas-carousel {
370
+ overflow: hidden;
371
+ }
372
+ .aas-carousel-track {
373
+ display: flex;
374
+ transition: transform 0.45s ease;
375
+ }
376
+ .aas-carousel-slide {
377
+ flex: 0 0 100%;
378
+ display: flex;
379
+ justify-content: center;
380
+ }
381
+ .aas-theme-tab[aria-pressed='true'] {
382
+ border-color: var(--aas-accent);
383
+ background: var(--aas-tint);
384
+ }
385
+ </style>
386
+
387
+ <script>
388
+ // Screenshot carousels: dots + 4s auto-advance.
389
+ document.querySelectorAll<HTMLElement>('[data-carousel]').forEach((carousel) => {
390
+ const track = carousel.querySelector<HTMLElement>('[data-track]');
391
+ const dots = carousel.querySelector<HTMLElement>('[data-dots]');
392
+ const total = track?.children.length ?? 0;
393
+ if (!track || !dots || total < 2) return;
394
+ let current = 0;
395
+ const buttons: HTMLButtonElement[] = [];
396
+ const goTo = (idx: number) => {
397
+ current = idx;
398
+ track.style.transform = `translateX(-${current * 100}%)`;
399
+ buttons.forEach((b, j) => b.setAttribute('aria-pressed', String(j === current)));
400
+ };
401
+ for (let i = 0; i < total; i++) {
402
+ const b = document.createElement('button');
403
+ b.type = 'button';
404
+ b.className =
405
+ 'h-2 w-2 rounded-full border border-[var(--aas-border)] aria-[pressed=true]:bg-[var(--aas-accent)] aria-[pressed=true]:border-[var(--aas-accent)]';
406
+ b.setAttribute('aria-pressed', String(i === 0));
407
+ b.setAttribute('aria-label', String(i + 1));
408
+ b.addEventListener('click', () => goTo(i));
409
+ dots.appendChild(b);
410
+ buttons.push(b);
411
+ }
412
+ setInterval(() => goTo((current + 1) % total), 4000);
413
+ });
414
+
415
+ // Themes showcase: tab switching; a finished video advances to the next
416
+ // theme after a beat.
417
+ document.querySelectorAll<HTMLElement>('[data-themes]').forEach((root) => {
418
+ const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('.aas-theme-tab'));
419
+ const videos = Array.from(root.querySelectorAll<HTMLVideoElement>('.aas-theme-video'));
420
+ const details = Array.from(root.querySelectorAll<HTMLElement>('.aas-theme-detail'));
421
+ if (tabs.length < 2) return;
422
+ const switchTo = (idx: number) => {
423
+ const s = String(idx);
424
+ tabs.forEach((t) => t.setAttribute('aria-pressed', String(t.dataset.theme === s)));
425
+ details.forEach((el) => el.classList.toggle('hidden', el.dataset.theme !== s));
426
+ videos.forEach((v) => {
427
+ const on = v.dataset.theme === s;
428
+ v.classList.toggle('hidden', !on);
429
+ if (on) {
430
+ v.currentTime = 0;
431
+ v.play().catch(() => {});
432
+ } else {
433
+ v.pause();
434
+ }
435
+ });
436
+ };
437
+ tabs.forEach((t) => t.addEventListener('click', () => switchTo(Number(t.dataset.theme))));
438
+ videos.forEach((v) =>
439
+ v.addEventListener('ended', () => {
440
+ const cur = Number(v.dataset.theme);
441
+ setTimeout(() => switchTo((cur + 1) % tabs.length), 2000);
442
+ }),
443
+ );
444
+ });
445
+ </script>
@@ -0,0 +1,37 @@
1
+ ---
2
+ // Link-preview card for MDX bodies (a Hugo `bookmark` shortcode successor):
3
+ // <Bookmark url="https://…" title="…" description="…" />
4
+ import ArrowUpRight from './ArrowUpRight.astro';
5
+
6
+ interface Props {
7
+ url: string;
8
+ title: string;
9
+ description?: string;
10
+ /** Host label under the title; derived from `url` when omitted. */
11
+ host?: string;
12
+ }
13
+
14
+ const { url, title, description, host = new URL(url).host } = Astro.props;
15
+ ---
16
+
17
+ <a
18
+ href={url}
19
+ target="_blank"
20
+ rel="noopener noreferrer"
21
+ class="not-prose aas-lift my-4 block rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-4 no-underline"
22
+ >
23
+ <span class="flex items-center justify-between gap-3">
24
+ <span class="min-w-0">
25
+ <span class="block truncate font-semibold text-[var(--aas-text)]">{title}</span>
26
+ {
27
+ description && (
28
+ <span class="mt-1 line-clamp-2 block text-sm leading-relaxed text-[var(--aas-muted)]">
29
+ {description}
30
+ </span>
31
+ )
32
+ }
33
+ <span class="mt-1.5 block text-xs text-[var(--aas-muted)] opacity-75">{host}</span>
34
+ </span>
35
+ <ArrowUpRight size={18} class="shrink-0 text-[var(--aas-muted)]" />
36
+ </span>
37
+ </a>
@@ -0,0 +1,108 @@
1
+ ---
2
+ // The data-driven "cards" homepage (see src/lib/home.ts): hero with the site
3
+ // icon/name, a stack of wide link cards (apps, sections, external links — the
4
+ // Things-style studio home), and a closing CTA banner. Everything renders
5
+ // from `site.home`; sections without data simply don't render.
6
+ import { site } from '@aas-data/site';
7
+ import { home, loc, locList } from '../lib/home';
8
+ import { useTranslations, type Lang } from '../i18n/ui';
9
+
10
+ interface Props {
11
+ lang: Lang;
12
+ }
13
+
14
+ const { lang } = Astro.props;
15
+ const t = useTranslations(lang);
16
+ const h = home;
17
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
18
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
19
+
20
+ const heroTitle = loc(h?.hero?.title, lang) ?? site.name;
21
+ const heroSubtitle = loc(h?.hero?.subtitle, lang) ?? t('site.tagline');
22
+ const extAttrs = (external?: boolean) =>
23
+ external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
24
+ ---
25
+
26
+ <section class="py-14 text-center">
27
+ {h?.hero?.icon && <img src={withBase(h.hero.icon)} alt="" class="mx-auto h-20 w-20 rounded-[22%] shadow-md" />}
28
+ <h1 class="mt-5 text-4xl font-bold tracking-tight sm:text-5xl">{heroTitle}</h1>
29
+ <p class="mx-auto mt-4 max-w-2xl text-xl text-[var(--aas-muted)]" set:html={heroSubtitle} />
30
+ </section>
31
+
32
+ {
33
+ (h?.cards?.length ?? 0) > 0 && (
34
+ <section class="aas-bleed bg-[var(--aas-panel)] py-14">
35
+ <div class="mx-auto max-w-[75rem] px-5">
36
+ {h?.cardsTitle && <h2 class="mb-6 text-3xl font-bold tracking-tight">{loc(h.cardsTitle, lang)}</h2>}
37
+ <div class="flex flex-col gap-5">
38
+ {h?.cards?.map((c) => (
39
+ <a
40
+ href={c.href}
41
+ {...extAttrs(c.external)}
42
+ class="aas-lift flex items-center gap-6 rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-bg)] p-6 no-underline"
43
+ >
44
+ {c.icon && (
45
+ <img
46
+ src={withBase(c.icon)}
47
+ alt=""
48
+ class:list={['h-20 w-20 shrink-0', c.rounded ? 'rounded-[22%]' : 'rounded-xl']}
49
+ loading="lazy"
50
+ />
51
+ )}
52
+ <span class="min-w-0">
53
+ <span class="block text-xl font-semibold text-[var(--aas-text)]">{loc(c.name, lang)}</span>
54
+ {c.description && (
55
+ <span
56
+ class="mt-1 block leading-relaxed text-[var(--aas-muted)]"
57
+ set:html={loc(c.description, lang)}
58
+ />
59
+ )}
60
+ {locList(c.tags, lang).length > 0 && (
61
+ <span class="mt-3 flex flex-wrap gap-1.5">
62
+ {locList(c.tags, lang).map((tag) => (
63
+ <span class="rounded-full border border-[var(--aas-border)] px-2 py-0.5 text-xs text-[var(--aas-muted)]">
64
+ {tag}
65
+ </span>
66
+ ))}
67
+ </span>
68
+ )}
69
+ </span>
70
+ </a>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ </section>
75
+ )
76
+ }
77
+
78
+ {
79
+ h?.cta && (
80
+ <section class="py-16 text-center">
81
+ {h.cta.title && <h2 class="text-3xl font-bold tracking-tight">{loc(h.cta.title, lang)}</h2>}
82
+ {h.cta.description && (
83
+ <p class="mx-auto mt-3 max-w-2xl text-[var(--aas-muted)]" set:html={loc(h.cta.description, lang)} />
84
+ )}
85
+ {h.cta.button && (
86
+ <a
87
+ href={h.cta.button.href}
88
+ {...extAttrs(h.cta.button.external)}
89
+ class="mt-7 inline-block rounded-full bg-[var(--aas-accent)] px-7 py-3 font-semibold text-white no-underline"
90
+ >
91
+ {loc(h.cta.button.label, lang)}
92
+ </a>
93
+ )}
94
+ </section>
95
+ )
96
+ }
97
+
98
+ <style>
99
+ /* Full-bleed section: escape BaseLayout's centered container. */
100
+ .aas-bleed {
101
+ position: relative;
102
+ left: 50%;
103
+ margin-left: -50vw;
104
+ margin-right: -50vw;
105
+ right: 50%;
106
+ width: 100vw;
107
+ }
108
+ </style>
@@ -0,0 +1,97 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { useTranslations, type Lang } from '../i18n/ui';
5
+ import { formatDate, isoDate } from '../lib/dates';
6
+ import { inlineMd } from '../lib/inline-md';
7
+ import DifficultyStars from './DifficultyStars.astro';
8
+ import type { ImageMetadata } from 'astro';
9
+
10
+ interface Props {
11
+ lang: Lang;
12
+ slug: string;
13
+ title: string;
14
+ description: string;
15
+ tags: string[];
16
+ image?: ImageMetadata;
17
+ imageAlt?: string;
18
+ level?: number;
19
+ hours?: string;
20
+ version?: string;
21
+ date: Date;
22
+ updated?: Date;
23
+ /** Manual sort key (`order` frontmatter) — beats date recency in the list sort. */
24
+ order?: string;
25
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
26
+ isPrivate?: boolean;
27
+ teaser?: string;
28
+ }
29
+
30
+ const {
31
+ lang,
32
+ slug,
33
+ title,
34
+ description,
35
+ tags,
36
+ image,
37
+ imageAlt,
38
+ level,
39
+ hours,
40
+ version,
41
+ date,
42
+ updated,
43
+ order,
44
+ isPrivate = false,
45
+ teaser,
46
+ } = Astro.props;
47
+ const t = useTranslations(lang);
48
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
49
+ // ListControls' "recent" sort compares data-date strings descending. Putting
50
+ // `order` in the key keeps the manual curation working client-side too: order
51
+ // keys ("2601-01") outrank ISO dates ("20…") lexicographically, matching the
52
+ // server-side getCourses() order — so the on-load sort causes no reflow.
53
+ const sortKey = order ?? isoDate(updated ?? date);
54
+ const shownDate = updated ?? date;
55
+ ---
56
+
57
+ <a
58
+ href={getRelativeLocaleUrl(lang, `course/${slug}/`)}
59
+ data-name={title.toLowerCase()}
60
+ data-date={sortKey}
61
+ class="aas-lift block overflow-hidden rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
62
+ >
63
+ {image && <Image src={image} alt={imageAlt ?? title} class="aspect-[16/9] w-full object-cover" />}
64
+ <div class="p-5">
65
+ <p class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--aas-muted)]">
66
+ {level && <DifficultyStars lang={lang} level={level} />}
67
+ {
68
+ hours && (
69
+ <span class="inline-flex items-center gap-1" title={t('course.hours')}>
70
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
71
+ {hours}
72
+ </span>
73
+ )
74
+ }
75
+ <time datetime={isoDate(shownDate)}>{formatDate(shownDate, lang)}</time>
76
+ {version && <span>{t('meta.docVersion')} {version}</span>}
77
+ </p>
78
+ <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">
79
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
80
+ </h3>
81
+ <p
82
+ class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
83
+ set:html={inlineMd(shownDesc)}
84
+ />
85
+ {
86
+ tags.length > 0 && (
87
+ <div class="mt-3 flex flex-wrap gap-1.5">
88
+ {tags.slice(0, 4).map((tag) => (
89
+ <span class="rounded-full border border-[var(--aas-border)] px-2 py-0.5 text-xs text-[var(--aas-muted)]">
90
+ {tag}
91
+ </span>
92
+ ))}
93
+ </div>
94
+ )
95
+ }
96
+ </div>
97
+ </a>