Merge pull request #1498 from Tria-plc/feat/publications

Feat/publications
This commit is contained in:
Nathnael Wondisha
2026-09-04 22:58:28 +03:00
committed by GitHub
29 changed files with 1691 additions and 80 deletions

View File

@@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { PublicationsModule } from "./modules/publications/publications.module";
import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
@@ -231,6 +232,7 @@ if (!process.env.APPLICATION_NAME) {
LogoSettingsModule,
ContractTemplatesModule,
SupportContentModule,
PublicationsModule,
OtpModule,
HealthModule,
RuleEngineModule,

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Public document library for the freight portal (PDFs, Markdown write-ups,
* PowerPoint decks about the platform), managed from the backoffice. Each row
* is one whole file stored in MinIO under `publications/` — a re-upload
* replaces the object and the row's file columns, there is no per-version
* history table like `support_documents` has.
*/
export class Publications3850000000000 implements MigrationInterface {
name = 'Publications3850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.publications (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
title varchar(200) NOT NULL,
description text,
category varchar(60),
file_key varchar(512) NOT NULL,
file_name varchar(255) NOT NULL,
file_mime_type varchar(120) NOT NULL,
file_size_bytes bigint NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
published boolean NOT NULL DEFAULT true,
published_at timestamptz,
uploaded_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// Serves the public list: published rows in display order.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_publications_published_sort
ON freight.publications (published, sort_order)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
}
}

View File

@@ -0,0 +1,33 @@
import { Transform } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
/**
* Metadata fields for `POST /publications`, sent alongside the file as
* multipart/form-data — every field arrives as a string, so numeric/boolean
* fields need an explicit `@Transform` (global `enableImplicitConversion` is
* off, see main.ts).
*/
export class CreatePublicationDto {
@IsString()
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(60)
category?: string;
@IsOptional()
@IsInt()
@Transform(({ value }) => Number(value ?? 0))
sortOrder?: number;
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === undefined || value === "true" || value === true)
published?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreatePublicationDto } from "./create-publication.dto";
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}

View File

@@ -0,0 +1,51 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* One document in the freight portal's public library (/publications) — a
* PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and
* curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs
* edited in place, this is a real table of many rows and each upload is a
* whole new file — there is no version-history log here, a re-upload just
* replaces the file columns (see `PublicationsService.replaceFile`).
*/
@Entity({ schema: "freight", name: "publications" })
@Index(["published", "sortOrder"])
export class Publication extends BaseEntity {
@Column({ name: "title", type: "varchar", length: 200 })
title!: string;
@Column({ name: "description", type: "text", nullable: true })
description?: string | null;
@Column({ name: "category", type: "varchar", length: 60, nullable: true })
category?: string | null;
/** MinIO object key. Never a signed URL — those expire; sign on read instead. */
@Column({ name: "file_key", type: "varchar", length: 512 })
fileKey!: string;
/** Original filename, used for the download's Content-Disposition. */
@Column({ name: "file_name", type: "varchar", length: 255 })
fileName!: string;
@Column({ name: "file_mime_type", type: "varchar", length: 120 })
fileMimeType!: string;
@Column({ name: "file_size_bytes", type: "bigint" })
fileSizeBytes!: number;
/** Manual ordering in the backoffice list and the public grid. */
@Column({ name: "sort_order", type: "integer", default: 0 })
sortOrder!: number;
/** Unpublish without deleting — hides it from the public list only. */
@Column({ name: "published", type: "boolean", default: true })
published!: boolean;
@Column({ name: "published_at", type: "timestamptz", nullable: true })
publishedAt?: Date | null;
@Column({ name: "uploaded_by_id", type: "uuid", nullable: true })
uploadedById?: string | null;
}

View File

@@ -0,0 +1,51 @@
import { Public } from "@edr/api-common";
import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common";
import { Response } from "express";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { PublicationsService } from "./publications.service";
/**
* The portal's /publications page — a public library of PDFs, Markdown
* write-ups and PowerPoint decks about the platform. No login required, same
* as /help, /faq and the legal pages: prospects reach it before any account
* exists.
*/
@ApiTags("publications")
@Public()
@Controller("publications")
export class PublicPublicationsController {
constructor(private readonly service: PublicationsService) {}
@Get()
// Cheap to serve stale for a few minutes; every anonymous page view hits it.
@Header("Cache-Control", "public, max-age=300")
@ApiOperation({ summary: "List published publications for the public library" })
list() {
return this.service.listPublic();
}
@Get(":id/file")
@ApiQuery({
name: "download",
required: false,
description: "Set to 1/true to force a download instead of inline preview.",
})
@ApiOperation({ summary: "Stream a published publication's file" })
async getFile(
@Param("id", ParseUUIDPipe) id: string,
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const { stream, record } = await this.service.getPublishedFileStream(id);
const forceDownload = download === "1" || download === "true";
res.setHeader("Content-Type", record.fileMimeType);
res.setHeader(
"Content-Disposition",
`${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`,
);
res.setHeader("Cache-Control", "public, max-age=300");
stream.pipe(res);
}
}

View File

@@ -0,0 +1,76 @@
import { CurrentUser } from "@edr/api-common";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { documentUploadMulterOptions } from "../../common/document-upload.options";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreatePublicationDto } from "./dto/create-publication.dto";
import { UpdatePublicationDto } from "./dto/update-publication.dto";
import { PublicationsService } from "./publications.service";
const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
@ApiTags("publications")
@ApiBearerAuth()
@Controller("publications")
export class PublicationsController {
constructor(private readonly service: PublicationsService) {}
@Get("admin")
@BookingStaff(READ)
@ApiOperation({ summary: "List every publication, published or not" })
list() {
return this.service.list();
}
@Post()
@BookingStaff(WRITE)
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload a new publication" })
create(
@UploadedFile() file: Express.Multer.File,
@Body() dto: CreatePublicationDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.create(file, dto, user?.id ?? null);
}
@Patch(":id")
@BookingStaff(WRITE)
@ApiOperation({ summary: "Update a publication's title, description, category, order or published state" })
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) {
return this.service.update(id, dto);
}
@Post(":id/file")
@BookingStaff(WRITE)
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Replace a publication's file" })
replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) {
return this.service.replaceFile(id, file);
}
@Delete(":id")
@BookingStaff(WRITE)
@ApiOperation({ summary: "Remove a publication" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { MinioModule } from "../minio/minio.module";
import { Publication } from "./entities/publication.entity";
import { PublicationsController } from "./publications.controller";
import { PublicationsRepository } from "./publications.repository";
import { PublicationsService } from "./publications.service";
import { PublicPublicationsController } from "./public-publications.controller";
@Module({
imports: [TypeOrmModule.forFeature([Publication]), MinioModule],
controllers: [PublicPublicationsController, PublicationsController],
providers: [PublicationsRepository, PublicationsService],
exports: [PublicationsService],
})
export class PublicationsModule {}

View File

@@ -0,0 +1,29 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Publication } from "./entities/publication.entity";
@Injectable()
export class PublicationsRepository extends BaseRepository<Publication> {
constructor(
@InjectRepository(Publication)
repository: Repository<Publication>,
) {
super(repository);
}
/** Public list: published rows only, in display order. */
findPublished(): Promise<Publication[]> {
return this.repository.find({
where: { published: true },
order: { sortOrder: "ASC", publishedAt: "DESC" },
});
}
/** Admin list: every row, published or not. */
override findAll(): Promise<Publication[]> {
return this.repository.find({ order: { sortOrder: "ASC" } });
}
}

View File

@@ -0,0 +1,151 @@
import {
PublicationSummary,
PUBLICATION_ALLOWED_MIME_TYPES,
PUBLICATION_FILE_PREFIX,
} from "@edr/types";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { extname } from "path";
import { Readable } from "stream";
import { randomUUID } from "crypto";
import { MinioService } from "../minio/minio.service";
import { CreatePublicationDto } from "./dto/create-publication.dto";
import { UpdatePublicationDto } from "./dto/update-publication.dto";
import { Publication } from "./entities/publication.entity";
import { PublicationsRepository } from "./publications.repository";
@Injectable()
export class PublicationsService {
constructor(
private readonly repository: PublicationsRepository,
private readonly minio: MinioService,
) {}
private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File {
if (!file) throw new BadRequestException("No file uploaded");
if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) {
throw new BadRequestException(
`Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`,
);
}
}
async create(
file: Express.Multer.File | undefined,
dto: CreatePublicationDto,
actorId: string | null,
): Promise<Publication> {
this.assertAllowedFile(file);
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
await this.minio.uploadFile(key, file.buffer, file.mimetype);
const published = dto.published ?? true;
return this.repository.create({
title: dto.title,
description: dto.description ?? null,
category: dto.category ?? null,
fileKey: key,
fileName: file.originalname,
fileMimeType: file.mimetype,
fileSizeBytes: file.size,
sortOrder: dto.sortOrder ?? 0,
published,
publishedAt: published ? new Date() : null,
uploadedById: actorId,
});
}
async update(id: string, dto: UpdatePublicationDto): Promise<Publication> {
const existing = await this.getByIdOrThrow(id);
const patch: Partial<Publication> = {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.category !== undefined && { category: dto.category }),
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
};
if (dto.published !== undefined && dto.published !== existing.published) {
patch.published = dto.published;
patch.publishedAt = dto.published ? new Date() : null;
}
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Publication ${id} not found`);
return updated;
}
/** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */
async replaceFile(id: string, file?: Express.Multer.File): Promise<Publication> {
this.assertAllowedFile(file);
const existing = await this.getByIdOrThrow(id);
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
await this.minio.uploadFile(key, file.buffer, file.mimetype);
const updated = await this.repository.update(id, {
fileKey: key,
fileName: file.originalname,
fileMimeType: file.mimetype,
fileSizeBytes: file.size,
});
await this.minio.deleteFile(existing.fileKey);
return updated!;
}
async remove(id: string): Promise<void> {
await this.getByIdOrThrow(id);
await this.repository.softDelete(id);
}
/** Admin list — every row, published or not. */
list(): Promise<Publication[]> {
return this.repository.findAll();
}
/**
* Public list — published rows only. No file URL here: a presigned MinIO
* URL isn't reachable from the browser (see `fileViewUrl` in the portal's
* `apiConfig.ts`); the portal builds each file's URL itself from `id` via
* `GET /publications/:id/file`.
*/
async listPublic(): Promise<PublicationSummary[]> {
const rows = await this.repository.findPublished();
return rows.map((row) => this.toSummary(row));
}
private toSummary(row: Publication): PublicationSummary {
return {
id: row.id,
title: row.title,
description: row.description ?? null,
category: row.category ?? null,
fileName: row.fileName,
fileMimeType: row.fileMimeType,
fileSizeBytes: Number(row.fileSizeBytes),
sortOrder: row.sortOrder,
publishedAt: row.publishedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/** For the public/staff file route: streams a published row's bytes. */
async getPublishedFileStream(
id: string,
): Promise<{ stream: Readable; record: Publication }> {
const record = await this.repository.findById(id);
if (!record || !record.published) {
throw new NotFoundException(`Publication ${id} not found`);
}
return { stream: await this.minio.getFileStream(record.fileKey), record };
}
private async getByIdOrThrow(id: string): Promise<Publication> {
const record = await this.repository.findById(id);
if (!record) throw new NotFoundException(`Publication ${id} not found`);
return record;
}
}

View File

@@ -2498,6 +2498,11 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:support_content:view",
manage: "edr_freight_app:settings:support_content:manage",
},
// Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice.
publications: {
view: "edr_freight_app:settings:publications:view",
manage: "edr_freight_app:settings:publications:manage",
},
},
support: {
agentView: "edr_freight_app:support:agent_view",

View File

@@ -60,6 +60,7 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import PublicationsPage from "./pages/publications/PublicationsPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage";
@@ -1193,6 +1194,19 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="publications"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.settings.publications.view,
FREIGHT_PERMS.settings.publications.manage,
]}
>
<PublicationsPage />
</RequirePermission>
}
/>
<Route
path="configuration"

View File

@@ -570,6 +570,15 @@ export const buildSidebarSections = (
FREIGHT_PERMS.settings.supportContent.manage,
],
},
{
label: "Publications",
href: "/dashboard/publications",
icon: <FileText />,
permission: [
FREIGHT_PERMS.settings.publications.view,
FREIGHT_PERMS.settings.publications.manage,
],
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",

View File

@@ -426,6 +426,10 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:support_content:view",
manage: "edr_freight_app:settings:support_content:manage",
},
publications: {
view: "edr_freight_app:settings:publications:view",
manage: "edr_freight_app:settings:publications:manage",
},
},
staff: {
roles: {

View File

@@ -0,0 +1,54 @@
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export interface DeletePublicationDialogProps {
title: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeletePublicationDialog({
title,
onConfirm,
children,
}: DeletePublicationDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Delete publication?</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{title}</span> from the
public library. It stops being downloadable immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onConfirm} className="bg-red-600 text-white hover:bg-red-700">
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,211 @@
import type { Publication } from "@edr/types";
import { useMutation } from "@tanstack/react-query";
import { Loader2, UploadCloud } from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/services/api";
export interface EditPublicationDialogProps {
mode?: "create" | "edit";
publication?: Publication;
children: ReactNode;
}
const ACCEPT =
".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation";
export default function EditPublicationDialog({
mode = "create",
publication,
children,
}: EditPublicationDialogProps) {
const isEdit = mode === "edit";
const fileInputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(publication?.title ?? "");
const [description, setDescription] = useState(publication?.description ?? "");
const [category, setCategory] = useState(publication?.category ?? "");
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation(api.publications.create.mutationOptions());
const updateMutation = useMutation(api.publications.update.mutationOptions());
const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions());
const pending =
createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending;
const reset = () => {
setTitle(publication?.title ?? "");
setDescription(publication?.description ?? "");
setCategory(publication?.category ?? "");
setFile(null);
setProgress(null);
setError(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSubmit = async () => {
setError(null);
if (!title.trim()) {
setError("Title is required.");
return;
}
if (!isEdit && !file) {
setError("Choose a file to upload.");
return;
}
const meta = {
title: title.trim(),
description: description.trim() || undefined,
category: category.trim() || undefined,
};
try {
if (isEdit && publication) {
await updateMutation.mutateAsync({ id: publication.id, dto: meta });
if (file) {
await replaceFileMutation.mutateAsync({
id: publication.id,
file,
onProgress: setProgress,
});
}
} else if (file) {
await createMutation.mutateAsync({ file, meta, onProgress: setProgress });
}
setOpen(false);
if (!isEdit) reset();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong. Try again.");
} finally {
setProgress(null);
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit publication" : "New publication"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update this document's title, description or category, or replace its file."
: "Upload a PDF, Markdown or PowerPoint file for the public library."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. EDR Freight Platform Guide"
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. Guides, Reports"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this document covers…"
/>
</div>
<div className="space-y-2">
<Label>{isEdit ? "Replace file (optional)" : "File *"}</Label>
<div
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer rounded-xl border-2 border-dashed border-slate-300 px-4 py-6 text-center hover:bg-slate-50"
>
{progress !== null ? (
<p className="text-sm text-slate-500">Uploading {progress}%</p>
) : file ? (
<p className="text-sm font-medium text-slate-700">{file.name}</p>
) : isEdit && publication ? (
<p className="text-sm text-slate-500">
Currently <span className="font-medium">{publication.fileName}</span>
click to replace
</p>
) : (
<div className="flex flex-col items-center gap-1 text-slate-500">
<UploadCloud className="h-6 w-6" />
<span className="text-sm">Click to choose a PDF, Markdown or PowerPoint file</span>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
hidden
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
/>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : null}
<div className="mt-2 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save changes"
) : (
"Upload"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,173 @@
import type { Publication } from "@edr/types";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Switch,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { FileText, Pencil, Plus, Trash2 } from "lucide-react";
import { PageContainer } from "@/components/page";
import { formatBytes, formatDate } from "@/lib/format";
import { api } from "@/services/api";
import DeletePublicationDialog from "./DeletePublicationDialog";
import EditPublicationDialog from "./EditPublicationDialog";
/** Short label from a mime type, for the file-type badge. */
function fileKindLabel(mime: string): string {
if (mime === "application/pdf") return "PDF";
if (mime.includes("markdown")) return "Markdown";
if (mime.includes("powerpoint") || mime.includes("presentationml")) return "PowerPoint";
return "File";
}
/**
* Backoffice admin for the freight portal's public /publications page —
* upload, edit, reorder-by-hand and unpublish PDFs, Markdown write-ups and
* PowerPoint decks about the platform.
*/
export default function PublicationsPage() {
const { data, isLoading, isError } = useQuery(api.publications.list.queryOptions());
const updateMutation = useMutation(api.publications.update.mutationOptions());
const removeMutation = useMutation(api.publications.remove.mutationOptions());
const publications = [...(data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
return (
<PageContainer>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={4}>
<Title order={2}>Publications</Title>
<Text size="sm" c="dimmed" maw={560}>
PDFs, Markdown write-ups and PowerPoint decks shown on the public
/publications page no login required to view them.
</Text>
</Stack>
<EditPublicationDialog>
<Button leftSection={<Plus size={16} />} color="edr-green">
New publication
</Button>
</EditPublicationDialog>
</Group>
<Paper withBorder radius="lg" p="lg">
{isLoading ? (
<Center py="xl">
<Loader color="edr-green" />
</Center>
) : isError ? (
<Text c="dimmed">Could not load publications.</Text>
) : publications.length === 0 ? (
<Stack align="center" gap="md" py="xl">
<FileText size={32} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">No publications yet.</Text>
<EditPublicationDialog>
<Button variant="light" color="edr-green">
Upload the first one
</Button>
</EditPublicationDialog>
</Stack>
) : (
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Title</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Published</Table.Th>
<Table.Th>Updated</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{publications.map((pub: Publication) => (
<Table.Tr key={pub.id}>
<Table.Td>
<Text fw={600} size="sm">
{pub.title}
</Text>
{pub.description ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{pub.description}
</Text>
) : null}
</Table.Td>
<Table.Td>
{pub.category ? (
<Badge variant="light" color="gray">
{pub.category}
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Badge variant="light" color="edr-green">
{fileKindLabel(pub.fileMimeType)}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{formatBytes(pub.fileSizeBytes)}</Text>
</Table.Td>
<Table.Td>
<Tooltip label={pub.published ? "Visible on the public page" : "Hidden from the public page"}>
<Switch
checked={pub.published}
color="edr-green"
onChange={(e) =>
updateMutation.mutate({
id: pub.id,
dto: { published: e.currentTarget.checked },
})
}
/>
</Tooltip>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{formatDate(pub.updatedAt)}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<EditPublicationDialog mode="edit" publication={pub}>
<ActionIcon variant="subtle" color="gray" aria-label="Edit">
<Pencil size={16} />
</ActionIcon>
</EditPublicationDialog>
<DeletePublicationDialog
title={pub.title}
onConfirm={() => removeMutation.mutate({ id: pub.id })}
>
<ActionIcon variant="subtle" color="red" aria-label="Delete">
<Trash2 size={16} />
</ActionIcon>
</DeletePublicationDialog>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import type { Freight, PaginatedResponse, Publication } from "@edr/types";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
@@ -191,6 +191,7 @@ import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVer
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { publicationsService, type UpdatePublicationPayload } from "./publications.service";
import {
fleetService,
type FleetListFilters,
@@ -2934,6 +2935,52 @@ export const api = {
),
},
publications: {
list: endpoint<void, Publication[]>(
"publications",
"list",
publicationsService.list,
),
create: endpoint<
{ file: File; meta: UpdatePublicationPayload & { title: string }; onProgress?: (percent: number | null) => void },
Publication
>(
"publications",
"create",
({ file, meta, onProgress }) => publicationsService.create(file, meta, onProgress),
undefined,
() => [["publications"]],
),
update: endpoint<{ id: string; dto: UpdatePublicationPayload }, Publication>(
"publications",
"update",
({ id, dto }) => publicationsService.update(id, dto),
undefined,
() => [["publications"]],
),
replaceFile: endpoint<
{ id: string; file: File; onProgress?: (percent: number | null) => void },
Publication
>(
"publications",
"replaceFile",
({ id, file, onProgress }) => publicationsService.replaceFile(id, file, onProgress),
undefined,
() => [["publications"]],
),
remove: endpoint<{ id: string }, void>(
"publications",
"remove",
({ id }) => publicationsService.remove(id),
undefined,
() => [["publications"]],
),
},
dropdownSettings: {
list: endpoint<void, DropdownSetting[]>(
"dropdown-settings",

View File

@@ -0,0 +1,74 @@
import type { Publication } from "@edr/types";
import { api as client } from "../auth/http";
const BASE = "/publications";
export interface UpdatePublicationPayload {
title?: string;
description?: string;
category?: string;
sortOrder?: number;
published?: boolean;
}
/**
* The freight portal's public document library (/publications), managed here.
* Every write is multipart because create/replaceFile carry a real file — the
* client's response interceptor already unwraps the `{ success, data }`
* envelope, so each method stays a one-liner.
*/
export const publicationsService = {
async list(): Promise<Publication[]> {
const { data } = await client.get<Publication[]>(`${BASE}/admin`);
return data;
},
async create(
file: File,
meta: UpdatePublicationPayload & { title: string },
onProgress?: (percent: number | null) => void,
): Promise<Publication> {
const form = new FormData();
form.append("file", file);
Object.entries(meta).forEach(([key, value]) => {
if (value !== undefined) form.append(key, String(value));
});
const { data } = await client.post<Publication>(BASE, form, {
timeout: 2 * 60 * 1000,
onUploadProgress: (event) =>
onProgress?.(
event.total ? Math.round((event.loaded / event.total) * 100) : null,
),
});
return data;
},
async update(id: string, dto: UpdatePublicationPayload): Promise<Publication> {
const { data } = await client.patch<Publication>(`${BASE}/${id}`, dto);
return data;
},
async replaceFile(
id: string,
file: File,
onProgress?: (percent: number | null) => void,
): Promise<Publication> {
const form = new FormData();
form.append("file", file);
const { data } = await client.post<Publication>(`${BASE}/${id}/file`, form, {
timeout: 2 * 60 * 1000,
onUploadProgress: (event) =>
onProgress?.(
event.total ? Math.round((event.loaded / event.total) * 100) : null,
),
});
return data;
},
async remove(id: string): Promise<void> {
await client.delete(`${BASE}/${id}`);
},
};

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

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

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

@@ -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,14 +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: "Contact", href: "#contact" },
];
const heroTrust = [
"Telebirr & CBE Birr payments",
"Fayda ID verified",
@@ -517,60 +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) => (
<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">
@@ -1248,6 +1188,7 @@ export default function EDRFreightLandingPage() {
links: [
{ label: "Help & Support", to: "/help" },
{ label: "FAQ", to: "/faq" },
{ label: "Publications", to: "/publications" },
],
},
{

View File

@@ -0,0 +1,422 @@
import type { PublicationSummary } from "@edr/types";
import { useFileViewer } from "@edr/ui-common";
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 { DocFooter } from "../support/DocShell";
const MARKDOWN_MIMES = new Set(["text/markdown", "text/x-markdown"]);
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";
}
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`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
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.
*
* 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);
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 (kindOf(pub) === "markdown") {
void openMarkdown(pub);
return;
}
view({
name: pub.fileName,
url: publicationFileUrl(pub.id),
mimeType: pub.fileMimeType,
});
};
// 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 (
<div className="min-h-screen bg-background text-foreground">
<PublicNavbar />
{/* 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>
<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="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>
<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}
{reading ? (
<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-8 py-6">
{markdownText === null ? (
<p className="text-muted-foreground">Loading</p>
) : (
<Markdown>{markdownText}</Markdown>
)}
</div>
</div>
</div>
) : null}
</div>
);
}

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

View File

@@ -17,6 +17,7 @@ export * from "./booking-window-ws";
export * from "./support-chat";
export * from "./portal-content";
export * from "./portal-content.defaults";
export * from "./publications";
export enum TradeDirection {
IMPORT = "IMPORT",

View File

@@ -0,0 +1,43 @@
/**
* Public document library for the freight portal (/publications): PDFs,
* Markdown write-ups and PowerPoint decks about the platform, uploaded and
* curated from the backoffice. Unlike `support-content`'s five fixed slugs,
* this is a real table of many rows — each row is one whole file, replaced
* wholesale rather than edited in place, so there is no version history here.
*/
/** MinIO key prefix every uploaded publication file is stored under. */
export const PUBLICATION_FILE_PREFIX = "publications/";
/** Mime types accepted for a publication upload. */
export const PUBLICATION_ALLOWED_MIME_TYPES = [
"application/pdf",
"text/markdown",
"text/x-markdown",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
] as const;
/** One document in the public library. */
export interface Publication {
id: string;
title: string;
description: string | null;
category: string | null;
fileName: string;
fileMimeType: string;
fileSizeBytes: number;
sortOrder: number;
published: boolean;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Shape the anonymous portal reads — no `fileKey` (that's a MinIO object
* name, not a browser-reachable URL — a presigned MinIO URL isn't reachable
* from the browser here, see `apiConfig.ts`'s `fileViewUrl`). The portal
* builds the file's URL itself from `id` via `GET /api/publications/:id/file`.
*/
export type PublicationSummary = Omit<Publication, "published">;