style: publication

This commit is contained in:
ghost2023
2026-09-04 15:41:11 +03:00
parent d60923dfee
commit bac9c24a47
4 changed files with 485 additions and 187 deletions

View File

@@ -0,0 +1,101 @@
import { ArrowRight, Menu, TrainFront } from "lucide-react";
import { Link } from "react-router-dom";
/**
* Top-level navigation for the public marketing pages. Entries beginning with
* `#` scroll within the landing page; entries beginning with `/` are real
* routes and need router navigation.
*/
export const navLinks = [
{ label: "Features", href: "#features" },
{ label: "Live ops", href: "#showcase" },
{ label: "Corridors", href: "#corridors" },
{ label: "How it works", href: "#how" },
{ label: "Publications", href: "/publications" },
{ label: "Contact", href: "#contact" },
];
/**
* The dark navbar shared by every public page that is not behind the app
* shell — the landing page and /publications. Extracted from the landing page
* so the two cannot drift: a link added here shows up on both.
*
* The anchor entries only resolve on the landing page itself, so away from it
* they are rendered as links back to the homepage's section instead of as
* same-page anchors that would go nowhere.
*/
export function PublicNavbar({ onLanding = false }: { onLanding?: boolean }) {
return (
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
<Link to="/" className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
<TrainFront className="size-5" />
</span>
<span className="leading-tight">
<span className="block text-lg font-bold text-white">EDR Freight</span>
<span className="block text-[11px] tracking-wide text-slate-400">
Rail Logistics Platform
</span>
</span>
</Link>
<nav className="hidden items-center gap-9 lg:flex">
{navLinks.map((link) => {
const className =
"text-sm font-medium text-slate-300 transition hover:text-white";
// A route always navigates. An anchor only works on the landing
// page; elsewhere it has to go home first, or clicking it does
// nothing at all.
if (link.href.startsWith("/")) {
return (
<Link key={link.href} to={link.href} className={className}>
{link.label}
</Link>
);
}
return onLanding ? (
<a key={link.href} href={link.href} className={className}>
{link.label}
</a>
) : (
<Link key={link.href} to={`/${link.href}`} className={className}>
{link.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-3">
<Link
to="/login"
className="hidden rounded-lg border border-white/20 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-white/10 md:block"
>
Log in
</Link>
<Link
to="/signup"
className="hidden items-center gap-2 rounded-lg bg-edr-primary px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 transition hover:-translate-y-0.5 hover:bg-edr-primary-dark md:flex"
>
Get started
<ArrowRight className="size-4" />
</Link>
<button
type="button"
aria-label="Open menu"
className="rounded-lg border border-white/20 p-2 text-white lg:hidden"
>
<Menu className="size-5" />
</button>
</div>
</div>
</header>
);
}
export default PublicNavbar;

View File

@@ -14,7 +14,6 @@ import {
Mail,
Map as MapIcon,
MapPin,
Menu,
Package,
PackageSearch,
Phone,
@@ -30,6 +29,8 @@ import {
Truck,
} from "lucide-react";
import { PublicNavbar } from "@/components/PublicNavbar";
/* ------------------------------------------------------------------ */
/* Motion helpers */
/* ------------------------------------------------------------------ */
@@ -137,15 +138,6 @@ function CountUp({
/* Content */
/* ------------------------------------------------------------------ */
const navLinks = [
{ label: "Features", href: "#features" },
{ label: "Live ops", href: "#showcase" },
{ label: "Corridors", href: "#corridors" },
{ label: "How it works", href: "#how" },
{ label: "Publications", href: "/publications" },
{ label: "Contact", href: "#contact" },
];
const heroTrust = [
"Telebirr & CBE Birr payments",
"Fayda ID verified",
@@ -518,72 +510,7 @@ export default function EDRFreightLandingPage() {
<div className="min-h-screen bg-background text-foreground">
<LandingStyles />
{/* Navbar */}
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
<Link to="/" className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
<TrainFront className="size-5" />
</span>
<span className="leading-tight">
<span className="block text-lg font-bold text-white">EDR Freight</span>
<span className="block text-[11px] tracking-wide text-slate-400">
Rail Logistics Platform
</span>
</span>
</Link>
<nav className="hidden items-center gap-9 lg:flex">
{navLinks.map((link) =>
// Same-page anchors (#features) scroll; a route (/publications)
// needs router navigation instead.
link.href.startsWith("/") ? (
<Link
key={link.href}
to={link.href}
className="text-sm font-medium text-slate-300 transition hover:text-white"
>
{link.label}
</Link>
) : (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-slate-300 transition hover:text-white"
>
{link.label}
</a>
),
)}
</nav>
<div className="flex items-center gap-3">
<Link
to="/login"
className="hidden rounded-lg border border-white/20 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-white/10 md:block"
>
Log in
</Link>
<Link
to="/signup"
className="hidden items-center gap-2 rounded-lg bg-edr-primary px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 transition hover:-translate-y-0.5 hover:bg-edr-primary-dark md:flex"
>
Get started
<ArrowRight className="size-4" />
</Link>
<button
type="button"
aria-label="Open menu"
className="rounded-lg border border-white/20 p-2 text-white lg:hidden"
>
<Menu className="size-5" />
</button>
</div>
</div>
</header>
<PublicNavbar onLanding />
{/* Hero */}
<section className="edr-hero relative overflow-hidden bg-edr-ink">

View File

@@ -1,38 +1,55 @@
import type { PublicationSummary } from "@edr/types";
import { useFileViewer } from "@edr/ui-common";
import { FileText, Presentation, Download, X } from "lucide-react";
import { useState } from "react";
import {
Download,
FileText,
Library,
Presentation,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PublicNavbar } from "@/components/PublicNavbar";
import { publicationFileUrl } from "@/constants/apiConfig";
import { usePublications } from "@/hooks/usePublications";
import { Markdown } from "../support/Markdown";
import { DocShell } from "../support/DocShell";
import { DocFooter } from "../support/DocShell";
const MARKDOWN_MIMES = new Set(["text/markdown", "text/x-markdown"]);
function isMarkdown(pub: PublicationSummary): boolean {
return MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md");
type PublicationKind = "pdf" | "markdown" | "slides" | "other";
function kindOf(pub: PublicationSummary): PublicationKind {
if (MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md")) {
return "markdown";
}
if (pub.fileMimeType === "application/pdf") return "pdf";
if (
pub.fileMimeType.includes("powerpoint") ||
pub.fileMimeType.includes("presentationml")
) {
return "slides";
}
return "other";
}
function fileKindIcon(pub: PublicationSummary) {
if (isMarkdown(pub)) return FileText;
if (pub.fileMimeType.includes("powerpoint") || pub.fileMimeType.includes("presentationml")) {
return Presentation;
}
return FileText;
}
function fileKindLabel(pub: PublicationSummary): string {
if (pub.fileMimeType === "application/pdf") return "PDF";
if (isMarkdown(pub)) return "Markdown";
if (pub.fileMimeType.includes("powerpoint") || pub.fileMimeType.includes("presentationml")) {
return "PowerPoint";
}
return "Document";
}
const KIND_META: Record<
PublicationKind,
{ label: string; icon: typeof FileText; accent: string }
> = {
pdf: { label: "PDF", icon: FileText, accent: "from-rose-500/15 to-rose-500/5" },
markdown: { label: "Markdown", icon: FileText, accent: "from-sky-500/15 to-sky-500/5" },
slides: {
label: "PowerPoint",
icon: Presentation,
accent: "from-amber-500/15 to-amber-500/5",
},
other: { label: "Document", icon: FileText, accent: "from-slate-500/15 to-slate-500/5" },
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -40,31 +57,235 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(value: string | null): string | null {
if (!value) return null;
return new Date(value).toLocaleDateString(undefined, {
month: "short",
year: "numeric",
});
}
/** True once the element has scrolled into view — and stays true afterwards. */
function useInView<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || seen) return;
if (typeof IntersectionObserver === "undefined") {
setSeen(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setSeen(true);
observer.disconnect();
}
},
{ rootMargin: "200px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [seen]);
return { ref, seen };
}
/**
* The card's preview panel.
*
* A PDF renders its own first page in a muted, non-interactive iframe, and a
* Markdown file shows the opening lines of its actual text. Both only load
* once the card is near the viewport — a grid of publications would otherwise
* pull every file on first paint.
*
* Slides get a drawn cover rather than a real thumbnail: the Office Online
* viewer is the only thing that can rasterise a .pptx here, and embedding it
* per card is far too heavy for a listing. Clicking through still opens the
* real thing.
*/
function PublicationPreview({ pub }: { pub: PublicationSummary }) {
const kind = kindOf(pub);
const { ref, seen } = useInView<HTMLDivElement>();
const [excerpt, setExcerpt] = useState<string | null>(null);
useEffect(() => {
if (kind !== "markdown" || !seen || excerpt !== null) return;
let cancelled = false;
void fetch(publicationFileUrl(pub.id))
.then((response) => response.text())
.then((text) => {
if (!cancelled) setExcerpt(text.slice(0, 600));
})
.catch(() => {
// A failed preview is cosmetic — the card still opens and downloads.
if (!cancelled) setExcerpt("");
});
return () => {
cancelled = true;
};
}, [kind, seen, excerpt, pub.id]);
const { icon: Icon, accent } = KIND_META[kind];
return (
<div
ref={ref}
className={`relative h-44 overflow-hidden border-b border-border bg-gradient-to-br ${accent}`}
>
{kind === "pdf" && seen ? (
<iframe
// Chrome's built-in PDF viewer honours these; the fragment keeps the
// toolbar and scrollbars out of what is meant to read as a cover.
src={`${publicationFileUrl(pub.id)}#page=1&toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
title={`${pub.title} preview`}
tabIndex={-1}
aria-hidden
// The iframe is decoration: clicks belong to the card's buttons.
className="pointer-events-none absolute inset-x-0 top-0 h-[220%] w-full origin-top scale-[0.62] border-0"
/>
) : kind === "markdown" ? (
<div
aria-hidden
className="pointer-events-none absolute inset-0 origin-top-left scale-[0.78] overflow-hidden p-4"
>
{excerpt ? (
<Markdown>{excerpt}</Markdown>
) : (
<div className="space-y-2 pt-2">
{[92, 78, 85, 60].map((width, index) => (
<div
key={index}
className="h-2.5 rounded-full bg-foreground/10"
style={{ width: `${width}%` }}
/>
))}
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center">
<Icon className="size-14 text-foreground/25" strokeWidth={1.25} />
</div>
)}
{/* Fades the preview into the card body so a clipped page doesn't end
on a hard edge. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-background to-transparent" />
</div>
);
}
function PublicationCard({
pub,
onOpen,
}: {
pub: PublicationSummary;
onOpen: (pub: PublicationSummary) => void;
}) {
const kind = kindOf(pub);
const meta = KIND_META[kind];
const published = formatDate(pub.publishedAt);
return (
<article className="group flex flex-col overflow-hidden rounded-3xl border border-border bg-background shadow-sm transition hover:-translate-y-1 hover:border-primary/40 hover:shadow-xl">
<PublicationPreview pub={pub} />
<div className="flex flex-1 flex-col p-6">
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1">
<meta.icon className="size-3" />
{meta.label}
</Badge>
{pub.category ? <Badge variant="secondary">{pub.category}</Badge> : null}
</div>
<h3 className="mt-3 text-lg font-bold leading-snug tracking-tight">
{pub.title}
</h3>
{pub.description ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
{pub.description}
</p>
) : null}
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatSize(pub.fileSizeBytes)}</span>
{published ? (
<>
<span aria-hidden>·</span>
<span>{published}</span>
</>
) : null}
</div>
<div className="mt-6 flex gap-2 pt-0">
<Button className="flex-1" onClick={() => onOpen(pub)}>
{kind === "markdown" ? "Read" : "Preview"}
</Button>
<Button variant="outline" size="icon" asChild>
<a
href={publicationFileUrl(pub.id, true)}
aria-label={`Download ${pub.title}`}
>
<Download className="size-4" />
</a>
</Button>
</div>
</div>
</article>
);
}
/**
* Public library of platform documentation: PDFs, Markdown write-ups and
* PowerPoint decks, curated from the backoffice. No login required — same
* chrome as /help, /faq and the legal pages.
* PowerPoint decks, curated from the backoffice. No login required.
*
* Markdown gets a real in-page reader (`Markdown`, the same renderer the
* legal pages use) rather than routing through the generic `FileViewerModal`,
* whose "text" kind is a raw iframe with no markdown rendering. PDF and
* PowerPoint use that shared viewer as-is.
* Carries the marketing navbar rather than {@link DocShell}'s plain doc
* header — this page is something a prospect is pointed at, so it should sit
* inside the same chrome as the landing page it is linked from.
*
* Markdown opens in an in-page reader using the same renderer the legal pages
* use; everything else goes through the shared `FileViewerModal`, whose
* "text" kind is a raw iframe with no markdown rendering.
*/
export default function PublicationsPage() {
const { data: publications, isLoading } = usePublications();
const { view, viewer } = useFileViewer();
const [reading, setReading] = useState<PublicationSummary | null>(null);
const [markdownText, setMarkdownText] = useState<string | null>(null);
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !publications) return publications ?? [];
return publications.filter(
(pub) =>
pub.title.toLowerCase().includes(q) ||
(pub.description ?? "").toLowerCase().includes(q) ||
(pub.category ?? "").toLowerCase().includes(q),
);
}, [publications, query]);
const openMarkdown = async (pub: PublicationSummary) => {
setReading(pub);
setMarkdownText(null);
const response = await fetch(publicationFileUrl(pub.id));
setMarkdownText(await response.text());
try {
const response = await fetch(publicationFileUrl(pub.id));
setMarkdownText(await response.text());
} catch {
setMarkdownText("Sorry — this document could not be loaded. Try downloading it.");
}
};
const open = (pub: PublicationSummary) => {
if (isMarkdown(pub)) {
if (kindOf(pub) === "markdown") {
void openMarkdown(pub);
return;
}
@@ -75,80 +296,118 @@ export default function PublicationsPage() {
});
};
// Escape closes the markdown reader, like the shared viewer's modal.
useEffect(() => {
if (!reading) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setReading(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [reading]);
return (
<DocShell
current="/publications"
title="Publications"
subtitle="Guides, reports and presentations about the EDR Freight platform — open them here or download a copy."
>
{isLoading ? (
<p className="text-muted-foreground">Loading</p>
) : !publications || publications.length === 0 ? (
<p className="text-muted-foreground">Nothing published yet check back soon.</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{publications.map((pub) => {
const Icon = fileKindIcon(pub);
return (
<div
key={pub.id}
className="flex flex-col rounded-[32px] border border-border bg-background p-6 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="flex size-11 items-center justify-center rounded-2xl bg-accent text-foreground">
<Icon className="size-5" />
</div>
<Badge variant="outline">{fileKindLabel(pub)}</Badge>
</div>
<div className="min-h-screen bg-background text-foreground">
<PublicNavbar />
<h3 className="mt-4 font-bold tracking-tight">{pub.title}</h3>
{pub.description ? (
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
{pub.description}
</p>
) : null}
{/* Hero, in the landing page's dark band so the navbar sits on the tone
it was designed for. */}
<section className="border-b border-white/10 bg-edr-ink">
<div className="mx-auto max-w-6xl px-6 py-16">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 py-1.5 pl-2 pr-3 text-[13px] font-medium text-white">
<Library className="size-4" />
Resource library
</span>
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
{pub.category ? <span>{pub.category}</span> : null}
{pub.category ? <span aria-hidden>·</span> : null}
<span>{formatSize(pub.fileSizeBytes)}</span>
</div>
<h1 className="mt-5 text-4xl font-black tracking-tight text-white sm:text-5xl">
Publications
</h1>
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-300">
Guides, reports and presentations about the EDR Freight platform
read them here or download a copy.
</p>
<div className="mt-6 flex gap-2">
<Button className="flex-1" onClick={() => open(pub)}>
{isMarkdown(pub) ? "Read" : "View"}
</Button>
<Button variant="outline" size="icon" asChild>
<a href={publicationFileUrl(pub.id, true)} aria-label="Download">
<Download className="size-4" />
</a>
</Button>
</div>
</div>
);
})}
<div className="relative mt-8 max-w-md">
<Search className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search publications…"
aria-label="Search publications"
className="w-full rounded-xl border border-white/15 bg-white/5 py-3 pl-11 pr-4 text-sm text-white placeholder:text-slate-400 focus:border-edr-primary focus:outline-none"
/>
</div>
</div>
)}
</section>
{/* PDF / office-file preview for everything except Markdown. */}
<main className="mx-auto max-w-6xl px-6 py-14">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="h-96 animate-pulse rounded-3xl border border-border bg-accent/40"
/>
))}
</div>
) : filtered.length === 0 ? (
<div className="rounded-3xl border border-dashed border-border py-20 text-center">
<Library className="mx-auto size-10 text-muted-foreground/50" strokeWidth={1.25} />
<p className="mt-4 font-semibold">
{query.trim() ? "No publications match that search." : "Nothing published yet."}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{query.trim() ? "Try a different word." : "Check back soon."}
</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((pub) => (
<PublicationCard key={pub.id} pub={pub} onOpen={open} />
))}
</div>
)}
</main>
<DocFooter current="/publications" />
{/* PDF / slide preview for everything except Markdown. */}
{viewer}
{/* In-page markdown reader — mirrors the legal pages' rendering. */}
{reading ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-[32px] bg-background shadow-xl">
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="font-bold">{reading.title}</h2>
<button
type="button"
onClick={() => setReading(null)}
aria-label="Close"
className="rounded-full p-1 hover:bg-accent"
>
<X className="size-5" />
</button>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label={reading.title}
onClick={() => setReading(null)}
>
<div
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-3xl bg-background shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2 className="truncate font-bold">{reading.title}</h2>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={publicationFileUrl(reading.id, true)}>
<Download className="size-4" />
Download
</a>
</Button>
<button
type="button"
onClick={() => setReading(null)}
aria-label="Close"
className="rounded-full p-1.5 transition hover:bg-accent"
>
<X className="size-5" />
</button>
</div>
</div>
<div className="overflow-y-auto px-6 py-4">
<div className="overflow-y-auto px-8 py-6">
{markdownText === null ? (
<p className="text-muted-foreground">Loading</p>
) : (
@@ -158,6 +417,6 @@ export default function PublicationsPage() {
</div>
</div>
) : null}
</DocShell>
</div>
);
}

View File

@@ -79,26 +79,37 @@ export function DocShell({
)}
</main>
<footer className="border-t border-border py-8">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<span>© 2026 EDR Freight. All rights reserved.</span>
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
<Link
key={link.to}
to={link.to}
className="font-semibold text-foreground transition-colors hover:text-primary"
>
{link.label}
</Link>
))}
</nav>
</div>
</footer>
<DocFooter current={current} />
</div>
);
}
/**
* Footer shared by every public doc page, cross-linking the others. Exported
* so /publications can carry it under the marketing navbar without also
* inheriting {@link DocShell}'s own header.
*/
export function DocFooter({ current }: { current: string }) {
return (
<footer className="border-t border-border py-8">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<span>© 2026 EDR Freight. All rights reserved.</span>
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
<Link
key={link.to}
to={link.to}
className="font-semibold text-foreground transition-colors hover:text-primary"
>
{link.label}
</Link>
))}
</nav>
</div>
</footer>
);
}
/**
* Renders a legal document's numbered sections. Bodies are markdown, so the
* paragraph and bullet arrays this used to walk are one string now — keyed by