Add markdown news system and improve news UX
This commit is contained in:
parent
d5f58e0b43
commit
191070c18e
12 changed files with 317 additions and 44 deletions
|
|
@ -10,6 +10,8 @@ import { About } from "@/pages/About";
|
|||
import { News } from "@/pages/News";
|
||||
import { Contact } from "@/pages/Contact";
|
||||
import { SupportUs } from "@/pages/SupportUs";
|
||||
import { Privacy } from "@/pages/Privacy";
|
||||
import { NewsArticle } from "@/pages/NewsArticle";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
|
|
@ -18,8 +20,10 @@ function Router() {
|
|||
<Route path="/" component={Desktop} />
|
||||
<Route path="/about" component={About} />
|
||||
<Route path="/news" component={News} />
|
||||
<Route path="/news/:slug" component={NewsArticle} />
|
||||
<Route path="/support" component={SupportUs} />
|
||||
<Route path="/contact" component={Contact} />
|
||||
<Route path="/privacy" component={Privacy} />
|
||||
{/* Fallback to 404 */}
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
|
|
|||
8
client/src/content/news/2024-06-volunteers-summer.md
Normal file
8
client/src/content/news/2024-06-volunteers-summer.md
Normal 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. It’s always great to get together! Find out how you can help out by **[Volunteering for us](/support)**.
|
||||
11
client/src/content/news/2024-07-summer-awards-2024.md
Normal file
11
client/src/content/news/2024-07-summer-awards-2024.md
Normal 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. It’s 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>
|
||||
.
|
||||

|
||||

|
||||
8
client/src/content/news/2024-10-race-night.md
Normal file
8
client/src/content/news/2024-10-race-night.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: "Race Night"
|
||||
date: "2024-10-01"
|
||||
summary: "Race night"
|
||||
image: "/race-night.avif"
|
||||
slug: "race-night"
|
||||
---
|
||||

|
||||
25
client/src/content/news/2025-12-important-news.md
Normal file
25
client/src/content/news/2025-12-important-news.md
Normal 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
77
client/src/lib/news.ts
Normal 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");
|
||||
};
|
||||
|
|
@ -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="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]">
|
||||
<span className="[font-family:'Merriweather',Helvetica] font-bold text-white">
|
||||
<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-[#f5f1e8]">
|
||||
Enriching lives through horses in
|
||||
the{" "}
|
||||
</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{" "}
|
||||
</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>
|
||||
</h1>
|
||||
|
|
@ -72,7 +72,7 @@ export const Desktop = (): JSX.Element => {
|
|||
</div>
|
||||
<div className="relative pb-16 sm:pb-[42px]">
|
||||
<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">
|
||||
"Our mission is to bring about
|
||||
meaningful and positive changes in the
|
||||
health and well-being of people with
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { formatNewsDate, newsArticles } from "@/lib/news";
|
||||
|
||||
const navigationItems = [
|
||||
{ label: "Home", href: "/" },
|
||||
|
|
@ -10,27 +11,6 @@ const navigationItems = [
|
|||
{ 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 => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]">
|
||||
|
|
@ -67,24 +47,46 @@ export const News = (): JSX.Element => {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="w-full max-w-[1200px] mx-auto px-4 sm:px-8 mt-8 sm:mt-12 pb-10">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
{newsItems.map((item) => (
|
||||
<article
|
||||
key={item.title}
|
||||
className="bg-[#d6d6d6] px-6 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">
|
||||
{item.date}
|
||||
</p>
|
||||
<h2 className="mt-3 [font-family:'Merriweather',Helvetica] font-bold text-black text-xl sm:text-2xl leading-[1.2]">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="mt-3 [font-family:'Public_Sans',Helvetica] font-semibold text-black text-base leading-[1.6]">
|
||||
{item.summary}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<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="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
{newsArticles.map((item) => (
|
||||
<article
|
||||
key={item.slug}
|
||||
className="bg-[#d6d6d6] border border-black/20 px-6 py-6 sm:py-8 flex flex-col gap-4"
|
||||
>
|
||||
{item.image && (
|
||||
<div
|
||||
className="h-[160px] rounded-[7px] bg-cover bg-[50%_35%]"
|
||||
style={{
|
||||
backgroundImage: `url(${item.image})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</section>
|
||||
<SiteFooter />
|
||||
|
|
|
|||
79
client/src/pages/NewsArticle.tsx
Normal file
79
client/src/pages/NewsArticle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
45
client/src/pages/Privacy.tsx
Normal file
45
client/src/pages/Privacy.tsx
Normal 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
13
package-lock.json
generated
|
|
@ -53,6 +53,7 @@
|
|||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"marked": "^17.0.1",
|
||||
"memorystore": "^1.6.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"passport": "^0.7.0",
|
||||
|
|
@ -5642,6 +5643,18 @@
|
|||
"@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": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"marked": "^17.0.1",
|
||||
"memorystore": "^1.6.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"passport": "^0.7.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue