Skip to content

Commit 51e869b

Browse files
committed
feat: improve page layout and fix sticky elements on scroll
1 parent e5abd92 commit 51e869b

4 files changed

Lines changed: 206 additions & 25 deletions

File tree

src/layouts/page.astro

Lines changed: 178 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
---
22
import BaseLayout from "./base.astro";
33
import Breadcrumbs from "../components/breadcrumbs.astro";
4+
import { slugToTitleCase } from "../lib/utils";
5+
import dayjs from "dayjs";
6+
import relativeTime from "dayjs/plugin/relativeTime";
7+
8+
dayjs.extend(relativeTime);
49
510
type Variant = "article" | "wide" | "full" | "sidebar";
611
@@ -37,13 +42,29 @@ const finalDate = frontmatter?.date || date;
3742
const rawCover = frontmatter?.cover || cover;
3843
const finalSeo = frontmatter?.seo || seo;
3944
45+
// Content-type-specific metadata from frontmatter
46+
const tags: string[] = frontmatter?.tags ?? [];
47+
const summary: string | undefined = frontmatter?.summary;
48+
const status: string | undefined = frontmatter?.status;
49+
const category: string | undefined = frontmatter?.category;
50+
const period: string | undefined = frontmatter?.period;
51+
const funder: { name?: string | null; link?: string | null } | undefined = frontmatter?.funder;
52+
const externalLink: string | undefined = frontmatter?.external_link;
53+
const keywords: string[] = frontmatter?.keywords ?? [];
54+
55+
// Detect content type from URL
56+
const pathname = Astro.url.pathname;
57+
const contentType = pathname.startsWith('/news/') ? 'news'
58+
: pathname.startsWith('/events/') ? 'events'
59+
: pathname.startsWith('/funding-and-projects/') ? 'projects'
60+
: null;
61+
4062
// Resolve relative asset paths (./file.png) to their public URL using the page pathname
4163
function resolvePageAsset(src: string): string {
4264
if (!src?.startsWith('./')) return src;
4365
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
44-
const pathname = Astro.url.pathname.replace(/\/$/, '');
45-
// Strip base prefix so the content path doesn't include it
46-
const contentPath = base && pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
66+
const cleanPathname = Astro.url.pathname.replace(/\/$/, '');
67+
const contentPath = base && cleanPathname.startsWith(base) ? cleanPathname.slice(base.length) : cleanPathname;
4768
return `${base}/content${contentPath}/${src.slice(2)}`;
4869
}
4970
const finalCover = rawCover
@@ -57,6 +78,46 @@ const dateObj = finalDate
5778
: finalDate
5879
: null;
5980
81+
const formattedDate = dateObj
82+
? dateObj.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
83+
: null;
84+
85+
const relativeDate = dateObj ? dayjs(dateObj).fromNow() : null;
86+
87+
// Period relative label for projects
88+
function periodToRelative(p: string): string {
89+
const matches = p.match(/(\d{4}-\d{2})/g);
90+
if (!matches || matches.length < 2) return p;
91+
const end = dayjs(matches[matches.length - 1]);
92+
if (!end.isValid()) return p;
93+
const now = dayjs();
94+
if (end.isAfter(now)) return end.fromNow(true) + ' to deadline';
95+
return 'Completed ' + end.fromNow();
96+
}
97+
98+
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
99+
100+
// Status badge colors (reused from listing page)
101+
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
102+
ongoing: {
103+
bg: 'bg-green-100 dark:bg-green-900/20',
104+
text: 'text-green-700 dark:text-green-400',
105+
dot: 'bg-green-500 dark:bg-green-400',
106+
},
107+
completed: {
108+
bg: 'bg-gray-100 dark:bg-gray-800/40',
109+
text: 'text-gray-600 dark:text-gray-400',
110+
dot: 'bg-gray-400 dark:bg-gray-500',
111+
},
112+
};
113+
114+
const categoryColors: Record<string, string> = {
115+
european: 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400',
116+
elixir: 'bg-brand-secondary/10 text-brand-secondary-text dark:text-brand-secondary',
117+
norwegian: 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400',
118+
global: 'bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-400',
119+
};
120+
60121
// Max width classes based on variant
61122
const containerClasses = {
62123
article: "max-w-3xl",
@@ -69,47 +130,145 @@ const hasSidebar = finalVariant === "sidebar";
69130
---
70131

71132
<BaseLayout seo={finalSeo}>
72-
<div class="isolate px-6 sm:px-8 pb-12 pt-16 flex-1">
133+
<div class="isolate px-6 sm:px-8 pb-12 pt-24 sm:pt-28 flex-1">
73134
<div class={`mx-auto ${containerClasses[finalVariant]}`}>
74135
<!-- Breadcrumbs -->
75136
{breadcrumbs && <Breadcrumbs crumbs={breadcrumbs} />}
76137
<!-- Page Header -->
77138
{
78139
(finalTitle || finalCover) && (
79-
<header class="mb-12">
140+
<header class="mb-10 sm:mb-14">
141+
{/* Back link */}
142+
{contentType && (
143+
<a
144+
href={`${BASE}/${contentType === 'projects' ? 'funding-and-projects' : contentType}`}
145+
class="inline-flex items-center gap-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-brand-primary dark:hover:text-white transition-colors mb-6"
146+
>
147+
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
148+
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
149+
</svg>
150+
{contentType === 'news' ? 'All news' : contentType === 'events' ? 'All events' : 'All projects'}
151+
</a>
152+
)}
153+
154+
{/* Badges row — news tags, project status/category */}
155+
{contentType === 'news' && tags.length > 0 && (
156+
<div class="flex flex-wrap gap-2 mb-4">
157+
{tags.map((tag: string) => (
158+
<span class="rounded-full px-3 py-1 text-xs font-semibold bg-brand-secondary/10 text-brand-secondary-text dark:text-brand-secondary">
159+
{slugToTitleCase(tag)}
160+
</span>
161+
))}
162+
</div>
163+
)}
164+
165+
{contentType === 'projects' && (status || category) && (
166+
<div class="flex flex-wrap items-center gap-2 mb-4">
167+
{status && status !== 'unknown' && (() => {
168+
const sc = statusColors[status] ?? statusColors.completed;
169+
return (
170+
<span class={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${sc.bg} ${sc.text}`}>
171+
<span class={`h-1.5 w-1.5 rounded-full ${sc.dot}`} />
172+
{slugToTitleCase(status)}
173+
</span>
174+
);
175+
})()}
176+
{category && category !== 'unknown' && (
177+
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${categoryColors[category] ?? 'bg-gray-100 dark:bg-gray-800/40 text-gray-600 dark:text-gray-400'}`}>
178+
{slugToTitleCase(category)}
179+
</span>
180+
)}
181+
</div>
182+
)}
183+
80184
{finalTitle && (
81-
<h1 class="text-3xl font-bold tracking-tight sm:text-5xl mb-4 text-gray-900 dark:text-white">
185+
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight text-brand-primary dark:text-white text-balance leading-[1.15]">
82186
{finalTitle}
83187
</h1>
84188
)}
85189

190+
{/* Date row */}
86191
{dateObj && (
87-
<time
88-
datetime={dateObj.toISOString()}
89-
class="text-sm text-gray-600 dark:text-gray-400 block mb-4"
90-
>
91-
{dateObj.toLocaleDateString("en-US", {
92-
year: "numeric",
93-
month: "long",
94-
day: "numeric",
95-
})}
96-
</time>
192+
<div class="mt-4 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
193+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
194+
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
195+
</svg>
196+
<time datetime={dateObj.toISOString()}>
197+
{formattedDate}
198+
</time>
199+
<span class="text-gray-300 dark:text-gray-600">&middot;</span>
200+
<span>{relativeDate}</span>
201+
</div>
97202
)}
98203

204+
{/* Project metadata row */}
205+
{contentType === 'projects' && (period || funder?.name) && (
206+
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
207+
{period && (
208+
<span class="inline-flex items-center gap-1.5" title={period}>
209+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
210+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
211+
</svg>
212+
{periodToRelative(period)}
213+
</span>
214+
)}
215+
{funder?.name && (
216+
<span class="inline-flex items-center gap-1.5">
217+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
218+
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3H21m-3.75 3H21" />
219+
</svg>
220+
{funder.link
221+
? <a href={funder.link} target="_blank" rel="noopener noreferrer" class="hover:text-brand-primary dark:hover:text-white transition-colors">{funder.name}</a>
222+
: funder.name
223+
}
224+
</span>
225+
)}
226+
{externalLink && (
227+
<a href={externalLink} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 hover:text-brand-primary dark:hover:text-white transition-colors">
228+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
229+
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
230+
</svg>
231+
Project website
232+
</a>
233+
)}
234+
</div>
235+
)}
236+
237+
{/* Summary / lead paragraph */}
238+
{summary && contentType && (
239+
<p class="mt-5 text-base sm:text-lg leading-relaxed text-brand-grey dark:text-gray-300 text-balance">
240+
{summary}
241+
</p>
242+
)}
243+
244+
{/* Project keywords */}
245+
{contentType === 'projects' && keywords.length > 0 && (
246+
<div class="mt-5 flex flex-wrap gap-2">
247+
{keywords.map((kw: string) => (
248+
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800/40 text-gray-600 dark:text-gray-400">
249+
{slugToTitleCase(kw)}
250+
</span>
251+
))}
252+
</div>
253+
)}
254+
255+
{/* Divider before cover or content */}
256+
{!finalCover && <div class="mt-8 border-t border-gray-200/60 dark:border-gray-700/30" />}
257+
99258
{finalCover && (
100-
<figure class="mb-12">
259+
<figure class="mt-8">
101260
<img
102261
src={finalCover.source}
103262
alt={
104263
finalCover.alt ||
105264
finalTitle ||
106265
"Cover image"
107266
}
108-
class="w-full rounded-lg"
267+
class="w-full rounded-xl"
109268
loading="lazy"
110269
/>
111270
{finalCover.caption && (
112-
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
271+
<figcaption class="text-sm text-gray-500 dark:text-gray-400 mt-3 text-center">
113272
{finalCover.caption}
114273
</figcaption>
115274
)}

src/pages/funding-and-projects/index.astro

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { getCollection } from "astro:content";
33
import Page from "../../layouts/page.astro";
44
import ProjectFilters from "../../components/project-filters";
55
import { slugToTitleCase, idToSlug } from "../../lib/utils";
6+
import dayjs from "dayjs";
7+
import relativeTime from "dayjs/plugin/relativeTime";
8+
9+
dayjs.extend(relativeTime);
610
711
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
812
@@ -43,6 +47,18 @@ const filterGroups = [
4347
{ key: 'keywords', label: 'Topics', options: buildKeywordOptions() },
4448
];
4549
50+
/** Parse "YYYY-MM YYYY-MM" or "YYYY-MM - YYYY-MM" period into a relative timeline label */
51+
function periodToRelative(period: string): string {
52+
// Match the last YYYY-MM in the string (the end date)
53+
const matches = period.match(/(\d{4}-\d{2})/g);
54+
if (!matches || matches.length < 2) return period;
55+
const end = dayjs(matches[matches.length - 1]);
56+
if (!end.isValid()) return period;
57+
const now = dayjs();
58+
if (end.isAfter(now)) return end.fromNow(true) + ' to deadline';
59+
return 'Completed ' + end.fromNow();
60+
}
61+
4662
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
4763
ongoing: {
4864
bg: 'bg-green-100 dark:bg-green-900/20',
@@ -157,11 +173,11 @@ const categoryColors: Record<string, string> = {
157173
{/* Metadata row */}
158174
<div class="mt-4 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
159175
{project.data.period && (
160-
<span class="inline-flex items-center gap-1">
176+
<span class="inline-flex items-center gap-1" title={project.data.period}>
161177
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
162178
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
163179
</svg>
164-
{project.data.period}
180+
{periodToRelative(project.data.period)}
165181
</span>
166182
)}
167183
{project.data.funder?.name && (

src/pages/index.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import slides from "../data/slides.json";
1010

1111
<BaseLayout>
1212
<main id="main-content">
13-
<Hero client:only="react" />
13+
<div class="min-h-[calc(100vh-84px)]">
14+
<Hero client:only="react" />
15+
</div>
1416

1517
<section class="py-20 lg:py-28 bg-light-surface dark:bg-dark-surface">
1618
<div class="max-w-[1400px] mx-auto px-4 sm:px-6">

src/styles/global.scss

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
@media (min-width: 3840px) { font-size: 24px; }
1818
}
1919

20+
html {
21+
overflow-y: scroll;
22+
scrollbar-gutter: stable; /* Reserves scrollbar space — no layout shift */
23+
scrollbar-width: thin; /* Firefox: thin track */
24+
scrollbar-color: rgba(0,0,0,0.2) transparent;
25+
}
26+
2027
html, body {
2128
text-rendering: geometricPrecision;
2229
-webkit-font-smoothing: antialiased;
2330
-moz-osx-font-smoothing: grayscale;
24-
overflow-y: overlay; /* Scrollbar overlays content — no layout shift */
25-
scrollbar-width: thin; /* Firefox: thin track */
26-
scrollbar-color: rgba(0,0,0,0.2) transparent;
2731
}
2832

2933
html::-webkit-scrollbar { width: 6px; }

0 commit comments

Comments
 (0)