organizrr/src/Organizrr.tsx
RaviAnand Mohabir e6c0e16e84
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
feat: implement manually add file, use every() to check if pdfs can be dropped, implement file delete, remove nested scrollarea for document preview
2025-04-08 20:17:28 +02:00

605 lines
20 KiB
TypeScript

import {
ActionIcon,
AppShell,
Autocomplete,
Box,
Burger,
Button,
Group,
Loader,
MantineSize,
Pagination,
Paper,
ScrollArea,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { Document, Page } from "react-pdf";
import {
IconDownload,
IconEye,
IconEyeOff,
IconFilePlus,
IconFiles,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconRestore,
IconTrash,
} from "@tabler/icons-react";
import {
createArchiveAndDownload,
fileCategories,
getFileDataUrl,
systemMessage,
} from "./utils";
import { isNotEmpty, useForm } from "@mantine/form";
import { useCallback, useMemo, useState } from "react";
import { ChatCompletionMessageParam } from "@mlc-ai/web-llm";
import Dropzone from "./Dropzone";
import { FileWithPath } from "@mantine/dropzone";
import LLMPicker from "./LLMPicker";
import classNames from "./Organizrr.module.css";
import { useDisclosure } from "@mantine/hooks";
import { useMLEngine } from "./MLEngineContext";
type FormValues = {
customer: { firstName: string; lastName: string };
documents: { file: FileWithPath; id: string }[];
files: {
id: string;
documents: { id: string; selectedPages?: string }[];
suffix: string;
}[];
};
function Organizrr() {
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
useDisclosure(true);
const [desktopOpened, { close: closeDesktop, open: openDesktop }] =
useDisclosure(true);
const previewBreakpoint: MantineSize = "xl";
const [previewMobileOpened, { toggle: togglePreviewMobile }] =
useDisclosure();
const [previewOpened, { toggle: togglePreview, open: openPreview }] =
useDisclosure(true);
const { engine } = useMLEngine();
const [activeDocumentId, setActiveDocumentId] = useState<string | null>(null);
const [activeFile, setActiveFile] = useState<number | null>(null);
const [generatingFilenames, setGeneratingFilenames] = useState<string[]>([]);
const form = useForm<FormValues>({
mode: "controlled",
initialValues: {
customer: { firstName: "", lastName: "" },
documents: [],
files: [],
},
validate: {
customer: {
firstName: isNotEmpty("First name must be given"),
lastName: isNotEmpty("Last name must be given"),
},
files: {
suffix: isNotEmpty("Suffix must be given"),
documents: {
id: isNotEmpty("Document must be selected"),
selectedPages: (val) => {
if (
val
?.split(",")
.some(
(val) =>
!/^\d+$/.test(val.replace(" ", "")) &&
!/^\d+\-\d+$/.test(val.replace(" ", "")) &&
val.replace(" ", "") !== "odd" &&
val.replace(" ", "") !== "even"
)
) {
return "Page numbers must be given as a comma separated list";
}
},
},
},
},
validateInputOnBlur: true,
});
const activeDocument = useMemo(
() =>
activeDocumentId &&
form.values.documents.find((d) => d.id === activeDocumentId),
[activeDocumentId, form]
);
const [numPages, setNumPages] = useState<number>();
const [pageNumber, setPageNumber] = useState<number>(1);
const onDocumentLoadSuccess = useCallback(
({ numPages }: { numPages: number }) => {
setNumPages(numPages);
},
[setNumPages]
);
const handleFileDrop = (files: FileWithPath[]) => {
if (files.length < 1) return;
files.forEach((f) => {
const id = Math.random().toString(36).replace("0.", "doc_");
const fileId = Math.random().toString(36).replace("0.", "file_");
form.insertListItem("documents", { id, file: f });
form.insertListItem("files", {
id: fileId,
documents: [{ id }],
suffix: "",
});
if (engine.current) {
const messages: ChatCompletionMessageParam[] = [
systemMessage,
{ role: "user", content: "The file name is: " + f.name },
];
setGeneratingFilenames((fns) => [...fns, fileId]);
engine.current.chat.completions
.create({
messages,
})
.then((reply) => {
if (
reply &&
reply.choices[0].message.content &&
!reply?.choices[0].message.content?.includes(
"Unable to label file"
) &&
!reply?.choices[0].message.content?.includes("I'm sorry")
) {
console.log(reply?.choices[0].message.content);
form.getValues().files.forEach((f, idx) => {
if (f.id === fileId) {
form.setFieldValue(
`files.${idx}.suffix`,
reply?.choices[0].message.content?.split("\n")[0]
);
}
});
} else {
console.warn(reply?.choices[0].message.content);
}
setGeneratingFilenames((fns) => fns.filter((fn) => fn !== fileId));
})
.catch((e) => {
console.error(e);
setGeneratingFilenames((fns) => fns.filter((fn) => fn !== fileId));
});
}
});
setActiveFile(form.getValues().files.length - 1);
setActiveDocumentId(
form.getValues().documents[form.getValues().documents.length - 1].id
);
};
const handleDocumentDrop = (files: FileWithPath[]) => {
if (activeFile === null) return;
files.forEach((f) => {
const id = Math.random().toString(36).replace("0.", "doc_");
form.insertListItem("documents", { id, file: f });
form.insertListItem(`files.${activeFile}.documents`, { id });
});
setActiveDocumentId(
form.getValues().documents[form.getValues().documents.length - 1].id
);
};
const handleDocumentSelect = (id: string | null) => {
if (id === null) return;
if (activeFile === null) return;
form.insertListItem(`files.${activeFile}.documents`, { id });
setActiveDocumentId(id);
};
const handleSubmit = async ({ customer, files, documents }: FormValues) => {
await createArchiveAndDownload(async () => {
return {
customer,
files: files.map((f) => ({
...f,
documents: f.documents.map((d) => {
const selectedPages = d.selectedPages
?.split(",")
.map((sp) => sp.replace(" ", ""));
return {
...d,
selectedPages: selectedPages?.length ? selectedPages : [],
};
}),
})),
documents: await Promise.all(
documents.map(async (d) => ({
...d,
name: d.file.name,
blob: await getFileDataUrl(d.file),
}))
),
};
});
};
const handleErrors = (errors: typeof form.errors) => {
for (const [key] of Object.entries(errors)) {
if (key.startsWith("files.")) {
if (!Number.isNaN(parseInt(key.split(".")[1]))) {
const idx = parseInt(key.split(".")[1]);
setActiveFile(idx);
break;
}
}
}
};
return (
<AppShell
header={{ height: 100 }}
footer={{ height: desktopOpened ? 0 : 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
aside={{
width: 600,
breakpoint: previewBreakpoint,
collapsed: { mobile: !previewMobileOpened, desktop: !previewOpened },
}}
padding="md"
>
<form onSubmit={form.onSubmit(handleSubmit, handleErrors)}>
<AppShell.Header bg="var(--mantine-primary-color-3)">
<Group px="md">
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Stack gap="xs">
<Title c="white" order={1}>
Organizrr
</Title>
<Text c="var(--mantine-primary-color-8)">By InnoPeak</Text>
</Stack>
<Group ml="auto">
<LLMPicker />
<ActionIcon
variant="subtle"
onClick={togglePreview}
visibleFrom={previewBreakpoint}
>
{previewOpened ? <IconEyeOff /> : <IconEye />}
</ActionIcon>
<ActionIcon
variant="subtle"
onClick={togglePreviewMobile}
hiddenFrom={previewBreakpoint}
>
{previewMobileOpened ? <IconEyeOff /> : <IconEye />}
</ActionIcon>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md" bg="var(--mantine-primary-color-0)">
<AppShell.Section>
<Group>
<IconFiles />
<Title order={3}>Files</Title>
<ActionIcon
variant="subtle"
ml="auto"
onClick={() => {
const id = Math.random().toString(36).replace("0.", "file_");
form.insertListItem("files", { id, documents: [] });
setActiveFile(form.getValues().files.length - 1);
}}
>
<IconFilePlus />
</ActionIcon>
</Group>
</AppShell.Section>
<AppShell.Section grow my="md" component={ScrollArea}>
<Stack>
{form.values.files.map((f, idx) => (
<Stack key={f.id} gap="xs">
<Group>
<Button
variant={idx === activeFile ? "" : "subtle"}
onClick={() => {
setActiveFile(idx);
closeMobile();
}}
rightSection={
generatingFilenames.includes(f.id) ? (
<Loader size="xs" type="bars" color="white" />
) : null
}
style={{ flex: 1 }}
>
<Text>{f.suffix || f.id}</Text>
</Button>
<ActionIcon
onClick={() => {
if (activeFile === idx) {
setActiveFile(null);
}
form.removeListItem("files", idx);
}}
variant="subtle"
c="red"
>
<IconTrash />
</ActionIcon>
</Group>
{activeFile != idx &&
form.errors &&
Object.entries(form.errors)
.filter(([key]) => key.startsWith(`files.${idx}`))
.map(([_, error]) => <Text c="red">{error}</Text>)}
</Stack>
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Stack>
<Dropzone onDrop={handleFileDrop} />
<Group justify="flex-end">
<ActionIcon
variant="transparent"
onClick={closeDesktop}
visibleFrom="sm"
>
<IconLayoutSidebarLeftCollapse />
</ActionIcon>
</Group>
</Stack>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main pb="md">
<Stack pb={30}>
<Group>
<Title order={2} size="h1">
Customer
</Title>
<Group ml="auto">
<Tooltip label="Formular zurücksetzen">
<ActionIcon
variant="subtle"
c="red"
onClick={() => {
form.reset();
setActiveFile(null);
}}
>
<IconRestore />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Group>
<TextInput
label="First Name"
placeholder="First Name"
withAsterisk
required
{...form.getInputProps("customer.firstName")}
key={form.key("customer.firstName")}
/>
<TextInput
label="Last Name"
placeholder="Last Name"
withAsterisk
required
{...form.getInputProps("customer.lastName")}
key={form.key("customer.lastName")}
/>
</Group>
{activeFile !== null && (
<Paper p="md" bg="var(--mantine-primary-color-0)">
<Stack>
<Autocomplete
label="Category"
data={fileCategories}
withAsterisk
required
{...form.getInputProps(`files.${activeFile}.suffix`)}
key={form.key(`files.${activeFile}.suffix`)}
/>
<Group>
<IconFiles />
<Title order={3}>Documents</Title>
</Group>
<ScrollArea offsetScrollbars>
<Group wrap="nowrap" align="start">
{form.values.files[activeFile]?.documents.map(
(d, idx) => (
<Stack
key={`${d.id}-${idx}`}
w={300}
style={{ cursor: "pointer" }}
onClick={() => {
setActiveDocumentId(d.id);
openPreview();
}}
>
<Group>
<Text>
{
form.values.documents.find(
(_d) => _d.id === d.id
)?.file.name
}
</Text>
<ActionIcon
ml="auto"
variant="subtle"
color="red"
onClick={(e) => {
e.stopPropagation();
form.removeListItem(
`files.${activeFile}.documents`,
idx
);
}}
>
<IconTrash />
</ActionIcon>
</Group>
{form.values.documents
.find((_d) => _d.id === d.id)
?.file.name.toLowerCase()
.endsWith(".pdf") && (
<ScrollArea h={400} w="100%">
<Document
file={
form.values.documents.find(
(_d) => _d.id === d.id
)?.file
}
className={classNames.pdfThumbnail}
>
<Page pageNumber={1} scale={0.5} />
</Document>
</ScrollArea>
)}
<TextInput
label="Selected pages"
placeholder="1, 3-4, even, odd"
onClick={(e) => e.stopPropagation()}
{...form.getInputProps(
`files.${activeFile}.documents.${idx}.selectedPages`
)}
key={form.key(
`files.${activeFile}.documents.${idx}.selectedPages`
)}
/>
</Stack>
)
)}
{form.values.files[activeFile].documents.every((doc) =>
form.values.documents
.find((d) => d.id === doc.id)
?.file.name.toLowerCase()
.endsWith(".pdf")
) && (
<Stack
w={300}
key={form.values.files[activeFile]?.documents.length}
>
<Select
label="Add file"
data={form.values.documents.map(({ id, file }) => ({
value: id,
label: file.name,
}))}
onChange={handleDocumentSelect}
/>
<Dropzone
onDrop={handleDocumentDrop}
accept={["application/pdf"]}
/>
</Stack>
)}
</Group>
</ScrollArea>
</Stack>
</Paper>
)}
<Group justify="end">
<Button leftSection={<IconDownload />} type="submit">
Download
</Button>
</Group>
</Stack>
</AppShell.Main>
<AppShell.Aside p="md" bg="var(--mantine-primary-color-0)">
<AppShell.Section>
<Stack>
<Group style={{ alignSelf: "start" }}>
<IconEye /> <Title order={3}>Preview</Title>
</Group>
<Select
label="Select file"
data={form.values.documents.map(({ id, file }) => ({
value: id,
label: file.name,
}))}
value={activeDocumentId}
onChange={setActiveDocumentId}
/>
</Stack>
</AppShell.Section>
<AppShell.Section grow my="md" component={ScrollArea}>
<Stack align="center">
{activeDocument &&
activeDocument.file.name.toLowerCase().endsWith(".pdf") && (
<Document
file={activeDocument.file}
onLoadSuccess={onDocumentLoadSuccess}
>
<Page pageNumber={pageNumber} scale={0.8} />
</Document>
)}
</Stack>
</AppShell.Section>
<AppShell.Section>
{activeDocument && (
<Stack align="center">
<Pagination
value={pageNumber}
onChange={setPageNumber}
total={numPages ?? 0}
/>
</Stack>
)}
</AppShell.Section>
</AppShell.Aside>
<AppShell.Footer p="md">
<ActionIcon
variant="transparent"
onClick={openDesktop}
visibleFrom="sm"
>
<IconLayoutSidebarLeftExpand />
</ActionIcon>
</AppShell.Footer>
</form>
</AppShell>
);
}
export default Organizrr;