feat: add publication

This commit is contained in:
ghost2023
2026-09-04 14:43:17 +03:00
parent ad791e2074
commit 0dd0e8d992
28 changed files with 1323 additions and 10 deletions

View File

@@ -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 />}>

View File

@@ -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}`,

View File

@@ -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;
}

View 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,
});
}

View File

@@ -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" },
],
},
{

View File

@@ -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>
);
}

View File

@@ -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" },
];