feat: add publication

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

View File

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