diff --git a/public/images/banner.png b/public/images/banner.png new file mode 100644 index 0000000..76df3e3 Binary files /dev/null and b/public/images/banner.png differ diff --git a/public/images/logo-text-noa.jpg b/public/images/logo-text-noa.jpg new file mode 100644 index 0000000..7452d88 Binary files /dev/null and b/public/images/logo-text-noa.jpg differ diff --git a/public/images/logo-text.webp b/public/images/logo-text.webp new file mode 100644 index 0000000..d137ab5 Binary files /dev/null and b/public/images/logo-text.webp differ diff --git a/src/components/AboutPlaceholderPage.astro b/src/components/AboutPlaceholderPage.astro new file mode 100644 index 0000000..27c90d3 --- /dev/null +++ b/src/components/AboutPlaceholderPage.astro @@ -0,0 +1,45 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; + +type Props = { + title: string; + intro?: string; +}; + +const { + title, + intro = 'This page is a placeholder and will be replaced with final approved content.', +} = Astro.props as Props; +--- + + +
+

About

+

{title}

+

{intro}

+ +
+
+

What this page will cover

+

+ Placeholder summary content for this topic. Replace with operational guidance, + responsibilities, and current policy details. +

+
+
+

Key contacts and process

+

+ Placeholder contact details and process notes. Replace with named contacts, + response times, and escalation routes. +

+
+
+

Documents and references

+

+ Placeholder links and downloadable resources can be added here once content and + approval are complete. +

+
+
+
+
diff --git a/src/components/EventsList.astro b/src/components/EventsList.astro index c19fd9b..e9afe52 100644 --- a/src/components/EventsList.astro +++ b/src/components/EventsList.astro @@ -1,7 +1,8 @@ --- import SectionHeading from './SectionHeading.astro'; import type { EventItem } from '../lib/fallback-data'; -import { formatDateTime } from '../lib/format'; +import { formatDate, formatTime, formatWeekday } from '../lib/format'; +import { normalizeSlug } from '../lib/slug'; type Props = { events: EventItem[]; @@ -17,22 +18,25 @@ const { events, title = 'Upcoming events', description = 'A quick scan list for
{events.length > 0 ? ( events.map((event) => { - const detailHref = event.slug ? `/events/${event.slug}/` : undefined; + const normalizedSlug = normalizeSlug(event.slug); + const detailHref = normalizedSlug ? `/events/${normalizedSlug}/` : undefined; + const summary = event.summary?.trim() || event.description; return ( -
-
-
-

{event.is_featured ? 'Featured' : 'Event'}

-

{detailHref ? {event.title} : event.title}

-

{event.description}

+
+ {detailHref && } + diff --git a/src/components/FuelPricesWidget.astro b/src/components/FuelPricesWidget.astro index c64e4d2..50a6706 100644 --- a/src/components/FuelPricesWidget.astro +++ b/src/components/FuelPricesWidget.astro @@ -1,7 +1,6 @@ --- import SectionHeading from './SectionHeading.astro'; import type { FuelPrice } from '../lib/fallback-data'; -import { formatDate } from '../lib/format'; type Props = { fuelPrices: FuelPrice[]; @@ -16,15 +15,50 @@ function formatFuelPrice(value: unknown): string { ---
- -
+ +
{fuelPrices.map((fuel) => ( -
-

{fuel.fuel_type}

-

{fuel.currency} {formatFuelPrice(fuel.price_per_litre)} / litre

-

Updated {formatDate(fuel.last_updated)}

+
+

{fuel.fuel_type}

+

{fuel.currency} {formatFuelPrice(fuel.price_per_litre)} / litre

{fuel.notes &&

{fuel.notes}

}
))}
+ + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index d63d105..072c65e 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -22,11 +22,24 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
+ Swansea Airport
- Menu +
{site.navigation.map((item) => ( - {item.label} + item.children ? ( +
+ {item.label} +
+ Overview + {item.children.map((child) => ( + {child.label} + ))} +
+
+ ) : ( + {item.label} + ) ))}
@@ -35,11 +48,22 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
diff --git a/src/pages/events/index.astro b/src/pages/events/index.astro index 814e255..4e979d3 100644 --- a/src/pages/events/index.astro +++ b/src/pages/events/index.astro @@ -8,6 +8,6 @@ const events = (await getEvents()).sort((left, right) => new Date(left.start_dat
- +
diff --git a/src/pages/index.astro b/src/pages/index.astro index 810a5ea..ab98750 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -50,10 +50,13 @@ const businessPromos = [ --- + +
-

Operational website

Welcome to Swansea Airport - Fairwood Common

@@ -63,7 +66,7 @@ const businessPromos = [ There are a whole host of aviation activities at Swansea Airport. If you're looking to learn to fly, or charter an aircraft, Swansea is more convenient and more cost effective than other Welsh and West of England airports. No queues and no expensive handling fees.

diff --git a/src/pages/pilot-info.astro b/src/pages/pilot-info.astro new file mode 100644 index 0000000..4b7ec49 --- /dev/null +++ b/src/pages/pilot-info.astro @@ -0,0 +1,91 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import FuelPricesWidget from '../components/FuelPricesWidget.astro'; +import { getFuelPrices } from '../lib/directus'; +import { site } from '../lib/site'; + +const runwayPattern = /^Runway\s+(\d+)\s+-\s+([^\s]+)\s+LDA\s+\(([^)]+)\)\s+Code\s+(\d+)\s+\(([^)]+)\)$/; + +const runwayRows = site.runwayFacts + .map((fact) => { + const match = fact.match(runwayPattern); + + if (!match) return null; + + const [, runway, lda, surface, code, circuits] = match; + return { runway, lda, surface, code, circuits }; + }) + .filter((row): row is { runway: string; lda: string; surface: string; code: string; circuits: string } => row !== null); + +const otherFacts = site.runwayFacts.filter((fact) => !fact.match(runwayPattern)); +const fuelPrices = await getFuelPrices(); +--- + + +
+

Pilot info

+

Communications

+

+ Swansea Airport operates a Air / Ground Communication Service, callsign Swansea Radio on 119.705. This service is staffed by volunteers and may not be operational at all times. +

+

+When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea Traffic shall be made on the Air / Ground frequency +

+ +

Runway Information

+
+ + + + + + + + + + + + {runwayRows.map((runway) => ( + + + + + + + + ))} + +
RunwayLDASurfaceCodeCircuits
{runway.runway}{runway.lda}{runway.surface}{runway.code}{runway.circuits}
+
+ + {otherFacts.length > 0 && ( +
    + {otherFacts.map((fact) =>
  • {fact}
  • )} +
+ )} + +

Circuits at 1000ft agl. Overhead joins and/or dead-side flying not permitted when Parachuting is active. + + + +

+ + + +

Arrival essentials

+
+
+

PPR

+

Pre-landing fogging is presented prominently here and can be linked to the relevant Directus content or booking workflow.

+
+
+

Book out

+

Departure procedures and any required outbound reporting remain in the same controlled page structure.

+
+
+

Fuel and services

+

Fuel prices are shown on the homepage and can be reused here with the same data source.

+
+
+
+
diff --git a/src/pages/visiting-pilots.astro b/src/pages/visiting-pilots.astro index b392da0..18fd161 100644 --- a/src/pages/visiting-pilots.astro +++ b/src/pages/visiting-pilots.astro @@ -1,20 +1,71 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; import { site } from '../lib/site'; + +const runwayPattern = /^Runway\s+(\d+)\s+-\s+([^\s]+)\s+LDA\s+\(([^)]+)\)\s+Code\s+(\d+)\s+\(([^)]+)\)$/; + +const runwayRows = site.runwayFacts + .map((fact) => { + const match = fact.match(runwayPattern); + + if (!match) return null; + + const [, runway, lda, surface, code, circuits] = match; + return { runway, lda, surface, code, circuits }; + }) + .filter((row): row is { runway: string; lda: string; surface: string; code: string; circuits: string } => row !== null); + +const otherFacts = site.runwayFacts.filter((fact) => !fact.match(runwayPattern)); --- - +
-

Visiting pilots

-

Essential information for arriving aircraft

+

Pilot info

+

Communications

- This page is structured for quick pre-flight checks. It keeps operational details in fixed Astro components and leaves content updates to Directus. + Swansea Airport operates a Air / Ground Communication Service, callsign Swansea Radio on 119.705. This service is staffed by volunteers and may not be operational at all times. +

+

+When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea Traffic shall be made on the Air / Ground frequency

-

Airport facts

-
    - {site.runwayFacts.map((fact) =>
  • {fact}
  • )} -
+

Runway Information

+
+ + + + + + + + + + + + {runwayRows.map((runway) => ( + + + + + + + + ))} + +
RunwayLDASurfaceCodeCircuits
{runway.runway}{runway.lda}{runway.surface}{runway.code}{runway.circuits}
+
+ + {otherFacts.length > 0 && ( +
    + {otherFacts.map((fact) =>
  • {fact}
  • )} +
+ )} + +

Circuits at 1000ft agl. Overhead joins not permitted when Parachuting is active. + + + +

Arrival essentials

diff --git a/src/styles/global.css b/src/styles/global.css index 65e15f7..636bc93 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -105,6 +105,19 @@ img { font-weight: 700; letter-spacing: 0.02em; text-decoration: none; + display: flex; + align-items: center; + height: auto; +} + +.brand img { + max-height: 2.5rem; + width: auto; + display: block; +} + +.brand-mobile { + display: none; } .nav-links { @@ -119,6 +132,49 @@ img { text-decoration: none; } +.nav-item { + position: relative; + padding-bottom: 0.5rem; +} + +.nav-dropdown-menu { + position: absolute; + top: calc(100% + 0.5rem); + left: 50%; + transform: translateX(-50%); + min-width: 11rem; + display: grid; + gap: 0.2rem; + padding: 0.45rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: rgba(255, 255, 255, 0.97); + box-shadow: var(--shadow); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.18s ease; + z-index: 24; + margin-top: -0.5rem; +} + +.nav-dropdown-menu a { + padding: 0.45rem 0.6rem; + border-radius: 0.6rem; + color: var(--text); +} + +.nav-dropdown-menu a:hover { + background: var(--brand-soft); +} + +.nav-dropdown:hover .nav-dropdown-menu, +.nav-dropdown:focus-within .nav-dropdown-menu { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + .nav-links a:hover, .mobile-nav-panel a:hover { color: var(--brand); @@ -134,15 +190,16 @@ img { list-style: none; display: flex; align-items: center; - justify-content: space-between; - gap: 1rem; + justify-content: center; min-height: 2.75rem; - padding: 0.7rem 1rem; + min-width: 2.75rem; + padding: 0; border: 1px solid var(--line); - border-radius: 999px; + border-radius: 0.7rem; background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(228, 240, 252, 0.95)); color: var(--brand); font-weight: 800; + font-size: 1.4rem; cursor: pointer; box-shadow: var(--shadow); } @@ -151,14 +208,14 @@ img { display: none; } -.mobile-nav summary::after { - content: '▾'; - font-size: 0.9rem; - color: var(--brand-2); +.mobile-nav summary::before { + content: '☰'; + line-height: 1; } -.mobile-nav[open] summary::after { - content: '▴'; +.mobile-nav[open] summary::before { + content: '✕'; + font-size: 1.2rem; } .mobile-nav-panel { @@ -185,10 +242,52 @@ img { font-weight: 700; } +.mobile-subnav { + border-radius: 0.75rem; +} + +.mobile-subnav summary { + list-style: none; + padding: 0.7rem 0.8rem; + border-radius: 0.75rem; + font-weight: 700; + cursor: pointer; +} + +.mobile-subnav summary::-webkit-details-marker { + display: none; +} + +.mobile-subnav-panel { + display: grid; + gap: 0.2rem; + margin: 0 0.2rem 0.35rem; + padding: 0.35rem; + border: 1px solid var(--line); + border-radius: 0.7rem; + background: rgba(255, 255, 255, 0.7); +} + main { padding-block: 1.5rem 4rem; } +.banner-image { + width: 100%; + display: block; + overflow: hidden; + margin: 0; + background: rgba(255, 255, 255, 0.4); +} + +.banner-image img { + width: 100%; + height: auto; + display: block; + max-height: 12rem; + object-fit: cover; +} + section { padding-block: 1rem; } @@ -502,6 +601,64 @@ section { margin-bottom: 0; } +.event-card { + position: relative; +} + +.stretched-link { + position: absolute; + inset: 0; + z-index: 1; +} + +.event-card-actions { + position: relative; + z-index: 2; +} + +.event-layout { + display: grid; + grid-template-columns: minmax(10rem, 11.5rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.event-date-block { + padding: 0.85rem 0.95rem; + border: 1px solid rgba(16, 34, 51, 0.12); + border-radius: var(--radius-sm); + background: linear-gradient(180deg, rgba(11, 79, 122, 0.1), rgba(29, 118, 184, 0.06)); +} + +.event-weekday, +.event-date, +.event-time { + margin: 0; +} + +.event-weekday { + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--brand-2); + font-size: 0.73rem; + font-weight: 800; +} + +.event-date { + margin-top: 0.3rem; + font-family: 'Fraunces', Georgia, serif; + font-size: 1.2rem; + font-weight: 700; + line-height: 1.2; +} + +.event-time { + margin-top: 0.35rem; + color: var(--muted); + font-size: 0.9rem; + font-weight: 700; +} + .split-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -540,18 +697,74 @@ section { max-width: 72ch; } +.runway-table-wrap { + width: 100%; + overflow-x: auto; + margin: 1rem auto 1.25rem; +} + +.runway-facts-table { + width: min(100%, 46rem); + margin-inline: auto; + border-collapse: collapse; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + overflow: hidden; + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 12px 24px rgba(16, 34, 51, 0.08); +} + +.runway-facts-table thead { + background: linear-gradient(180deg, rgba(11, 79, 122, 0.14), rgba(29, 118, 184, 0.09)); +} + +.runway-facts-table th, +.runway-facts-table td { + padding: 0.7rem 0.85rem; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +.runway-facts-table th { + font-size: 0.83rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--brand); +} + +.runway-facts-table tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.64); +} + +.runway-facts-table tbody tr:hover { + background: var(--brand-soft); +} + +.runway-facts-table tbody tr:last-child td { + border-bottom: 0; +} + .prose h2, .prose h3 { font-family: 'Manrope', system-ui, sans-serif; font-weight: 800; } +.text-multiline { + white-space: pre-line; +} + .stack { display: grid; gap: 1rem; } @media (max-width: 860px) { + .event-layout { + grid-template-columns: 1fr; + } + .split-grid, .footer-grid, .operational-grid { @@ -598,4 +811,16 @@ section { .mobile-nav { display: block; } + + .brand-mobile { + display: flex; + } + + .brand-mobile img { + max-height: 2rem; + } + + .runway-facts-table { + min-width: 38rem; + } }