mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 20:03:39 +00:00
style: publication
This commit is contained in:
101
apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
Normal file
101
apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user