Simple homepage

This commit is contained in:
2026-09-24 14:37:40 +02:00
parent d4769e9a33
commit 6897103041
11 changed files with 154 additions and 48 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
Static Astro site with a git-based CMS (Sveltia). Code lives here; editorial Static Astro site with a git-based CMS (Sveltia). Code lives here; editorial
content lives in the separate [`ctao/content`](https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content) content lives in the separate [`ctao/content`](https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content)
repository. Four Example pages are editable; all other pages are placeholders. repository. The Home page and four Example pages are editable; the rest are placeholders.
- Live demo: https://astro.isl-dev.grid.cyfronet.pl - Live demo: https://astro.isl-dev.grid.cyfronet.pl
- Editor: https://astro.isl-dev.grid.cyfronet.pl/admin/ (Gitea sign-in) - Editor: https://astro.isl-dev.grid.cyfronet.pl/admin/ (Gitea sign-in)
@@ -42,7 +42,7 @@ Server setup is in [deploy/README.md](deploy/README.md).
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `src/layouts/Base.astro` | Header, navigation, footer, page shell | | `src/layouts/Base.astro` | Header, navigation, footer, page shell |
| `src/pages/example/[slug].astro` | Template for the editable Example pages | | `src/layouts/ContentPage.astro` | Layout of the editable pages (Home and Examples) |
| `src/data/examples.json` | Example pages: menu labels, order, slugs | | `src/data/examples.json` | Example pages: menu labels, order, slugs |
| `src/data/placeholders.json` | Placeholder pages | | `src/data/placeholders.json` | Placeholder pages |
| `src/styles/global.css` | All site styles | | `src/styles/global.css` | All site styles |
+24
View File
@@ -44,6 +44,30 @@ media_libraries:
optimize: true optimize: true
collections: collections:
- name: home
label: "Home page"
files:
- name: home
label: "Home page"
file: "pages/home.md"
format: frontmatter
fields:
- { name: title, label: "Title", widget: string }
- { name: introduction, label: "Introduction", widget: text, required: false }
- name: images
label: "Images"
label_singular: "image"
widget: list
required: false
max: 2
hint: "Up to two images, shown side by side."
fields:
- { name: image, label: "Image", widget: image }
- { name: alt, label: "Alternative text", widget: string, required: false, hint: "Describe the image; leave empty only for decorative images." }
- { name: caption, label: "Caption", widget: string, required: false }
- { name: body, label: "Body", widget: markdown }
- { name: buttonLabel, label: "Button label", widget: string, required: false }
- { name: buttonUrl, label: "Button destination", widget: string, required: false, hint: "A site path such as /example/lorem-ipsum/, or an https:// URL." }
- name: examples - name: examples
label: "Example pages" label: "Example pages"
files: files:
+26 -1
View File
@@ -279,6 +279,24 @@ button:hover .arrow {
color: var(--text-muted); color: var(--text-muted);
} }
/* Two photos side by side, pulled up over the hero together */
.page-figures {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin: -9rem 0 4rem;
}
.page-figures .page-figure {
margin: 0;
}
/* Same shape for both photos, whatever the uploads' proportions */
.page-figures img {
aspect-ratio: 3 / 2;
object-fit: cover;
}
/* Prose /* Prose
========================================================================== */ ========================================================================== */
@@ -450,11 +468,18 @@ button:hover .arrow {
padding-bottom: 4rem; padding-bottom: 4rem;
} }
.page-figure { .page-figure,
.page-figures {
margin-top: -6rem; margin-top: -6rem;
} }
} }
@media (max-width: 600px) {
.page-figures {
grid-template-columns: 1fr;
}
}
/* Preferences /* Preferences
========================================================================== */ ========================================================================== */
+1 -1
View File
@@ -43,7 +43,7 @@ for (const { from, to } of targets) {
} }
const pages = join(root, 'src/content/pages'); const pages = join(root, 'src/content/pages');
for (const { slug } of examples) { for (const slug of ['home', ...examples.map(({ slug }) => slug)]) {
if (!existsSync(join(pages, `${slug}.md`))) { if (!existsSync(join(pages, `${slug}.md`))) {
throw new Error(`Missing ${join(pages, `${slug}.md`)}. Check the content checkout, or run npm run content:link -- <path>.`); throw new Error(`Missing ${join(pages, `${slug}.md`)}. Check the content checkout, or run npm run content:link -- <path>.`);
} }
+4 -4
View File
@@ -1,16 +1,16 @@
--- ---
interface Props { title: string; introduction?: string; example?: boolean; hasImage?: boolean } interface Props { title: string; introduction?: string; example?: boolean; breadcrumb?: boolean; hasImage?: boolean }
const { title, introduction, example = false, hasImage = false } = Astro.props; const { title, introduction, example = false, breadcrumb = true, hasImage = false } = Astro.props;
--- ---
<section class:list={['page-hero', { 'page-hero--compact': !hasImage }]}> <section class:list={['page-hero', { 'page-hero--compact': !hasImage }]}>
<div class="container"> <div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb"> {breadcrumb && <nav class="breadcrumb" aria-label="Breadcrumb">
<ol> <ol>
{Astro.url.pathname !== '/' && <li><a href="/">Home</a></li>} {Astro.url.pathname !== '/' && <li><a href="/">Home</a></li>}
{example && <li>Example</li>} {example && <li>Example</li>}
<li aria-current="page">{title}</li> <li aria-current="page">{title}</li>
</ol> </ol>
</nav> </nav>}
<h1 class="page-hero__title">{title}</h1> <h1 class="page-hero__title">{title}</h1>
{introduction && <p class="page-hero__lead">{introduction}</p>} {introduction && <p class="page-hero__lead">{introduction}</p>}
</div> </div>
+9 -2
View File
@@ -8,17 +8,24 @@ const destination = z.string().refine(
'Use a site path, https:// URL, mailto: address, or fragment.', 'Use a site path, https:// URL, mailto: address, or fragment.',
); );
const pages = defineCollection({ const pages = defineCollection({
// Only these four files can become public content pages. // Only the Home page and the four Example files can become public pages.
loader: glob({ loader: glob({
pattern: `{${examples.map(({ slug }) => slug).join(',')}}.md`, pattern: `{home,${examples.map(({ slug }) => slug).join(',')}}.md`,
base: './src/content/pages', base: './src/content/pages',
}), }),
schema: z.object({ schema: z.object({
title: z.string().min(1), title: z.string().min(1),
introduction: z.string().optional(), introduction: z.string().optional(),
// Example pages: one photo
image: z.string().optional(), image: z.string().optional(),
imageAlt: z.string().optional(), imageAlt: z.string().optional(),
imageCaption: z.string().optional(), imageCaption: z.string().optional(),
// Home: up to two photos side by side
images: z.array(z.object({
image: z.string().min(1),
alt: z.string().optional(),
caption: z.string().optional(),
})).max(2).optional(),
buttonLabel: z.string().optional(), buttonLabel: z.string().optional(),
buttonUrl: destination.optional(), buttonUrl: destination.optional(),
}), }),
+36
View File
@@ -0,0 +1,36 @@
---
// Editable content page: hero, one photo (Example pages) or two side by side
// (Home), Markdown body and optional button. The CMS preview template in
// src/pages/admin/index.astro mirrors this markup.
import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content';
import Base from './Base.astro';
import PageHero from '../components/PageHero.astro';
interface Props { page: CollectionEntry<'pages'>; example?: boolean; breadcrumb?: boolean }
const { page, example = false, breadcrumb = true } = Astro.props;
const { Content } = await render(page);
const { title, introduction, image, imageAlt, imageCaption, images, buttonLabel, buttonUrl } = page.data;
const photos = images ?? (image ? [{ image, alt: imageAlt, caption: imageCaption }] : []);
---
<Base title={example ? title : undefined} description={introduction}>
<PageHero title={title} introduction={introduction} example={example} breadcrumb={breadcrumb} hasImage={photos.length > 0} />
{photos.length > 0 && <div class="container">
<div class:list={{ 'page-figures': photos.length > 1 }}>
{photos.map(({ image, alt, caption }) => (
<figure class="page-figure">
<img src={image} alt={alt || ''} />
{caption && <figcaption>{caption}</figcaption>}
</figure>
))}
</div>
</div>}
<article class:list={['container', 'prose', { 'prose--no-image': photos.length === 0 }]}>
<Content />
{buttonLabel && buttonUrl && <p>
<a class="btn btn--primary" href={buttonUrl}>
<span class="btn__label">{buttonLabel}</span><span class="arrow" aria-hidden="true">→</span>
</a>
</p>}
</article>
</Base>
+17 -13
View File
@@ -36,9 +36,9 @@ const previewNames = examples.map(({ slug }) => slug);
window.CMS?.registerPreviewStyle?.('/admin/preview.css'); window.CMS?.registerPreviewStyle?.('/admin/preview.css');
</script> </script>
<script is:inline define:vars={{ previewNames }}> <script is:inline define:vars={{ previewNames }}>
// Preview pane: the same markup as src/pages/example/[slug].astro and // Preview pane: the same markup as src/layouts/ContentPage.astro and
// PageHero.astro, without field labels. Sveltia looks templates up by // PageHero.astro, without field labels. Sveltia looks templates up by
// file name for file collections, so register one per Example page. // file name for file collections, so register one per page.
const { h, rf } = window; const { h, rf } = window;
// Our own <img>: Sveltia's image widget keeps the image transparent until // Our own <img>: Sveltia's image widget keeps the image transparent until
// a visibility check that does not complete inside a custom template. // a visibility check that does not complete inside a custom template.
@@ -56,37 +56,41 @@ const previewNames = examples.map(({ slug }) => slug);
retry(10); retry(10);
}, },
}); });
const PagePreview = ({ entry, widgetFor, getAsset }) => { const PagePreview = ({ entry, widgetFor, getAsset, home = false }) => {
const get = (key) => entry.getIn(['data', key]); const get = (key) => entry.getIn(['data', key]);
const title = get('title') || ''; const title = get('title') || '';
const introduction = get('introduction'); const introduction = get('introduction');
const image = get('image');
const imageAlt = get('imageAlt') || '';
const imageCaption = get('imageCaption');
const buttonLabel = get('buttonLabel'); const buttonLabel = get('buttonLabel');
const buttonUrl = get('buttonUrl'); const buttonUrl = get('buttonUrl');
// Home: a list of up to two photos; Example pages: a single photo
const photos = home
? (get('images')?.toJS?.() ?? []).filter((item) => item?.image)
: get('image') ? [{ image: get('image'), alt: get('imageAlt'), caption: get('imageCaption') }] : [];
return h(rf, null, return h(rf, null,
h('section', { className: image ? 'page-hero' : 'page-hero page-hero--compact' }, h('section', { className: photos.length ? 'page-hero' : 'page-hero page-hero--compact' },
h('div', { className: 'container' }, h('div', { className: 'container' },
h('nav', { className: 'breadcrumb', 'aria-label': 'Breadcrumb' }, !home && h('nav', { className: 'breadcrumb', 'aria-label': 'Breadcrumb' },
h('ol', null, h('ol', null,
h('li', null, h('a', { href: '/' }, 'Home')), h('li', null, h('a', { href: '/' }, 'Home')),
h('li', null, 'Example'), h('li', null, 'Example'),
h('li', { 'aria-current': 'page' }, title))), h('li', { 'aria-current': 'page' }, title))),
h('h1', { className: 'page-hero__title' }, title), h('h1', { className: 'page-hero__title' }, title),
introduction && h('p', { className: 'page-hero__lead' }, introduction))), introduction && h('p', { className: 'page-hero__lead' }, introduction))),
image && h('div', { className: 'container' }, photos.length > 0 && h('div', { className: 'container' },
h('figure', { className: 'page-figure' }, h('div', { className: photos.length > 1 ? 'page-figures' : undefined },
h(PreviewImage, { key: image, asset: getAsset(image), fallback: image, alt: imageAlt }), photos.map(({ image, alt, caption }, index) => h('figure', { className: 'page-figure', key: index },
imageCaption && h('figcaption', null, imageCaption))), h(PreviewImage, { key: image, asset: getAsset(image), fallback: image, alt: alt || '' }),
h('article', { className: image ? 'container prose' : 'container prose prose--no-image' }, caption && h('figcaption', null, caption))))),
h('article', { className: photos.length ? 'container prose' : 'container prose prose--no-image' },
widgetFor('body'), widgetFor('body'),
buttonLabel && buttonUrl && h('p', null, buttonLabel && buttonUrl && h('p', null,
h('a', { className: 'btn btn--primary', href: buttonUrl }, h('a', { className: 'btn btn--primary', href: buttonUrl },
h('span', { className: 'btn__label' }, buttonLabel), h('span', { className: 'btn__label' }, buttonLabel),
h('span', { className: 'arrow', 'aria-hidden': 'true' }, '→'))))); h('span', { className: 'arrow', 'aria-hidden': 'true' }, '→')))));
}; };
const HomePreview = (props) => h(PagePreview, { ...props, home: true });
if (window.CMS?.registerPreviewTemplate && h) { if (window.CMS?.registerPreviewTemplate && h) {
window.CMS.registerPreviewTemplate('home', HomePreview);
for (const name of previewNames) window.CMS.registerPreviewTemplate(name, PagePreview); for (const name of previewNames) window.CMS.registerPreviewTemplate(name, PagePreview);
} }
</script> </script>
+3 -22
View File
@@ -1,7 +1,6 @@
--- ---
import { getCollection, render } from 'astro:content'; import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro'; import ContentPage from '../../layouts/ContentPage.astro';
import PageHero from '../../components/PageHero.astro';
import examples from '../../data/examples.json'; import examples from '../../data/examples.json';
export async function getStaticPaths() { export async function getStaticPaths() {
@@ -13,23 +12,5 @@ export async function getStaticPaths() {
}); });
} }
const { page } = Astro.props; const { page } = Astro.props;
const { Content } = await render(page);
const { title, introduction, image, imageAlt, imageCaption, buttonLabel, buttonUrl } = page.data;
--- ---
<Base title={title} description={introduction}> <ContentPage page={page} example />
<PageHero title={title} introduction={introduction} example hasImage={Boolean(image)} />
{image && <div class="container">
<figure class="page-figure">
<img src={image} alt={imageAlt || ''} />
{imageCaption && <figcaption>{imageCaption}</figcaption>}
</figure>
</div>}
<article class:list={['container', 'prose', { 'prose--no-image': !image }]}>
<Content />
{buttonLabel && buttonUrl && <p>
<a class="btn btn--primary" href={buttonUrl}>
<span class="btn__label">{buttonLabel}</span><span class="arrow" aria-hidden="true">→</span>
</a>
</p>}
</article>
</Base>
+6 -2
View File
@@ -1,4 +1,8 @@
--- ---
import Placeholder from '../layouts/Placeholder.astro'; import { getEntry } from 'astro:content';
import ContentPage from '../layouts/ContentPage.astro';
const page = await getEntry('pages', 'home');
if (!page) throw new Error('Missing Home page: pages/home.md. Check the content checkout (npm run content:link -- <path>).');
--- ---
<Placeholder title="CTAO Science Portal" /> <ContentPage page={page} breadcrumb={false} />
+26 -1
View File
@@ -690,6 +690,24 @@ button:hover .arrow {
color: var(--text-muted); color: var(--text-muted);
} }
/* Two photos side by side (Home), pulled up over the hero together */
.page-figures {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin: -9rem 0 4rem;
}
.page-figures .page-figure {
margin: 0;
}
/* Same shape for both photos, whatever the uploads' proportions */
.page-figures img {
aspect-ratio: 3 / 2;
object-fit: cover;
}
/* Prose /* Prose
========================================================================== */ ========================================================================== */
@@ -916,11 +934,18 @@ main {
padding-bottom: 4rem; padding-bottom: 4rem;
} }
.page-figure { .page-figure,
.page-figures {
margin-top: -6rem; margin-top: -6rem;
} }
} }
@media (max-width: 600px) {
.page-figures {
grid-template-columns: 1fr;
}
}
/* Preferences /* Preferences
========================================================================== */ ========================================================================== */