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.
- package/CHANGELOG.md +50 -0
- package/README.md +86 -3
- package/index.d.ts +16 -5
- package/index.mjs +21 -2
- package/package.json +2 -1
- package/src/components/AppLanding.astro +445 -0
- package/src/components/Bookmark.astro +37 -0
- package/src/components/CardsHome.astro +108 -0
- package/src/components/CourseCard.astro +97 -0
- package/src/components/CourseDetail.astro +116 -0
- package/src/components/CourseIndex.astro +145 -0
- package/src/components/DifficultyStars.astro +34 -0
- package/src/components/Embed.astro +36 -0
- package/src/components/Lead.astro +8 -0
- package/src/components/RelatedCourses.astro +64 -0
- package/src/content.ts +200 -4
- package/src/i18n/ui.ts +33 -0
- package/src/layouts/BaseLayout.astro +36 -4
- package/src/lib/apps.ts +24 -0
- package/src/lib/courses.ts +26 -0
- package/src/lib/home.ts +61 -0
- package/src/lib/sections.ts +15 -2
- package/src/pages/[...lang]/apps/[...id].astro +67 -0
- package/src/pages/[...lang]/course/[...id].astro +35 -0
- package/src/pages/[...lang]/course/category/[id].astro +25 -0
- package/src/pages/[...lang]/course/index.astro +18 -0
- package/src/pages/[...lang]/index.astro +3 -1
- package/src/pages/[...lang]/rss.xml.ts +40 -0
|
@@ -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>
|