Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,538 changes: 777 additions & 761 deletions web/package-lock.json

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions web/src/app/articles/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getArticleById, getRelatedArticles, articles } from "@/lib/article-data";
import ArticlePageClient from "@/components/articles/ArticlePageClient";

interface PageProps {
params: Promise<{ id: string }>;
}

// Pre-generate static paths for all known articles at build time
export async function generateStaticParams() {
return articles.map((article) => ({ id: article.id }));
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const article = getArticleById(id);

if (!article) {
return {
title: "Article Not Found | UltimateHealth",
description: "The requested article could not be found.",
};
}

return {
title: `${article.title} | UltimateHealth`,
description: article.excerpt,
keywords: article.tags.join(", "),
authors: [{ name: article.author.name }],
openGraph: {
title: article.title,
description: article.excerpt,
type: "article",
publishedTime: article.publishedAt,
modifiedTime: article.updatedAt,
authors: [article.author.name],
tags: article.tags,
...(article.imageUrl
? { images: [{ url: article.imageUrl, alt: article.imageAlt }] }
: {}),
},
twitter: {
card: "summary_large_image",
title: article.title,
description: article.excerpt,
...(article.imageUrl ? { images: [article.imageUrl] } : {}),
},
};
}

/**
* Article detail page.
* Resolves the article by [id], generates rich metadata, and delegates
* rendering to the client-side ArticlePageClient component.
*/
export default async function ArticlePage({ params }: PageProps) {
const { id } = await params;
const article = getArticleById(id);

if (!article) {
notFound();
}

const relatedArticles = getRelatedArticles(id, 3);

return (
<ArticlePageClient article={article} relatedArticles={relatedArticles} />
);
}
131 changes: 131 additions & 0 deletions web/src/app/articles/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Metadata } from "next";
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { withBasePath } from "@/lib/basePath";
import { articles } from "@/lib/article-data";
import type { Article } from "@/types/article";

export const metadata: Metadata = {
title: "Health Articles | UltimateHealth",
description:
"Browse evidence-based health and wellness articles written by medical professionals on UltimateHealth.",
};

export default function ArticlesPage() {
return (
<main className="min-h-screen bg-white">
{/* ── Header ── */}
<header className="border-b border-gray-100">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<Link
href={withBasePath("/")}
className="font-extrabold text-lg"
style={{ background: "linear-gradient(135deg,#667eea,#764ba2)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}
>
UltimateHealth
</Link>
<Link
href={withBasePath("/medical-glossary")}
className="text-sm font-semibold text-slate-600 hover:text-[#667eea] transition-colors"
>
Medical Glossary
</Link>
</div>
</header>

{/* ── Hero ── */}
<section className="bg-gradient-to-br from-[#667eea] to-[#764ba2] py-16 px-4">
<div className="max-w-3xl mx-auto text-center">
<p className="text-xs font-bold uppercase tracking-widest text-white/70 mb-3">
Health Knowledge Hub
</p>
<h1 className="text-4xl sm:text-5xl font-extrabold text-white leading-tight mb-4">
Health Articles
</h1>
<p className="text-lg text-white/85 max-w-xl mx-auto">
Evidence-based articles written by medical professionals — covering
cardiovascular health, nutrition, mental wellness, and more.
</p>
</div>
</section>

{/* ── Articles grid ── */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-14">
<p className="text-sm text-slate-500 mb-8">
{articles.length} articles
</p>

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</section>

{/* ── Footer ── */}
<footer className="border-t border-gray-100 py-8 text-center text-sm text-slate-400">
<Link href={withBasePath("/")} className="hover:text-[#667eea] transition-colors">
← Back to UltimateHealth
</Link>
</footer>
</main>
);
}

function ArticleCard({ article }: { article: Article }) {
const date = format(parseISO(article.publishedAt), "MMM d, yyyy");

return (
<Link
href={withBasePath(`/articles/${article.id}`)}
className="group flex flex-col bg-white rounded-2xl border border-gray-100 overflow-hidden hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
>
{/* Thumbnail / gradient */}
<div className="relative aspect-video overflow-hidden bg-gradient-to-br from-[#667eea]/20 to-[#764ba2]/30 flex items-center justify-center">
<span className="text-5xl opacity-60" aria-hidden="true">
{CATEGORY_ICONS[article.category] ?? "📋"}
</span>
<span className="absolute top-3 left-3 text-[10px] font-bold uppercase tracking-widest text-white bg-black/35 backdrop-blur-sm px-2.5 py-1 rounded-full">
{article.category}
</span>
</div>

{/* Body */}
<div className="flex flex-col flex-1 p-5">
<h2 className="font-bold text-[#1a202c] leading-snug line-clamp-2 mb-2 group-hover:text-[#667eea] transition-colors text-base">
{article.title}
</h2>
<p className="text-sm text-slate-500 line-clamp-2 leading-relaxed flex-1 mb-4">
{article.excerpt}
</p>

<div className="flex items-center justify-between text-xs text-slate-400 pt-3 border-t border-gray-50">
<div className="flex items-center gap-2">
<span
className="w-6 h-6 rounded-full flex items-center justify-center text-white font-bold text-[10px]"
style={{ background: article.author.avatarColor }}
>
{article.author.avatarInitials}
</span>
<span>{article.author.name}</span>
</div>
<div className="flex items-center gap-2">
<time dateTime={article.publishedAt}>{date}</time>
<span>·</span>
<span>{article.readingTime}</span>
</div>
</div>
</div>
</Link>
);
}

const CATEGORY_ICONS: Record<string, string> = {
"Cardiovascular Health": "❤️",
"Diabetes Management": "🩺",
"Mental Health": "🧠",
Nutrition: "🥗",
"Sleep Health": "😴",
Fitness: "🏃",
Wellness: "✨",
};
7 changes: 4 additions & 3 deletions web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,10 +509,10 @@ const moveSlider = (ref: RefObject<HTMLDivElement | null>, dir: number) => {
</a>
</li>
<li>
<a href="https://uhsocial.in/docs" target="_blank" rel="noreferrer" className="nav-link-item">
<Link href={withBasePath("/articles")} className="nav-link-item">
<i className="fas fa-file-lines nav-item-icon" aria-hidden="true"></i>
<span className="nav-item-text">Read Articles</span>
</a>
</Link>
</li>
<li>
<Link href={withBasePath("/medical-glossary")} className="nav-link-item">
Expand Down Expand Up @@ -543,7 +543,7 @@ const moveSlider = (ref: RefObject<HTMLDivElement | null>, dir: number) => {
<a href="#screenshots" onClick={() => setMobileMenuOpen(false)}>Screenshots</a>
<a href="#features" onClick={() => setMobileMenuOpen(false)}>Platform Highlights</a>
<a href="#programs" onClick={() => setMobileMenuOpen(false)}>Community Programs</a>
<a href="https://uhsocial.in/docs" target="_blank" rel="noreferrer">Read Articles</a>
<Link href={withBasePath("/articles")} onClick={() => setMobileMenuOpen(false)}>Read Articles</Link>
<Link href={withBasePath("/medical-glossary")} onClick={() => setMobileMenuOpen(false)}>Medical Glossary</Link>
<Link href={withBasePath("/contribute")} onClick={() => setMobileMenuOpen(false)}>Join Us to Contribute</Link>
<a href="#downloads" onClick={() => setMobileMenuOpen(false)}>Login / Register</a>
Expand Down Expand Up @@ -926,6 +926,7 @@ const moveSlider = (ref: RefObject<HTMLDivElement | null>, dir: number) => {
<a href="#programs">Programs</a>
<a href="#screenshots">Screenshots</a>
<a href="#contact">Contact</a>
<Link href={withBasePath("/articles")}>Health Articles</Link>
<Link href={withBasePath("/contribute")}>Join Us &amp; Contribute</Link>
</div>

Expand Down
132 changes: 132 additions & 0 deletions web/src/components/articles/AccessibilityControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useState } from "react";

export type FontSize = "sm" | "md" | "lg";

interface AccessibilityControlsProps {
fontSize: FontSize;
onFontSizeChange: (size: FontSize) => void;
}

const FONT_OPTIONS: { value: FontSize; label: string; ariaLabel: string; textClass: string }[] = [
{ value: "sm", label: "A", ariaLabel: "Small text", textClass: "text-xs" },
{ value: "md", label: "A", ariaLabel: "Default text size", textClass: "text-sm" },
{ value: "lg", label: "A", ariaLabel: "Large text", textClass: "text-base" },
];

/**
* Floating accessibility control panel for article reading preferences.
*
* FUTURE expansion hooks:
* - High contrast mode toggle
* - Text-to-speech playback controls
* - Line spacing adjustment
* - Font family switch (serif / sans-serif)
* - Bookmark current reading position
*/
export default function AccessibilityControls({
fontSize,
onFontSizeChange,
}: AccessibilityControlsProps) {
const [isOpen, setIsOpen] = useState(false);

return (
<div
className="fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2"
aria-label="Accessibility settings"
>
{/* Control panel */}
{isOpen && (
<div
role="dialog"
aria-label="Article accessibility options"
className="bg-white rounded-2xl shadow-xl border border-gray-100 p-4 w-[220px] animate-in fade-in slide-in-from-bottom-2 duration-200"
>
{/* Text size */}
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-2">
Text Size
</p>
<div
className="flex gap-1.5 items-end"
role="group"
aria-label="Select text size"
>
{FONT_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onFontSizeChange(opt.value)}
aria-pressed={fontSize === opt.value}
aria-label={opt.ariaLabel}
className={[
"flex-1 py-2 rounded-xl border-2 font-bold transition-all duration-150",
opt.textClass,
fontSize === opt.value
? "border-[#667eea] bg-[#667eea]/10 text-[#667eea]"
: "border-gray-200 text-gray-500 hover:border-gray-300",
].join(" ")}
>
{opt.label}
</button>
))}
</div>
</div>

{/* Divider */}
<hr className="my-3 border-gray-100" />

{/* Future features — placeholders */}
<div className="space-y-2">
<button
disabled
title="Coming soon"
className="w-full flex items-center gap-2.5 text-left px-1 py-1 rounded text-xs text-gray-400 cursor-not-allowed"
>
<span aria-hidden="true">🔊</span>
<span>Text to Speech</span>
<span className="ml-auto text-[10px] bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</button>
<button
disabled
title="Coming soon"
className="w-full flex items-center gap-2.5 text-left px-1 py-1 rounded text-xs text-gray-400 cursor-not-allowed"
>
<span aria-hidden="true">🔖</span>
<span>Bookmark Article</span>
<span className="ml-auto text-[10px] bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</button>
<button
disabled
title="Coming soon"
className="w-full flex items-center gap-2.5 text-left px-1 py-1 rounded text-xs text-gray-400 cursor-not-allowed"
>
<span aria-hidden="true">🌙</span>
<span>High Contrast</span>
<span className="ml-auto text-[10px] bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</button>
</div>
</div>
)}

{/* Toggle button */}
<button
onClick={() => setIsOpen((o) => !o)}
aria-label={isOpen ? "Close accessibility settings" : "Open accessibility settings"}
aria-expanded={isOpen}
aria-haspopup="dialog"
className="w-12 h-12 bg-white rounded-full shadow-md border border-gray-200 flex items-center justify-center hover:shadow-lg hover:border-[#667eea]/30 transition-all duration-150 focus-visible:ring-2 focus-visible:ring-[#667eea]"
>
<span className="text-lg" aria-hidden="true">
</span>
</button>
</div>
);
}
Loading
Loading