CTAO Science Portal: static Astro site with git-based CMS (Sveltia + Gitea)

Portal code only; editorial content lives in the ctao/content repo on the
demo machine's Gitea and is overlaid at build time (see README.md and
deploy/README.md). Live demo: https://astro.isl-dev.grid.cyfronet.pl
This commit is contained in:
2026-09-08 13:49:21 +02:00
parent 845c1ccf0d
commit 2817353c46
50 changed files with 11564 additions and 44 deletions
+16
View File
@@ -0,0 +1,16 @@
---
// The one news card — used by the home page and the /news archive.
// `featured` (archive page 1, first item) switches the wide lead variant and
// eager-loads its cover. Cover alt stays empty by design: the image is
// decorative next to the always-visible title.
import { fmt, iso, readMin } from '../lib/news.js';
const { post, featured = false } = Astro.props;
---
<article class={featured ? 'card card--featured' : 'card'}>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading={featured ? 'eager' : 'lazy'} />}
<div class="body">
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
</div>
</article>
+41
View File
@@ -0,0 +1,41 @@
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
// News/articles live as Markdown files in src/content/news/*.md.
// This is exactly what Sveltia CMS edits — the CMS writes these files, Astro
// renders them to static HTML at build time.
const news = defineCollection({
// Top-level only ('*.md', not '**'): entry ids feed a non-rest [slug]
// route, and an id from a subfolder would contain '/' and break it.
loader: glob({ pattern: '*.md', base: './src/content/news' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
category: z.string().default('news'),
author: z.string().default('CTAO'),
// Public-path string, e.g. "/uploads/foo.jpg" — media lives in the
// content repo's uploads/, overlaid to public/uploads at build time,
// which Astro's image() helper can't validate (src/ only).
cover: z.string().optional(),
// BCP-47 tag when an article is not in English (e.g. "pl") — set as lang
// on the <article> element so screen readers pick the right voice.
lang: z.string().optional(),
draft: z.boolean().default(false),
}),
});
// Static portal pages (privacy, disclaimer, contact…) — separate collection
// because the content type differs (no dates/covers/categories), and editors
// get a distinct "Pages" section in the CMS.
const pages = defineCollection({
// Top-level only, same reason as news: the [slug] route is non-rest.
loader: glob({ pattern: '*.md', base: './src/content/pages' }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
}),
});
export const collections = { news, pages };
+191
View File
@@ -0,0 +1,191 @@
---
import '../styles/global.css';
// `ambient` opts a page into the whole-page drifting brand wash (home only)
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal demo', ambient = false, type = 'website', image } = Astro.props;
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
// blog template). `site` comes from astro.config.mjs and is always set.
const site = Astro.site;
const canonical = new URL(Astro.url.pathname, site);
// SVG covers never reach og:image — link-preview crawlers (Slack/Teams/
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
const path = Astro.url.pathname;
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
// Order: serial-position effect (NN/g) — News (universal entry) first, Support
// (help convention) last; Data/Proposals by task frequency; Dashboard is the
// personal area, placed late next to the Sign-in cluster. External systems
// built by other teams are links out, clearly marked.
const links = [
{ href: '/news', label: 'News' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/proposals', label: 'Proposals' },
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/support', label: 'Support' },
];
---
<!doctype html>
{/* Always "en": the chrome (nav/footer) is English; a non-English article
sets lang on its <article> element instead ([slug].astro). */}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{/* Social cards (Open Graph + Twitter) — article cover when present, brand card otherwise */}
<meta property="og:site_name" content="CTAO Science Portal" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="alternate" type="application/rss+xml" title="CTAO Science Portal News" href={new URL('/rss.xml', site)} />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#00004a" />
{/* The home hero photo is a CSS background, which browsers discover late —
preloading it moves the page's LCP element to the front of the queue. */}
{path === '/' && <link rel="preload" as="image" href="/brand/hero.jpg" fetchpriority="high" />}
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines).
Both ship inlined as data: URIs in the render-blocking stylesheet —
no font request, no swap (see global.css @font-face). */}
</head>
<body class={ambient ? 'has-ambient' : undefined}>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<div class="container nav">
<a class="brand" href="/" aria-label="CTAO Science Portal, home">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<span class="brand-sub">Science&nbsp;Portal</span>
</a>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
<a class="navlink external" href={l.href} target="_blank" rel="noopener">{l.label}</a>
) : (
<a class="navlink" href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
</nav>
<span class="spacer"></span>
{/* Site search. Desktop ≥1025px: a VISIBLE quiet field — NN/g's
magnifying-glass-icon research: hiding search behind an icon
measurably reduces discoverability and usage; a visible input is
recommended when search matters (200+ articles, spec-required).
Plain GET form (works without JS); suggestions via search-client.js. */}
<form class="nav-search" role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="hdr-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="hdr-q" name="q" type="search" placeholder="Search…" autocomplete="off" />
<ul class="suggest" id="hdr-suggest"></ul>
<p class="sr-only" id="hdr-suggest-status" role="status" aria-live="polite"></p>
</form>
{/* ≤1024px: the magnifier reveals a search row under the header (NN/g
mobile-search pattern). name="header-panel" pairs it with the menu as
a native exclusive group — opening one closes the other, no JS. */}
<details class="search-pop" name="header-panel">
<summary aria-label="Search">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
</summary>
<form role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="pop-q">Search news</label>
<input id="pop-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
<ul class="suggest" id="pop-suggest"></ul>
<p class="sr-only" id="pop-suggest-status" role="status" aria-live="polite"></p>
</form>
</details>
<a class="login-mock" href="/login">Sign in</a>
{/* Mobile disclosure menu — native <details>/<summary>, no JS; desktop hides it via CSS */}
<details class="menu" name="header-panel">
<summary aria-label="Menu">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path class="i-menu" d="M3 6h18M3 12h18M3 18h18" /><path class="i-close" d="M5 5l14 14M19 5 5 19" /></svg>
<span class="menu-label">Menu</span>
</summary>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
<a class="external" href={l.href} target="_blank" rel="noopener">{l.label}</a>
) : (
<a href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
</nav>
</details>
</div>
</header>
{/* Search enhancement is deferred — a synchronous script here would stall
the parser mid-body on every navigation (blank below the header until
it arrives). `defer` + the inline `type="module"` init join the same
after-parse in-order queue (HTML spec), so newsSearch is defined first.
The forms are plain GET /search, so nothing user-facing waits on JS. */}
<script is:inline src="/search-client.js" defer></script>
<script is:inline type="module">
// Header search suggestions (desktop field + mobile reveal); the forms
// are plain GET /search, so everything below is progressive enhancement.
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
var pop = document.querySelector('.search-pop');
if (pop) pop.addEventListener('toggle', function () {
var q = document.getElementById('pop-q');
if (pop.open && q) q.focus();
});
// Light dismiss for the header panels (menu + search reveal) — scrim
// convention (Material/iOS, NN/g error prevention): a tap outside an
// open panel ONLY closes it and must never activate what's underneath.
// <details> has no native light dismiss; the Popover API was evaluated
// and rejected here — its light dismiss deliberately lets the outside
// click THROUGH (::backdrop is spec-forced pointer-events: none), the
// exact behavior research ruled out. Canceling pointerdown doesn't
// cancel the later click (Pointer Events spec), hence the click guard.
var swallow = false;
document.addEventListener('pointerdown', function (e) {
var open = document.querySelector('details[name="header-panel"][open]');
swallow = !!(open && !open.contains(e.target));
if (swallow) { open.open = false; e.preventDefault(); }
}, true);
document.addEventListener('click', function (e) {
if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); }
}, true);
// Escape closes the open panel and returns focus to its trigger button
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
var open = document.querySelector('details[name="header-panel"][open]');
if (open) { open.open = false; open.querySelector('summary').focus(); }
});
</script>
<main id="main">
<slot />
</main>
<footer class="footer">
{/* Bottom menu required by SPEC §3.3.1: disclaimers, privacy, contact, settings, search.
Site settings has no target yet — placeholder link = <a> without href. */}
<div class="container">
<div class="foot-top">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<p>CTAO Science Portal demo by Cyfronet (SUSS-PORT team).</p>
</div>
{/* Order: action/utility first, legal middle, meta last (GOV.UK/NN-g
footer anatomy). Spec §3.3.1 requires the five items, not an order. */}
<nav class="foot-links" aria-label="Footer">
<a href="/pages/contact">Contact</a>
<a href="/search">Search</a>
<a href="/rss.xml">RSS</a>
<a href="/pages/disclaimer">Disclaimer</a>
<a href="/pages/privacy">Privacy</a>
<a>Site settings</a>
{/* Internal tooling stays out of the main nav (NN/g: navigation reflects
user tasks); the editor is a discreet utility link for the demo. */}
<a href="/admin/">Content editor</a>
</nav>
<div class="foot-note">
{/* The real brand flash — path taken from the official logo SVG (BRAND D.4).
Sole decorative accent in this view; static by design. */}
<svg class="flash" viewBox="85.3 15 15 15" aria-hidden="true"><path fill="currentColor" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/></svg>
<span>© 2026 CTAO. Demonstration, not an official service.</span>
</div>
</div>
</footer>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
// Shared news helpers — single source for card metadata and archive chunking.
export const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
export const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
export const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Archive chunking: page 1 holds 25 items (1 featured lead + 24 grid = even
// 3-column rows), later pages 24. Used by BOTH the /news/[...page] route and
// sitemap.xml.js — the page counts cannot drift apart.
export const PAGE_FIRST = 25;
export const PAGE_REST = 24;
+24
View File
@@ -0,0 +1,24 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Page not found" description="This page does not exist">
{/* Document archetype: the document IS the page — h1, one line,
recovery links, and a real GET search form as the way forward. */}
<div class="container article-layout">
<article class="article">
<h1>Page not found</h1>
<div class="prose">
{/* Keep "the <a>" on one line: the compiler drops the whitespace at a
line break before a tag, which glued "the" to the link text. */}
<p>The address may be mistyped or the page may have moved.
Go to the <a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
</div>
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="nf-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
</article>
</div>
</Base>
+36
View File
@@ -0,0 +1,36 @@
---
// Editor entry point (/admin). Sveltia CMS loads its config from ./config.yml
// (served statically from public/admin/config.yml). The bundle is vendored in
// public/vendor/ so the editor CODE never auto-updates from a CDN (updates are
// deliberate — see README); note the bundle still fetches its own UI fonts
// from jsDelivr at runtime. Kept is:inline so Astro doesn't process/bundle it.
// CTAO branding on the sign-in screen comes from the documented `logo` option.
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CTAO Content Editor</title>
<meta name="theme-color" content="#00004A" />
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
<style>
/* Wrapper-page styling only — never injected into the Sveltia app.
Galaxy-Blue backdrop makes the load-in feel intentional. */
body { margin: 0; background: #00004a; }
noscript p {
color: #fff; font-family: Inter, Arial, system-ui, sans-serif;
text-align: center; margin: 40vh 24px 0; line-height: 1.6;
}
noscript a { color: #00e4d8; }
</style>
</head>
<body>
<noscript><p>The CTAO content editor requires JavaScript.<br />Please enable it, or return to the <a href="/">Science Portal</a>.</p></noscript>
<script is:inline src="/vendor/sveltia-cms.js"></script>
<script is:inline>
// Officially supported API: article previews rendered with portal-like styles.
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
---
import Base from '../layouts/Base.astro';
// Per SPEC §3.3.2 the dashboard aggregates proposal statuses, data products
// and help-desk tickets. No integrations exist yet, so panels show empty
// states instead of fabricated sample rows (source-documents-only rule).
---
<Base title="CTAO Science Portal - Dashboard" description="User dashboard (mock)">
<section class="band--moon">
<header class="container page-head">
<h1>Dashboard</h1>
<p class="standfirst">Your proposals, data products and support tickets in one place.</p>
</header>
<div class="container page">
<div class="dash-grid">
<div class="panel">
<h2 class="panel-title">My proposals</h2>
<p class="notice">Proposal statuses will appear here once the Proposal Handling System (APC team) is connected.</p>
</div>
<div class="panel">
<h2 class="panel-title">Recent data products</h2>
<p class="notice">Your data products will appear here. Search and download in the <a href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener" class="external">Data Explorer</a> (external, LUX team).</p>
</div>
<div class="panel">
<h2 class="panel-title">Support tickets</h2>
<p class="notice">Help-desk tickets will appear here once User Support is connected.</p>
</div>
</div>
</div>
</section>
</Base>
+93
View File
@@ -0,0 +1,93 @@
---
import { getCollection } from 'astro:content';
import Base from '../layouts/Base.astro';
import NewsCard from '../components/NewsCard.astro';
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 3);
// Services per the Science Portal spec (REQUIREMENTS §5). Tiles carry no
// status chrome; the footer's demo note is the single mock disclosure.
// Planned services stay in the grid but subdued (muted, no link) with
// availability folded into the description text.
// Tile icons: hand-drawn stroke glyphs in the header-icon family (.ico —
// currentColor, round caps), aria-hidden. NN/g icon research: icons alone are
// ambiguous, but NEXT TO an always-visible label they differentiate otherwise
// uniform tiles and speed up grid scanning.
const services = [
{ title: 'Proposals', href: '/proposals', desc: 'Submit observation proposals, including ToO / MWL requests.',
icon: '<path d="M13.5 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7.5z"/><path d="M13.5 3v4.5H18"/><path d="M9 12.5h6M9 16h4"/>' },
{ title: 'Data Explorer', href: 'https://padc-ctao-data-explorer.obspm.fr/', ext: true, desc: 'Search and download CTAO science data products.',
icon: '<ellipse cx="12" cy="5.5" rx="7" ry="2.5"/><path d="M5 5.5V18c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5.5"/><path d="M5 11.8c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>' },
{ title: 'Dashboard', href: '/dashboard', desc: 'Your proposals, data products and support tickets in one place.',
icon: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>' },
{ title: 'User Support', href: '/support', desc: 'Help desk, FAQ, user forum and mailing lists.',
icon: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="m5.7 5.7 3.5 3.5m5.6 0 3.5-3.5m0 12.6-3.5-3.5m-5.6 0-3.5 3.5"/>' },
// Two subdued planned tiles complete the 2×3 grid (an orphan 4th tile in a
// 3-col grid reads broken); the remaining TBD services stay on the quiet line.
{ title: 'Software', planned: true, desc: 'Landing page for CTAO software (planned).',
icon: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7 9.5 3 2.5-3 2.5M12.5 15H17"/>' },
{ title: 'Documentation', planned: true, desc: 'CTAO user documentation (planned).',
icon: '<path d="M12 6.7C10.6 5.2 8.6 4.5 5.7 4.5H4v13.6h1.7c2.9 0 4.9.7 6.3 2.2 1.4-1.5 3.4-2.2 6.3-2.2H20V4.5h-1.7c-2.9 0-4.9.7-6.3 2.2z"/><path d="M12 6.7v13.6"/>' },
];
const planned = ['Scheduling', 'Science alerts'];
---
<Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient>
{/* Photographic hero: LST-1 under the La Palma night sky (copied from the
content library to /brand/hero.jpg) beneath the Galaxy scrim — text
contrast computed worst-case at design time. Three elements only:
headline, brand subtitle, CTA pair (Apple hero restraint). */}
<section class="hero-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="container hero">
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>CTAO Science Portal</h1>
<p>Exploring the Universe at the Highest Energies</p>
{/* Hero actions = the two REAL things a logged-out visitor can do today:
news (SPEC §3.1 lists general information/announcements FIRST; §3.3.1
"displays the main information"; calls for proposals arrive VIA news)
and the external Data Explorer. Proposals stays in nav + tiles — a
mock doesn't earn hero billing. Search lives in the header. */}
<div class="hero-cta">
<a class="btn" href="/news">Browse news &amp; announcements</a>
<a class="btn btn--ghost external" href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener">Explore the data</a>
</div>
</div>
</section>
{/* Section order is task-first: Services directly under the hero, news after.
NN/g homepage principles — the homepage must give clear starting points
for the user's top tasks; for scientists that is proposals/data/support,
not reading news. Freshness is still signalled above the fold by the
one-line hero teaser, so the 3-card news section can sit below.
Surface rhythm: services on Moon Gray, editorial news on white. */}
<section class="band--moon">
<div class="container page">
<div class="section-head"><h2>Services</h2></div>
<div class="grid">
{services.map((s) => (
<article class={s.planned ? 'card tile tile--planned' : 'card tile'}>
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24" set:html={s.icon} />
<h3>{s.planned
? s.title
: (s.ext
? <a class="external" href={s.href} target="_blank" rel="noopener">{s.title}</a>
: <a href={s.href}>{s.title}</a>)}</h3>
<p class="desc">{s.desc}</p>
</article>
))}
</div>
<p class="planned-note">Planned services: {planned.join(' · ')}</p>
</div>
</section>
<section class="container page">
<div class="section-head">
<h2>Latest news</h2>
<a class="more" href="/news">All news →</a>
</div>
<div class="grid">
{posts.map((post) => <NewsCard post={post} />)}
</div>
</section>
</Base>
+16
View File
@@ -0,0 +1,16 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Sign in" description="Sign in via CTAO AAI (mock)">
<section class="auth-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="auth-card">
<img class="auth-logo" src="/brand/CTAO_Logo_positive.svg" alt="CTAO" />
<h1>Sign in</h1>
{/* Auth pages are scanned, not read — one line, no reassurance prose */}
<p class="muted">Use your CTAO account (single sign-on via CTAO AAI).</p>
<button class="btn btn-wide" type="button">Continue with your CTAO account</button>
<p class="notice">No account? <a>Register via CTAO AAI</a></p>
</div>
</section>
</Base>
+83
View File
@@ -0,0 +1,83 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import NewsCard from '../../components/NewsCard.astro';
import { PAGE_FIRST as FIRST, PAGE_REST as REST } from '../../lib/news.js';
// Paginated archive; the [...page] rest route makes page 1 the bare /news URL,
// then /news/2 … /news/N. Custom slicing instead of paginate(): page 1 holds
// FIRST items (1 featured lead + 24 grid = even 3-column rows), later pages
// REST — paginate() cannot vary page size. Chunk sizes live in lib/news.js,
// shared with sitemap.xml.js.
export async function getStaticPaths() {
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
const chunks = [posts.slice(0, FIRST)];
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
const lastPage = chunks.length;
const hrefOf = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
return chunks.map((data, i) => {
const n = i + 1;
return {
params: { page: n === 1 ? undefined : String(n) },
props: { page: {
data,
currentPage: n,
lastPage,
url: {
prev: n > 1 ? hrefOf(n - 1) : undefined,
next: n < lastPage ? hrefOf(n + 1) : undefined,
},
} },
};
});
}
const { page } = Astro.props;
const first = page.currentPage === 1;
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = [];
for (let n = 1; n <= page.lastPage; n++) {
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
else if (nums.at(-1) !== 0) nums.push(0);
}
const hrefFor = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
---
<Base
title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`}
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO, page ${page.currentPage} of ${page.lastPage}`}
>
{/* Section-landing archetype: white head, display Galaxy h1 + standfirst
(the navy band is reserved for / and /login). */}
<header class="container page-head">
<h1>News &amp; Announcements</h1>
<p class="standfirst">Science highlights and updates from the observatory and its partners.</p>
</header>
<section class="container page">
<h2 class="sr-only">{first ? 'All news' : `All news, page ${page.currentPage}`}</h2>
<div class="grid">
{page.data.map((post, i) => <NewsCard post={post} featured={first && i === 0} />)}
</div>
{page.lastPage > 1 && (
<nav class="pagination" aria-label="News pages">
{page.url.prev
? <a class="page-link page-step" href={page.url.prev} rel="prev">← Newer</a>
: <span class="page-link page-step" aria-hidden="true">← Newer</span>}
<ol>
{nums.map((n) => (
<li>
{n === 0
? <span class="page-gap" aria-hidden="true">…</span>
: <a class="page-link" href={hrefFor(n)} aria-current={n === page.currentPage ? 'page' : undefined}><span class="sr-only">Page </span>{n}</a>}
</li>
))}
</ol>
{page.url.next
? <a class="page-link page-step" href={page.url.next} rel="next">Older →</a>
: <span class="page-link page-step" aria-hidden="true">Older →</span>}
</nav>
)}
</section>
</Base>
+144
View File
@@ -0,0 +1,144 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { fmt, readMin as readMinOf } from '../../lib/news.js';
export async function getStaticPaths() {
// Date-sorted so each article knows its sequential neighbours (prev/next
// pattern of every editorial site — keeps readers in the content flow).
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
return posts.map((post, i) => ({
params: { slug: post.id },
props: { post, newer: posts[i - 1] ?? null, older: posts[i + 1] ?? null },
}));
}
const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const readMin = readMinOf(post);
---
<Base title={`${post.data.title} - CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
{/* Reading progress (scroll-driven CSS, no JS) — styled only where
animation-timeline is supported; elsewhere it stays an empty div. */}
<div class="read-progress" aria-hidden="true"></div>
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
under the title below that. CSS shows exactly one of the two. */}
{/* lang sits on the article, not <html>: the chrome (nav/footer) stays
English for screen readers even when the article body is not. */}
<div class="container article-layout">
<article class="article" lang={post.data.lang}>
<a class="back" href="/news">← All news</a>
{/* Editorial anatomy (Guardian/BBC/Reuters convention): headline →
standfirst → byline/meta → lead image → body. The standfirst is the
description made visible — cards already show it, the article should too. */}
<h1>{post.data.title}</h1>
<p class="standfirst">{post.data.description}</p>
{/* Meta — one quiet muted line: category · author · date · reading time */}
<div class="meta">
<span class="cat"><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 13.2 13.2 20a2 2 0 0 1-2.9 0L4 13.7V4h9.7l6.3 6.3a2 2 0 0 1 0 2.9Z" stroke-linejoin="round" /><circle cx="8.5" cy="8.5" r="1" /></svg>{post.data.category}</span>·
<span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" /><path d="M4.5 20c1.6-3.8 4.6-5.5 7.5-5.5s5.9 1.7 7.5 5.5" /></svg>{post.data.author}</span>·
<time datetime={post.data.date.toISOString().slice(0, 10)}><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg>{fmt(post.data.date)}</time>·
<span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin} min read</span>
</div>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" />}
{/* TOC sits between lead image and body (Wikipedia/GOV.UK contents
position): the press head above stays unbroken, and the list is
adjacent to the content it indexes. */}
{toc.length >= 3 && (
<details class="toc toc--inline">
<summary>On this page <svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="m5 9 7 7 7-7" /></svg></summary>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</details>
)}
<div class="prose">
<Content />
</div>
{(newer || older) && (
<nav class="post-nav" aria-label="More news">
{newer && (
<a class="post-nav-prev" rel="prev" href={`/news/${newer.id}`}>
<small>← Newer</small>
<span class="post-nav-title">{newer.data.title}</span>
</a>
)}
{older && (
<a class="post-nav-next" rel="next" href={`/news/${older.id}`}>
<small>Older →</small>
<span class="post-nav-title">{older.data.title}</span>
</a>
)}
</nav>
)}
</article>
{toc.length >= 3 && (
<nav class="toc toc--rail" aria-label="On this page">
<strong>On this page</strong>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</nav>
)}
</div>
</Base>
<script>
// TOC scrollspy — minimal vanilla take on Starlight's starlight-toc.ts:
// an IntersectionObserver band near the viewport top; whichever prose block
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
// Progressive enhancement: without JS the TOC is plain working anchors.
const links = [...document.querySelectorAll<HTMLAnchorElement>('.toc a[href^="#"]')];
if (links.length) {
let current: HTMLAnchorElement[] = [];
const setCurrent = (id: string | null) => {
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
if (next[0] === current[0]) return;
for (const a of current) a.removeAttribute('aria-current');
for (const a of next) a.setAttribute('aria-current', 'true');
current = next;
};
const blocks = [...document.querySelectorAll('.prose > *')];
// Governing heading = the block itself or the nearest h2/h3[id] above it
const headingFor = (el: Element) => {
for (let i = blocks.indexOf(el); i >= 0; i--) {
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
}
return null; // intro before the first heading — nothing highlighted
};
const onIntersect = (entries: IntersectionObserverEntry[]) => {
for (const e of entries) {
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
}
};
let observer: IntersectionObserver | undefined;
const observe = () => {
observer?.disconnect();
// Narrow band below the sticky header (--header-h 56px + 12px slack;
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
const top = 68, band = 64;
const io = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
});
observer = io;
blocks.forEach((b) => io.observe(b));
};
observe();
let timer: ReturnType<typeof setTimeout> | undefined;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
// Short final section: at page bottom, the last heading wins
addEventListener('scroll', () => {
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
setCurrent(headingFor(blocks[blocks.length - 1]));
}
}, { passive: true });
}
</script>
+27
View File
@@ -0,0 +1,27 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages');
return pages.map((page) => ({ params: { slug: page.id }, props: { page } }));
}
const { page } = Astro.props;
const { Content } = await render(page);
---
<Base title={`${page.data.title} - CTAO Science Portal`} description={page.data.description}>
{/* Same wrapper pattern as news/[slug] (never .container and .article on
one element — both set max-width and would depend on rule order). */}
<div class="container article-layout">
<article class="article">
{/* Static pages are footer-reached documents; same back affordance as
articles ("← All news") so deep links aren't dead ends. */}
<a class="back" href="/">← Home</a>
<h1>{page.data.title}</h1>
<div class="prose">
<Content />
</div>
</article>
</div>
</Base>
+28
View File
@@ -0,0 +1,28 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Proposals" description="Observation proposals: Proposal Handling System integration (planned)">
<section class="band--moon">
<header class="container page-head">
<h1>Observation proposals</h1>
<p class="standfirst">Submit observation proposals, including ToO and MWL requests.</p>
</header>
<div class="container page">
{/* Integration landing, not a form: the PHS is APC's product — the spec
(§3.3.3) scopes this portal to integrating it, so we describe the
hand-off instead of imitating their submission UI. */}
<div class="panel panel--narrow">
<h2 class="panel-title">Proposal Handling System</h2>
<p>Submitting observation proposals, including Target of Opportunity (ToO)
and multi-wavelength (MWL) requests, happens in the Proposal Handling
System. That system is built by the APC team and integrated into this portal.</p>
<p>What the portal contributes:</p>
<ul class="panel-list">
<li>This menu entry, with a single sign-on hand-off (CTAO AAI).</li>
<li>Proposal status feedback in your <a href="/dashboard">Dashboard</a>.</li>
</ul>
<button class="btn" type="button" disabled>Open the Proposal Handling System (integration planned)</button>
</div>
</div>
</section>
</Base>
+43
View File
@@ -0,0 +1,43 @@
// RSS 2.0 feed — hand-rolled static endpoint like search.json.js (no @astrojs/rss
// dependency; the spec is 20 lines of XML). Newest 30 items, absolute URLs from
// the configured `site`.
import { getCollection } from 'astro:content';
const esc = (s = '') =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]);
export async function GET(context) {
const site = context.site; // always set in astro.config.mjs
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 30);
const items = posts
.map((p) => {
const url = new URL(`/news/${p.id}`, site).href;
return ` <item>
<title>${esc(p.data.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${p.data.date.toUTCString()}</pubDate>
<description>${esc(p.data.description)}</description>
<category>${esc(p.data.category)}</category>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>CTAO Science Portal News</title>
<link>${new URL('/news', site).href}</link>
<description>News and announcements of the Cherenkov Telescope Array Observatory</description>
<language>en</language>
<lastBuildDate>${(posts[0]?.data.date ?? new Date()).toUTCString()}</lastBuildDate>
<atom:link href="${new URL('/rss.xml', site).href}" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}
+36
View File
@@ -0,0 +1,36 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Search" description="Search CTAO news and announcements">
{/* Utility archetype: ONE surface per page — Moon canvas from the landing
head down (borderless cards/panels pop against it); editorial pages stay
all-white. No mid-page surface seams. */}
<section class="band--moon">
<header class="container page-head">
<h1>Search</h1>
<p class="standfirst">Find news and announcements.</p>
</header>
<div class="container page">
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="q" name="q" type="search" placeholder="e.g. LST camera, Council, data…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
<p class="notice" id="search-status" role="status" aria-live="polite">Type to search the news archive.</p>
<ol class="search-results" id="search-results"></ol>
<noscript><p class="notice">Search requires JavaScript. Browse all news on the <a href="/news">news page</a>.</p></noscript>
</div>
</section>
</Base>
{/* search-client.js is loaded once (deferred) by the Base layout; this inline
module runs after it in the shared after-parse queue — order guaranteed. */}
<script is:inline type="module">
const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' });
// Support /search?q=… deep links (hero search form, footer, bookmarks)
const q0 = new URLSearchParams(location.search).get('q');
const q = document.getElementById('q');
if (q0) { q.value = q0; run(); }
q.focus();
</script>
+19
View File
@@ -0,0 +1,19 @@
// Build-time search index (Astro static endpoint — no server, no dependencies).
// Kept lean: title/description/category/date/slug only (~60 KB for 200+ articles).
import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('news', ({ data }) => !data.draft);
const index = posts
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.map((p) => ({
slug: p.id,
title: p.data.title,
description: p.data.description,
category: p.data.category,
date: p.data.date.toISOString().slice(0, 10),
}));
return new Response(JSON.stringify(index), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
+40
View File
@@ -0,0 +1,40 @@
// Sitemap — hand-rolled static endpoint (no @astrojs/sitemap dependency; our URL
// set is fully known at build time). /admin is intentionally excluded.
import { getCollection } from 'astro:content';
import { PAGE_FIRST, PAGE_REST } from '../lib/news.js';
// Chunk sizes are imported from lib/news.js — the same values the
// /news/[...page] route slices with, so the page count here cannot drift.
const newsPageCount = (n) => (n <= PAGE_FIRST ? 1 : 1 + Math.ceil((n - PAGE_FIRST) / PAGE_REST));
export async function GET(context) {
const site = context.site; // always set in astro.config.mjs
const news = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const pages = await getCollection('pages');
// Static pages come from the actual route files, so a new src/pages/*.astro
// can never be forgotten here. 404 is excluded; /admin (subdirectory) is
// intentionally unlisted. Go-live note: /login and /dashboard are mock
// pages — filter them out before robots.txt stops disallowing everything.
const staticPages = Object.keys(import.meta.glob('./*.astro'))
.map((p) => p.slice(1, -'.astro'.length))
.filter((p) => p !== '/404')
.map((p) => (p === '/index' ? '/' : p))
.sort();
const urls = [
...staticPages, '/news',
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
...pages.map((p) => `/pages/${p.id}`),
].map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`);
const articles = news.map(
(p) => ` <url><loc>${new URL(`/news/${p.id}`, site).href}</loc><lastmod>${p.data.date.toISOString().slice(0, 10)}</lastmod></url>`,
);
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${[...urls, ...articles].join('\n')}
</urlset>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
}
+29
View File
@@ -0,0 +1,29 @@
---
import Base from '../layouts/Base.astro';
// User support channels per the Science Portal spec — all mock for now.
const channels = [
{ title: 'Help desk', desc: 'Submit and track support tickets.' },
{ title: 'FAQ & troubleshooting', desc: 'Common questions and step-by-step guides.' },
{ title: 'User forum', desc: 'Discuss with the CTAO user community.' },
{ title: 'Mailing lists', desc: 'Announcements and topical mailing lists.' },
];
---
<Base title="CTAO Science Portal - Support" description="User support: help desk, FAQ, forum, mailing lists (mock)">
<section class="band--moon">
<header class="container page-head">
<h1>User Support</h1>
<p class="standfirst">Help desk, FAQ, user forum and mailing lists.</p>
</header>
<div class="container page">
<h2 class="sr-only">Support channels</h2>
<div class="grid">
{channels.map((c) => (
<article class="card tile">
<h3>{c.title}</h3>
<p class="desc">{c.desc}</p>
</article>
))}
</div>
</div>
</section>
</Base>
File diff suppressed because one or more lines are too long