mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 14:45:04 +00:00
feat: add publication
This commit is contained in:
@@ -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 WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||
@@ -1173,6 +1174,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"
|
||||
|
||||
@@ -564,6 +564,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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user