Add markdown news system and improve news UX

This commit is contained in:
Calum Muir 2025-12-21 19:53:04 +00:00
parent d5f58e0b43
commit 191070c18e
12 changed files with 317 additions and 44 deletions

View file

@ -10,6 +10,8 @@ import { About } from "@/pages/About";
import { News } from "@/pages/News"; import { News } from "@/pages/News";
import { Contact } from "@/pages/Contact"; import { Contact } from "@/pages/Contact";
import { SupportUs } from "@/pages/SupportUs"; import { SupportUs } from "@/pages/SupportUs";
import { Privacy } from "@/pages/Privacy";
import { NewsArticle } from "@/pages/NewsArticle";
function Router() { function Router() {
return ( return (
@ -18,8 +20,10 @@ function Router() {
<Route path="/" component={Desktop} /> <Route path="/" component={Desktop} />
<Route path="/about" component={About} /> <Route path="/about" component={About} />
<Route path="/news" component={News} /> <Route path="/news" component={News} />
<Route path="/news/:slug" component={NewsArticle} />
<Route path="/support" component={SupportUs} /> <Route path="/support" component={SupportUs} />
<Route path="/contact" component={Contact} /> <Route path="/contact" component={Contact} />
<Route path="/privacy" component={Privacy} />
{/* Fallback to 404 */} {/* Fallback to 404 */}
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>

View file

@ -0,0 +1,8 @@
---
title: "Volunteers Summer 2024"
date: "2024-06-01"
summary: "Volunteers summer 2024"
image: "/Vols5-2880w-1024x768.avif"
slug: "volunteers-summer-2024"
---
We welcomed a group of our volunteers to the centre for a catch up and discussion about upcoming events. Its always great to get together! Find out how you can help out by **[Volunteering for us](/support)**.

View file

@ -0,0 +1,11 @@
---
title: "Summer Awards"
date: "2024-07-01"
summary: "Summer awards 2024"
image: "/Awardtime-1920w-768x1024.avif"
slug: "summer-awards-2024"
---
Last week we had the pleasure of our awards ceremony. Its always fantastic to see the progress of our riders, ponies and everyone involved in Highland Group RDA. Samantha received an award for.., read <a href="/Star-Participants-Samantha-s-story.pdf" target="_blank" rel="noreferrer">Samantha's story</a>
.
![Alt text](/Art-1024x683.jpg)
![Alt text](/Louise-art-award-822x1024.jpg)

View file

@ -0,0 +1,8 @@
---
title: "Race Night"
date: "2024-10-01"
summary: "Race night"
image: "/race-night.avif"
slug: "race-night"
---
![Alt text](/race-night.avif)

View file

@ -0,0 +1,25 @@
---
title: "Important News"
date: "2025-12-19"
summary: "Important news to share with the community."
image: "/important-news.png"
slug: "important-news"
---
The Highland Riding for the Disabled Group (SCIO 007357) has been operating from
Reelig Estate since 2019 and where a plan was developed to create a range of indoor
and outdoor facilities.
Due to the challenges of significant price increases across the construction sectors,
changes within the funding landscape and the groups need to focus on its core
charitable purpose, it was decided by the group that they needed to find an alternative
venue from which to operate and will terminate the lease on the 1st January 2026.
The landlords, Dan and Sarah Fraser have been supportive and patient through the
process and are able to offer a grazing license so that we have time to transition to our
new premises.
Sarah and Dan would like to add that they are saddened by the groups decision but
respect their reasons. They are committed to ensure that the remaining facility will be
available for the community to use as that was part of the original planning
permission and wish the Highland RDA Group all the best in their future
developments.

77
client/src/lib/news.ts Normal file
View file

@ -0,0 +1,77 @@
import { format } from "date-fns";
import { marked } from "marked";
export type NewsArticle = {
title: string;
date: string;
summary: string;
image?: string;
slug: string;
body: string;
html: string;
};
type Frontmatter = Partial<Pick<NewsArticle, "title" | "date" | "summary" | "image" | "slug">>;
const newsModules = import.meta.glob("/src/content/news/*.md", {
eager: true,
as: "raw",
});
const parseFrontmatter = (raw: string): { frontmatter: Frontmatter; body: string } => {
const frontmatterMatch = raw.match(/^---\s*([\s\S]*?)\s*---\s*([\s\S]*)$/);
if (!frontmatterMatch) {
return { frontmatter: {}, body: raw.trim() };
}
const [, frontmatterBlock, body] = frontmatterMatch;
const frontmatter: Frontmatter = {};
frontmatterBlock.split("\n").forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const [key, ...rest] = trimmed.split(":");
if (!key || rest.length === 0) return;
const value = rest.join(":").trim().replace(/^["']|["']$/g, "");
if (key in frontmatter) return;
(frontmatter as Record<string, string>)[key.trim()] = value;
});
return { frontmatter, body: body.trim() };
};
export const newsArticles: NewsArticle[] = Object.entries(newsModules)
.map(([path, raw]) => {
const { frontmatter, body } = parseFrontmatter(raw);
const slug =
frontmatter.slug ||
path.split("/").pop()?.replace(/\.md$/, "") ||
"";
return {
title: frontmatter.title || slug,
date: frontmatter.date || "",
summary: frontmatter.summary || "",
image: frontmatter.image,
slug,
body,
html: marked.parse(body),
};
})
.sort((a, b) => {
const aDate = Date.parse(a.date);
const bDate = Date.parse(b.date);
if (Number.isNaN(aDate) || Number.isNaN(bDate)) {
return b.title.localeCompare(a.title);
}
return bDate - aDate;
});
export const getNewsArticle = (slug: string): NewsArticle | undefined =>
newsArticles.find((article) => article.slug === slug);
export const formatNewsDate = (date: string): string => {
const parsed = Date.parse(date);
if (Number.isNaN(parsed)) return date;
return format(new Date(parsed), "MMM yyyy");
};

View file

@ -53,18 +53,18 @@ export const Desktop = (): JSX.Element => {
/> />
<div className="relative px-4 sm:px-6 pt-8 pb-6 sm:pt-[70px] sm:pb-[42px]"> <div className="relative px-4 sm:px-6 pt-8 pb-6 sm:pt-[70px] sm:pb-[42px]">
<div className="max-w-[640px] rounded-[7px] bg-black/45 px-5 py-5 sm:px-6 sm:py-6 backdrop-blur-sm"> <div className="max-w-[640px] rounded-[7px] bg-black/45 px-5 py-5 sm:px-6 sm:py-6 backdrop-blur-sm">
<h1 className="[font-family:'Merriweather',Helvetica] font-bold text-white text-[34px] leading-[1.15] sm:text-[52px] sm:leading-[normal]"> <h1 className="[font-family:'Merriweather',Helvetica] font-bold text-[#f5f1e8] text-[34px] leading-[1.15] sm:text-[52px] sm:leading-[normal]">
<span className="[font-family:'Merriweather',Helvetica] font-bold text-white"> <span className="[font-family:'Merriweather',Helvetica] font-bold text-[#f5f1e8]">
Enriching lives through horses in Enriching lives through horses in
the{" "} the{" "}
</span> </span>
<span className="underline">Highlands</span> <span className="underline">Highlands</span>
<span className="[font-family:'Merriweather',Helvetica] font-bold text-white"> <span className="[font-family:'Merriweather',Helvetica] font-bold text-[#f5f1e8]">
{" "} {" "}
since{" "} since{" "}
</span> </span>
<span className="underline">1975</span> <span className="underline">1975</span>
<span className="[font-family:'Merriweather',Helvetica] font-bold text-white"> <span className="[font-family:'Merriweather',Helvetica] font-bold text-[#f5f1e8]">
. .
</span> </span>
</h1> </h1>
@ -72,7 +72,7 @@ export const Desktop = (): JSX.Element => {
</div> </div>
<div className="relative pb-16 sm:pb-[42px]"> <div className="relative pb-16 sm:pb-[42px]">
<div className="flex justify-end px-4 sm:px-6"> <div className="flex justify-end px-4 sm:px-6">
<blockquote className="max-w-[520px] rounded-[7px] bg-black/35 px-5 py-5 sm:px-6 sm:py-6 [font-family:'Merriweather',Helvetica] font-bold text-white text-lg sm:text-2xl text-left sm:text-right tracking-[0] leading-[1.3] backdrop-blur-sm"> <blockquote className="max-w-[520px] rounded-[7px] bg-black/35 px-5 py-5 sm:px-6 sm:py-6 [font-family:'Merriweather',Helvetica] font-bold text-[#f5f1e8] text-lg sm:text-2xl text-left sm:text-right tracking-[0] leading-[1.3] backdrop-blur-sm">
&quot;Our mission is to bring about &quot;Our mission is to bring about
meaningful and positive changes in the meaningful and positive changes in the
health and well-being of people with health and well-being of people with

View file

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { SiteHeader } from "@/components/SiteHeader"; import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter"; import { SiteFooter } from "@/components/SiteFooter";
import { formatNewsDate, newsArticles } from "@/lib/news";
const navigationItems = [ const navigationItems = [
{ label: "Home", href: "/" }, { label: "Home", href: "/" },
@ -10,27 +11,6 @@ const navigationItems = [
{ label: "Contact", href: "/contact" }, { label: "Contact", href: "/contact" },
]; ];
const newsItems = [
{
date: "Dec 2025",
title: "Winter sessions and holiday activities",
summary:
"Our winter timetable is live. Sessions continue with extra family days across December.",
},
{
date: "Nov 2025",
title: "Volunteer spotlight: the team behind the yard",
summary:
"A huge thank you to our volunteers who keep everything running smoothly week to week.",
},
{
date: "Oct 2025",
title: "New accessibility equipment supported by donors",
summary:
"We have added new adaptive tack to make sessions more comfortable and inclusive.",
},
];
export const News = (): JSX.Element => { export const News = (): JSX.Element => {
return ( return (
<div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]"> <div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]">
@ -67,24 +47,46 @@ export const News = (): JSX.Element => {
</div> </div>
</section> </section>
<section className="w-full max-w-[1200px] mx-auto px-4 sm:px-8 mt-8 sm:mt-12 pb-10"> <section className="w-full max-w-[1200px] mx-auto px-4 sm:px-8 mt-0 pb-0">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3"> <div className="bg-[#d6d6d6] px-6 sm:px-8 py-6 sm:py-8">
{newsItems.map((item) => ( <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<article {newsArticles.map((item) => (
key={item.title} <article
className="bg-[#d6d6d6] px-6 py-6 sm:py-8" key={item.slug}
> className="bg-[#d6d6d6] border border-black/20 px-6 py-6 sm:py-8 flex flex-col gap-4"
<p className="[font-family:'Public_Sans',Helvetica] font-semibold text-xs sm:text-sm uppercase tracking-[0.2em] text-black/70"> >
{item.date} {item.image && (
</p> <div
<h2 className="mt-3 [font-family:'Merriweather',Helvetica] font-bold text-black text-xl sm:text-2xl leading-[1.2]"> className="h-[160px] rounded-[7px] bg-cover bg-[50%_35%]"
{item.title} style={{
</h2> backgroundImage: `url(${item.image})`,
<p className="mt-3 [font-family:'Public_Sans',Helvetica] font-semibold text-black text-base leading-[1.6]"> }}
{item.summary} />
</p> )}
</article> <p className="[font-family:'Public_Sans',Helvetica] font-semibold text-xs sm:text-sm uppercase tracking-[0.2em] text-black/70">
))} {formatNewsDate(item.date)}
</p>
<div className="h-px w-full bg-black/20" />
<h2 className="mt-3 [font-family:'Merriweather',Helvetica] font-bold text-black text-xl sm:text-2xl leading-[1.2]">
<a
className="hover:underline"
href={`/news/${item.slug}`}
>
{item.title}
</a>
</h2>
<p className="mt-3 [font-family:'Public_Sans',Helvetica] font-semibold text-black text-base leading-[1.6]">
{item.summary}
</p>
<a
className="mt-auto [font-family:'Public_Sans',Helvetica] font-semibold text-black text-base underline"
href={`/news/${item.slug}`}
>
Read more
</a>
</article>
))}
</div>
</div> </div>
</section> </section>
<SiteFooter /> <SiteFooter />

View file

@ -0,0 +1,79 @@
import React from "react";
import { useRoute } from "wouter";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { formatNewsDate, getNewsArticle } from "@/lib/news";
import NotFound from "@/pages/not-found";
const navigationItems = [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "News", href: "/news" },
{ label: "Support Us", href: "/support" },
{ label: "Contact", href: "/contact" },
];
export const NewsArticle = (): JSX.Element => {
const [, params] = useRoute("/news/:slug");
const article = getNewsArticle(params?.slug ?? "");
if (!article) {
return <NotFound />;
}
return (
<div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]">
<main className="w-full">
<section className="w-full max-w-[1200px] mx-auto px-0 sm:px-8 mt-0">
<div
className="relative w-full sm:rounded-[7px] overflow-hidden bg-cover bg-[50%_35%]"
style={{
backgroundImage: article.image
? `url(${article.image})`
: "url(/news.webp)",
}}
>
<div className="absolute inset-0 bg-black/45" />
<SiteHeader
navigationItems={navigationItems}
theme="dark"
className="px-6 sm:px-4"
/>
<div className="relative px-6 sm:px-8 pb-10 pt-6 sm:pb-12 sm:pt-8">
<div className="max-w-none sm:max-w-[760px] rounded-[7px] bg-black/45 px-5 py-5 sm:px-6 sm:py-6 backdrop-blur-sm">
<p className="[font-family:'Public_Sans',Helvetica] font-semibold text-xs sm:text-sm uppercase tracking-[0.2em] text-white/90">
{formatNewsDate(article.date)}
</p>
<div className="mt-3 h-[2px] w-10 bg-white/70" />
<h1 className="mt-4 [font-family:'Merriweather',Helvetica] font-bold text-white text-[34px] sm:text-[44px] leading-[1.15]">
{article.title}
</h1>
<p className="mt-4 [font-family:'Public_Sans',Helvetica] font-semibold text-white text-base sm:text-lg leading-[1.6]">
{article.summary}
</p>
</div>
</div>
</div>
</section>
<section className="w-full max-w-[1200px] mx-auto px-4 sm:px-8 mt-0 pb-0">
<div className="bg-[#d6d6d6] px-6 sm:px-8 py-6 sm:py-8">
<div
className="prose prose-neutral max-w-[720px] text-base sm:text-lg [font-family:'Public_Sans',Helvetica] prose-headings:font-['Merriweather',Helvetica] prose-headings:font-bold prose-p:leading-[1.75] prose-li:leading-[1.75]"
dangerouslySetInnerHTML={{ __html: article.html }}
/>
<div className="mt-8">
<a
className="[font-family:'Public_Sans',Helvetica] font-semibold text-black text-base sm:text-lg underline"
href="/news"
>
Back to news
</a>
</div>
</div>
</section>
<SiteFooter />
</main>
</div>
);
};

View file

@ -0,0 +1,45 @@
import React from "react";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
const navigationItems = [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "News", href: "/news" },
{ label: "Support Us", href: "/support" },
{ label: "Contact", href: "/contact" },
];
export const Privacy = (): JSX.Element => {
return (
<div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]">
<SiteHeader
navigationItems={navigationItems}
className="w-full max-w-[1200px] mx-auto px-4 sm:px-8"
/>
<main className="w-full">
<section className="w-full max-w-[1200px] mx-auto px-4 sm:px-8 mt-8 sm:mt-12 pb-10">
<div className="bg-[#d6d6d6] px-6 sm:px-8 py-6 sm:py-8">
<p className="[font-family:'Public_Sans',Helvetica] font-semibold text-xs sm:text-sm uppercase tracking-[0.2em] text-black/70">
Privacy Policy
</p>
<h1 className="mt-4 [font-family:'Merriweather',Helvetica] font-bold text-black text-[34px] sm:text-[44px] leading-[1.15]">
Privacy Policy
</h1>
<p className="mt-4 [font-family:'Public_Sans',Helvetica] font-semibold text-black text-base sm:text-lg leading-[1.6]">
We respect your privacy and only collect the
information needed to respond to enquiries and
operate the site.
</p>
<p className="mt-4 [font-family:'Public_Sans',Helvetica] font-semibold text-black text-base sm:text-lg leading-[1.6]">
We use Cloudflare Web Analytics, which does not use
cookies or track individuals.
</p>
</div>
</section>
<SiteFooter />
</main>
</div>
);
};

13
package-lock.json generated
View file

@ -53,6 +53,7 @@
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"marked": "^17.0.1",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"passport": "^0.7.0", "passport": "^0.7.0",
@ -5642,6 +5643,18 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",

View file

@ -55,6 +55,7 @@
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"marked": "^17.0.1",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"passport": "^0.7.0", "passport": "^0.7.0",