11---
22import BaseLayout from " ./base.astro" ;
33import 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
510type Variant = " article" | " wide" | " full" | " sidebar" ;
611
@@ -37,13 +42,29 @@ const finalDate = frontmatter?.date || date;
3742const rawCover = frontmatter ?.cover || cover ;
3843const 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
4163function 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}
4970const 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
61122const 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 " > · </ 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 )}
0 commit comments