diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 84e3e4b7a..982728241 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts new file mode 100644 index 000000000..d44f57931 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts new file mode 100644 index 000000000..313ff182c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts new file mode 100644 index 000000000..677b08c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreatePublicationDto } from "./create-publication.dto"; + +export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {} diff --git a/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts new file mode 100644 index 000000000..e627e638c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts new file mode 100644 index 000000000..8a95ffb39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.controller.ts b/apps/edr-freight-api/src/modules/publications/publications.controller.ts new file mode 100644 index 000000000..79f18e9e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.module.ts b/apps/edr-freight-api/src/modules/publications/publications.module.ts new file mode 100644 index 000000000..46e612ebe --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/publications/publications.repository.ts b/apps/edr-freight-api/src/modules/publications/publications.repository.ts new file mode 100644 index 000000000..315f630bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.repository.ts @@ -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 { + constructor( + @InjectRepository(Publication) + repository: Repository, + ) { + super(repository); + } + + /** Public list: published rows only, in display order. */ + findPublished(): Promise { + return this.repository.find({ + where: { published: true }, + order: { sortOrder: "ASC", publishedAt: "DESC" }, + }); + } + + /** Admin list: every row, published or not. */ + override findAll(): Promise { + return this.repository.find({ order: { sortOrder: "ASC" } }); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.service.ts b/apps/edr-freight-api/src/modules/publications/publications.service.ts new file mode 100644 index 000000000..e7ff2b2a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.service.ts @@ -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 { + 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 { + const existing = await this.getByIdOrThrow(id); + + const patch: Partial = { + ...(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 { + 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 { + await this.getByIdOrThrow(id); + await this.repository.softDelete(id); + } + + /** Admin list — every row, published or not. */ + list(): Promise { + 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 { + 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 { + const record = await this.repository.findById(id); + if (!record) throw new NotFoundException(`Publication ${id} not found`); + return record; + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0a3d26d71..93e558dfe 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 652a331c7..cef024ede 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 = () => { } /> + + + + } + /> , + permission: [ + FREIGHT_PERMS.settings.publications.view, + FREIGHT_PERMS.settings.publications.manage, + ], + }, { label: "Audit logs", href: "/dashboard/audit-logs", diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc03bfcb5..fee1f42a2 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx new file mode 100644 index 000000000..a4ca7e5a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx @@ -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 ( + + {children} + + + + Delete publication? + + This will remove{" "} + {title} from the + public library. It stops being downloadable immediately. + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx new file mode 100644 index 000000000..f7e6a8b2d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx @@ -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(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(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(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 ( + { + setOpen(next); + if (!next) reset(); + }} + > + {children} + + + + + {isEdit ? "Edit publication" : "New publication"} + + + {isEdit + ? "Update this document's title, description or category, or replace its file." + : "Upload a PDF, Markdown or PowerPoint file for the public library."} + + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. EDR Freight Platform Guide" + /> +
+ +
+ + setCategory(e.target.value)} + placeholder="e.g. Guides, Reports" + /> +
+ +
+ +