mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
feat: add publication
This commit is contained in:
@@ -78,6 +78,7 @@ import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
import TermsPage from "./pages/support/TermsPage";
|
||||
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
function FullScreenSpinner() {
|
||||
@@ -427,6 +428,7 @@ const App = () => {
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route path="/publications" element={<PublicationsPage />} />
|
||||
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
|
||||
@@ -229,6 +229,10 @@ export const URL_CONSTANTS = {
|
||||
PUBLIC: "/api/support-content",
|
||||
},
|
||||
|
||||
PUBLICATIONS: {
|
||||
PUBLIC: "/api/publications",
|
||||
},
|
||||
|
||||
EMPTY_RETURN_REQUESTS: {
|
||||
BASE: "/api/empty-return-requests",
|
||||
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
|
||||
|
||||
@@ -11,3 +11,13 @@ export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL that streams a public publication's file through the API by its UUID.
|
||||
* Same reasoning as `fileViewUrl`: a presigned MinIO URL is not reachable from
|
||||
* the browser here, so the bytes are streamed through the API instead.
|
||||
*/
|
||||
export function publicationFileUrl(id: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/publications/${id}/file`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { PublicationSummary } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
import { client } from "@/utils/api";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
/**
|
||||
* The public /publications library — PDFs, Markdown write-ups and PowerPoint
|
||||
* decks about the platform. Unauthenticated, same as `usePortalContent`; the
|
||||
* shared axios client only attaches a token when the cookie exists.
|
||||
*/
|
||||
export function usePublications() {
|
||||
return useQuery({
|
||||
queryKey: ["publications"],
|
||||
queryFn: async (): Promise<PublicationSummary[]> => {
|
||||
const response = await client.get<ApiResponse<PublicationSummary[]>>(
|
||||
URL_CONSTANTS.PUBLICATIONS.PUBLIC,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -142,6 +142,7 @@ const navLinks = [
|
||||
{ label: "Live ops", href: "#showcase" },
|
||||
{ label: "Corridors", href: "#corridors" },
|
||||
{ label: "How it works", href: "#how" },
|
||||
{ label: "Publications", href: "/publications" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
];
|
||||
|
||||
@@ -534,15 +535,27 @@ export default function EDRFreightLandingPage() {
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-9 lg:flex">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
{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">
|
||||
@@ -1248,6 +1261,7 @@ export default function EDRFreightLandingPage() {
|
||||
links: [
|
||||
{ label: "Help & Support", to: "/help" },
|
||||
{ label: "FAQ", to: "/faq" },
|
||||
{ label: "Publications", to: "/publications" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { publicationFileUrl } from "@/constants/apiConfig";
|
||||
import { usePublications } from "@/hooks/usePublications";
|
||||
|
||||
import { Markdown } from "../support/Markdown";
|
||||
import { DocShell } 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");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 openMarkdown = async (pub: PublicationSummary) => {
|
||||
setReading(pub);
|
||||
setMarkdownText(null);
|
||||
const response = await fetch(publicationFileUrl(pub.id));
|
||||
setMarkdownText(await response.text());
|
||||
};
|
||||
|
||||
const open = (pub: PublicationSummary) => {
|
||||
if (isMarkdown(pub)) {
|
||||
void openMarkdown(pub);
|
||||
return;
|
||||
}
|
||||
view({
|
||||
name: pub.fileName,
|
||||
url: publicationFileUrl(pub.id),
|
||||
mimeType: pub.fileMimeType,
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* PDF / office-file 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>
|
||||
<div className="overflow-y-auto px-6 py-4">
|
||||
{markdownText === null ? (
|
||||
<p className="text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<Markdown>{markdownText}</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DocShell>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
||||
const DOC_LINKS = [
|
||||
{ to: "/help", label: "Help & Support" },
|
||||
{ to: "/faq", label: "FAQ" },
|
||||
{ to: "/publications", label: "Publications" },
|
||||
{ to: "/privacy", label: "Privacy Policy" },
|
||||
{ to: "/terms", label: "Terms of Service" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user