diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 773903e..a87d828 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import DocumentProcessor from './components/DocumentProcessor'; +import DocumentProcessor from './DocumentProcessor'; import './index.css'; const App: React.FC = () => { diff --git a/web-app/src/DocumentProcessor.tsx b/web-app/src/DocumentProcessor.tsx new file mode 100644 index 0000000..22b87fd --- /dev/null +++ b/web-app/src/DocumentProcessor.tsx @@ -0,0 +1,249 @@ +import axios from "axios"; +import React, { useCallback, useEffect, useState } from "react"; +import "react-tag-autocomplete/example/src/styles.css"; // Ensure styles are loaded +import DocumentsToProcess from "./components/DocumentsToProcess"; +import NoDocuments from "./components/NoDocuments"; +import SuccessModal from "./components/SuccessModal"; +import SuggestionsReview from "./components/SuggestionsReview"; + +export interface Document { + id: number; + title: string; + content: string; + tags: string[]; +} + +export interface GenerateSuggestionsRequest { + documents: Document[]; + generate_titles?: boolean; + generate_tags?: boolean; +} + +export interface DocumentSuggestion { + id: number; + original_document: Document; + suggested_title?: string; + suggested_tags?: string[]; +} + +export interface TagOption { + id: string; + name: string; +} + +const DocumentProcessor: React.FC = () => { + const [documents, setDocuments] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + const [loading, setLoading] = useState(true); + const [processing, setProcessing] = useState(false); + const [updating, setUpdating] = useState(false); + const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); + const [filterTag, setFilterTag] = useState(null); + const [generateTitles, setGenerateTitles] = useState(true); + const [generateTags, setGenerateTags] = useState(true); + const [error, setError] = useState(null); + + // Custom hook to fetch initial data + const fetchInitialData = useCallback(async () => { + try { + const [filterTagRes, documentsRes, tagsRes] = await Promise.all([ + axios.get<{ tag: string }>("/api/filter-tag"), + axios.get("/api/documents"), + axios.get>("/api/tags"), + ]); + + setFilterTag(filterTagRes.data.tag); + setDocuments(documentsRes.data); + const tags = Object.keys(tagsRes.data).map((tag) => ({ + id: tag, + name: tag, + })); + setAvailableTags(tags); + } catch (err) { + console.error("Error fetching initial data:", err); + setError("Failed to fetch initial data."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchInitialData(); + }, [fetchInitialData]); + + const handleProcessDocuments = async () => { + setProcessing(true); + setError(null); + try { + const requestPayload: GenerateSuggestionsRequest = { + documents, + generate_titles: generateTitles, + generate_tags: generateTags, + }; + + const { data } = await axios.post( + "/api/generate-suggestions", + requestPayload + ); + setSuggestions(data); + } catch (err) { + console.error("Error generating suggestions:", err); + setError("Failed to generate suggestions."); + } finally { + setProcessing(false); + } + }; + + const handleUpdateDocuments = async () => { + setUpdating(true); + setError(null); + try { + await axios.patch("/api/update-documents", suggestions); + setIsSuccessModalOpen(true); + setSuggestions([]); + } catch (err) { + console.error("Error updating documents:", err); + setError("Failed to update documents."); + } finally { + setUpdating(false); + } + }; + + const handleTagAddition = (docId: number, tag: TagOption) => { + setSuggestions((prevSuggestions) => + prevSuggestions.map((doc) => + doc.id === docId + ? { + ...doc, + suggested_tags: [...(doc.suggested_tags || []), tag.name], + } + : doc + ) + ); + }; + + const handleTagDeletion = (docId: number, index: number) => { + setSuggestions((prevSuggestions) => + prevSuggestions.map((doc) => + doc.id === docId + ? { + ...doc, + suggested_tags: doc.suggested_tags?.filter( + (_, i) => i !== index + ), + } + : doc + ) + ); + }; + + const handleTitleChange = (docId: number, title: string) => { + setSuggestions((prevSuggestions) => + prevSuggestions.map((doc) => + doc.id === docId + ? { ...doc, suggested_title: title } + : doc + ) + ); + }; + + const resetSuggestions = () => { + setSuggestions([]); + }; + + const reloadDocuments = async () => { + setLoading(true); + setError(null); + try { + const { data } = await axios.get("/api/documents"); + setDocuments(data); + } catch (err) { + console.error("Error reloading documents:", err); + setError("Failed to reload documents."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (documents.length === 0) { + const interval = setInterval(async () => { + setError(null); + try { + const { data } = await axios.get("/api/documents"); + setDocuments(data); + } catch (err) { + console.error("Error reloading documents:", err); + setError("Failed to reload documents."); + } + }, 500); + return () => clearInterval(interval); + } + }, [documents]); + + + if (loading) { + return ( +
+
Loading documents...
+
+ ); + } + + return ( +
+
+

+ Paperless GPT +

+
+ + {error && ( +
+ {error} +
+ )} + + {documents.length === 0 ? ( + + ) : suggestions.length === 0 ? ( + + ) : ( + + )} + + { + setIsSuccessModalOpen(false); + reloadDocuments(); + }} + /> +
+ ); +}; + +export default DocumentProcessor; diff --git a/web-app/src/components/DocumentCard.tsx b/web-app/src/components/DocumentCard.tsx new file mode 100644 index 0000000..01ddbca --- /dev/null +++ b/web-app/src/components/DocumentCard.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Document } from "../DocumentProcessor"; + +interface DocumentCardProps { + document: Document; +} + +const DocumentCard: React.FC = ({ document }) => ( +
+

{document.title}

+

+ {document.content.length > 100 + ? `${document.content.substring(0, 100)}...` + : document.content} +

+
+ {document.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+

{document.title}

+

{document.content}

+
+ {document.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+
+); + +export default DocumentCard; \ No newline at end of file diff --git a/web-app/src/components/DocumentProcessor.tsx b/web-app/src/components/DocumentProcessor.tsx deleted file mode 100644 index 70f5704..0000000 --- a/web-app/src/components/DocumentProcessor.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { - Dialog, - DialogTitle, - Transition, - TransitionChild, -} from "@headlessui/react"; -import { ArrowPathIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; -import axios from "axios"; -import React, { Fragment, useEffect, useState } from "react"; -import { ReactTags } from "react-tag-autocomplete"; -import "react-tag-autocomplete/example/src/styles.css"; // Ensure styles are loaded - -interface Document { - id: number; - title: string; - content: string; - tags: string[]; -} - -interface GenerateSuggestionsRequest { - documents: Document[]; - generate_titles?: boolean; - generate_tags?: boolean; -} - -interface DocumentSuggestion { - id: number; - original_document: Document; - suggested_title?: string; - suggested_tags?: string[]; -} - -const DocumentProcessor: React.FC = () => { - const [documents, setDocuments] = useState([]); - const [documentSuggestions, setDocumentSuggestions] = useState< - DocumentSuggestion[] - >([]); - const [availableTags, setAvailableTags] = useState< - { value: string; label: string }[] - >([]); - const [loading, setLoading] = useState(true); - const [processing, setProcessing] = useState(false); - const [updating, setUpdating] = useState(false); - const [successModalOpen, setSuccessModalOpen] = useState(false); - const [filterTag, setFilterTag] = useState(undefined); - const [generateTitles, setGenerateTitles] = useState(true); - const [generateTags, setGenerateTags] = useState(true); - - useEffect(() => { - const fetchData = async () => { - try { - const [filterTagResponse, documentsResponse, tagsResponse] = - await Promise.all([ - axios.get< - { tag: string } | undefined - > - ("/api/filter-tag"), - axios.get< - Document[] - >("/api/documents"), - axios.get<{ - [tag: string]: number; - }>("/api/tags"), - ]); - - setFilterTag(filterTagResponse.data?.tag); - setDocuments(documentsResponse.data); - - // Store available tags as objects with value and label - const tags = Object.entries(tagsResponse.data).map(([name]) => ({ - value: name, - label: name, - })); - setAvailableTags(tags); - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, []); - - const handleProcessDocuments = async () => { - setProcessing(true); - try { - const requestPayload: GenerateSuggestionsRequest = { - documents, - generate_titles: generateTitles, - generate_tags: generateTags, - }; - - const response = await axios.post( - "/api/generate-suggestions", - requestPayload - ); - setDocumentSuggestions(response.data); - } catch (error) { - console.error("Error generating suggestions:", error); - } finally { - setProcessing(false); - } - }; - - const handleUpdateDocuments = async () => { - setUpdating(true); - try { - await axios.patch("/api/update-documents", documentSuggestions); - setSuccessModalOpen(true); - resetSuggestions(); - } catch (error) { - console.error("Error updating documents:", error); - } finally { - setUpdating(false); - } - }; - - const resetSuggestions = () => { - setDocumentSuggestions([]); - }; - - const fetchDocuments = async () => { - try { - const response = await axios.get("/api/documents"); - setDocuments(response.data); - } catch (error) { - console.error("Error fetching documents:", error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (documents.length === 0) { - const interval = setInterval(() => { - fetchDocuments(); - }, 500); - return () => clearInterval(interval); - } - }, [documents]); - - if (loading) { - return ( -
-
Loading documents...
-
- ); - } - - return ( -
-

- Paperless GPT -

- - {documents.length === 0 && ( -
-
- No documents found with filter tag{" "} - - {filterTag} - {" "} - found. Try{" "} - - . -
-
- )} - - {documentSuggestions.length === 0 && ( -
-
-

- Documents to Process -

- - -
-
- - -
-
- {documents.map((doc) => ( -
-

{doc.title}

-
-                  {doc.content.length > 100 ? `${doc.content.substring(0, 100)}...` : doc.content}
-                
-
- {doc.tags.map((tag, index) => ( - - {tag} - - ))} -
-
-
-

{doc.title}

-
-                      {doc.content}
-                    
-
- {doc.tags.map((tag, index) => ( - - {tag} - - ))} -
-
-
-
- ))} -
-
- )} - - {documentSuggestions.length > 0 && ( -
-

- Review and Edit Suggested Titles -

-
- {documentSuggestions.map((doc) => ( -
-

- {doc.original_document.title} -

- { - const updatedSuggestions = documentSuggestions.map((d) => - d.id === doc.id - ? { ...d, suggested_title: e.target.value } - : d - ); - setDocumentSuggestions(updatedSuggestions); - }} - className="w-full border border-gray-300 rounded px-2 py-1 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- ({ - value: tag, - label: tag, - })) || [] - } - suggestions={availableTags} - onAdd={(tag) => { - const tagValue = tag.value as string; - const updatedTags = [ - ...(doc.suggested_tags || []), - tagValue, - ]; - const updatedSuggestions = documentSuggestions.map((d) => - d.id === doc.id - ? { ...d, suggested_tags: updatedTags } - : d - ); - setDocumentSuggestions(updatedSuggestions); - }} - onDelete={(i) => { - const updatedTags = doc.suggested_tags?.filter( - (_, index) => index !== i - ); - const updatedSuggestions = documentSuggestions.map((d) => - d.id === doc.id - ? { ...d, suggested_tags: updatedTags } - : d - ); - setDocumentSuggestions(updatedSuggestions); - }} - allowNew={false} - placeholderText="Add a tag" - /> -
-
- ))} -
-
- - -
-
- )} - - - -
- -
- - - - - -
-
-
-
-
- - Documents Updated - -
-

- The documents have been successfully updated with the - new titles. -

-
-
-
-
- -
-
-
-
-
-
-
- ); -}; - -export default DocumentProcessor; diff --git a/web-app/src/components/DocumentsToProcess.tsx b/web-app/src/components/DocumentsToProcess.tsx new file mode 100644 index 0000000..c8c030e --- /dev/null +++ b/web-app/src/components/DocumentsToProcess.tsx @@ -0,0 +1,75 @@ +import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; +import React from "react"; +import { Document } from "../DocumentProcessor"; +import DocumentCard from "./DocumentCard"; + +interface DocumentsToProcessProps { + documents: Document[]; + generateTitles: boolean; + setGenerateTitles: React.Dispatch>; + generateTags: boolean; + setGenerateTags: React.Dispatch>; + onProcess: () => void; + processing: boolean; + onReload: () => void; +} + +const DocumentsToProcess: React.FC = ({ + documents, + generateTitles, + setGenerateTitles, + generateTags, + setGenerateTags, + onProcess, + processing, + onReload, +}) => ( +
+
+

Documents to Process

+
+ + +
+
+ +
+ + +
+ +
+ {documents.map((doc) => ( + + ))} +
+
+); + +export default DocumentsToProcess; \ No newline at end of file diff --git a/web-app/src/components/NoDocuments.tsx b/web-app/src/components/NoDocuments.tsx new file mode 100644 index 0000000..9a58c20 --- /dev/null +++ b/web-app/src/components/NoDocuments.tsx @@ -0,0 +1,36 @@ +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import React from "react"; + +interface NoDocumentsProps { + filterTag: string | null; + onReload: () => void; + processing: boolean; +} + +const NoDocuments: React.FC = ({ + filterTag, + onReload, + processing, +}) => ( +
+

+ No documents found with filter tag{" "} + {filterTag && ( + + {filterTag} + + )} + . +

+ +
+); + +export default NoDocuments; \ No newline at end of file diff --git a/web-app/src/components/SuccessModal.tsx b/web-app/src/components/SuccessModal.tsx new file mode 100644 index 0000000..4751f4d --- /dev/null +++ b/web-app/src/components/SuccessModal.tsx @@ -0,0 +1,85 @@ +import React, { Fragment } from "react"; +import { Dialog, DialogTitle, Transition } from "@headlessui/react"; +import { CheckCircleIcon } from "@heroicons/react/24/outline"; + +interface SuccessModalProps { + isOpen: boolean; + onClose: () => void; +} + +const SuccessModal: React.FC = ({ isOpen, onClose }) => ( + + +
+ +
+ + + + + +
+
+
+
+
+ + Documents Updated + +
+

+ The documents have been successfully updated with the + new titles and tags. +

+
+
+
+
+ +
+
+
+
+
+
+); + +export default SuccessModal; \ No newline at end of file diff --git a/web-app/src/components/SuggestionCard.tsx b/web-app/src/components/SuggestionCard.tsx new file mode 100644 index 0000000..4a0d8a4 --- /dev/null +++ b/web-app/src/components/SuggestionCard.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; +import { ReactTags } from "react-tag-autocomplete"; +import { DocumentSuggestion, TagOption } from "../DocumentProcessor"; + +interface SuggestionCardProps { + suggestion: DocumentSuggestion; + availableTags: TagOption[]; + onTitleChange: (docId: number, title: string) => void; + onTagAddition: (docId: number, tag: TagOption) => void; + onTagDeletion: (docId: number, index: number) => void; +} + +const SuggestionCard: React.FC = ({ + suggestion, + availableTags, + onTitleChange, + onTagAddition, + onTagDeletion, +}) => ( +
+
+

+ {suggestion.original_document.title} +

+ +
+ onTitleChange(suggestion.id, e.target.value)} + className="w-full border border-gray-300 rounded px-2 py-1 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ ({ + id: index.toString(), + name: tag, + label: tag, + value: index.toString(), + })) || [] + } + suggestions={availableTags.map(tag => ({ id: tag.id, name: tag.name, label: tag.name, value: tag.id }))} + onAdd={(tag) => onTagAddition(suggestion.id, { id: String(tag.label), name: String(tag.value) })} + onDelete={(index) => onTagDeletion(suggestion.id, index)} + allowNew={true} + placeholderText="Add a tag" + /> +
+
+); + +export default SuggestionCard; \ No newline at end of file diff --git a/web-app/src/components/SuggestionsReview.tsx b/web-app/src/components/SuggestionsReview.tsx new file mode 100644 index 0000000..cf7a2e1 --- /dev/null +++ b/web-app/src/components/SuggestionsReview.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { DocumentSuggestion, TagOption } from "../DocumentProcessor"; +import SuggestionCard from "./SuggestionCard"; + +interface SuggestionsReviewProps { + suggestions: DocumentSuggestion[]; + availableTags: TagOption[]; + onTitleChange: (docId: number, title: string) => void; + onTagAddition: (docId: number, tag: TagOption) => void; + onTagDeletion: (docId: number, index: number) => void; + onBack: () => void; + onUpdate: () => void; + updating: boolean; +} + +const SuggestionsReview: React.FC = ({ + suggestions, + availableTags, + onTitleChange, + onTagAddition, + onTagDeletion, + onBack, + onUpdate, + updating, +}) => ( +
+

+ Review and Edit Suggested Titles +

+
+ {suggestions.map((doc) => ( + + ))} +
+
+ + +
+
+); + +export default SuggestionsReview; \ No newline at end of file diff --git a/web-app/tsconfig.app.tsbuildinfo b/web-app/tsconfig.app.tsbuildinfo index abb7240..daa87b6 100644 --- a/web-app/tsconfig.app.tsbuildinfo +++ b/web-app/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentprocessor.tsx"],"version":"5.6.2"} \ No newline at end of file +{"root":["./src/app.tsx","./src/documentprocessor.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentcard.tsx","./src/components/documentstoprocess.tsx","./src/components/nodocuments.tsx","./src/components/successmodal.tsx","./src/components/suggestioncard.tsx","./src/components/suggestionsreview.tsx"],"version":"5.6.2"} \ No newline at end of file