From 7ed4c2b202eadd508a6888ab119acc764773f87b Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 15:40:44 +0900 Subject: [PATCH 01/11] Update PDFList --- frontend/electron/main/index.ts | 2 +- frontend/src/pages/Main/Main.tsx | 2 +- .../src/pages/PDFList/PDFList.module.scss | 122 ++++++++------- frontend/src/pages/PDFList/PDFList.tsx | 141 +++++------------- 4 files changed, 102 insertions(+), 165 deletions(-) diff --git a/frontend/electron/main/index.ts b/frontend/electron/main/index.ts index cb29401..c852609 100644 --- a/frontend/electron/main/index.ts +++ b/frontend/electron/main/index.ts @@ -103,7 +103,7 @@ app.on('activate', () => { } else { createWindow() } -}) +}); // New window example arg: new windows url ipcMain.handle('open-win', (_, arg) => { diff --git a/frontend/src/pages/Main/Main.tsx b/frontend/src/pages/Main/Main.tsx index 68b46eb..b5cf904 100644 --- a/frontend/src/pages/Main/Main.tsx +++ b/frontend/src/pages/Main/Main.tsx @@ -56,7 +56,7 @@ export default function Main() {
- +
{openTabs.length > 0 && ( diff --git a/frontend/src/pages/PDFList/PDFList.module.scss b/frontend/src/pages/PDFList/PDFList.module.scss index 7039c2a..25e84d7 100644 --- a/frontend/src/pages/PDFList/PDFList.module.scss +++ b/frontend/src/pages/PDFList/PDFList.module.scss @@ -1,5 +1,5 @@ .container { - padding: 3rem 4rem; + padding: 16px; max-width: 1400px; margin: 0 auto; position: relative; @@ -24,67 +24,79 @@ .header { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 3rem; - padding: 1.5rem 2rem; - background-color: white; - border-radius: 1rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + justify-content: space-between; + margin-bottom: 16px; } .title { - font-size: 2rem; - font-weight: bold; - color: #1a1a1a; - display: flex; - align-items: center; - gap: 0.5rem; - - &::before { - content: 'πŸ“š'; - font-size: 1.8rem; - } + font-size: 24px; + color: #333; + margin: 0; } .uploadButton { - background-color: #3b82f6; - color: white; - padding: 0.75rem 1.5rem; - border-radius: 0.75rem; - font-weight: 600; - font-size: 1rem; + background-color: #0070f3; border: none; + color: #fff; + padding: 8px 12px; + border-radius: 4px; cursor: pointer; - transition: all 0.2s ease-in-out; + transition: background-color 0.2s ease; +} + +.uploadButton:hover { + background-color: #005bb5; +} + +.content { + background-color: #f5f5f5; + padding: 16px; + border-radius: 4px; +} + +.pdfList { + list-style: none; + margin: 0; + padding: 0; +} + +.pdfItem { + padding: 8px; + margin-bottom: 8px; + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 4px; display: flex; + justify-content: space-between; align-items: center; - gap: 0.5rem; - - &::before { - content: '⬆️'; - font-size: 1.2rem; - } + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; +} - &:hover { - background-color: #2563eb; - transform: translateY(-1px); - box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2); - } +.pdfItem:hover { + background-color: #efefef; +} - &:active { - background-color: #1d4ed8; - transform: translateY(0); - } +.pdfItem.active { + background-color: #d0e6ff; + border-color: #0070f3; } -.content { - height: calc(100vh - 12rem); - padding: 2rem; - background-color: white; - border-radius: 1rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - overflow-y: auto; +.dropOverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; } .grid { @@ -223,22 +235,6 @@ } } -.dropOverlay { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: #3b82f6; - color: white; - padding: 1.5rem 3rem; - border-radius: 1rem; - font-size: 1.5rem; - font-weight: 600; - pointer-events: none; - animation: fadeIn 0.2s ease-in-out; - box-shadow: 0 8px 16px rgba(59, 130, 246, 0.2); -} - @keyframes fadeIn { from { opacity: 0; diff --git a/frontend/src/pages/PDFList/PDFList.tsx b/frontend/src/pages/PDFList/PDFList.tsx index eb88c73..6318420 100644 --- a/frontend/src/pages/PDFList/PDFList.tsx +++ b/frontend/src/pages/PDFList/PDFList.tsx @@ -1,24 +1,18 @@ import { useState, useRef, useEffect } from 'react'; import classNames from 'classnames/bind'; -import { Worker } from '@react-pdf-viewer/core'; -import { thumbnailPlugin } from '@react-pdf-viewer/thumbnail'; -import '@react-pdf-viewer/core/lib/styles/index.css'; -import '@react-pdf-viewer/thumbnail/lib/styles/index.css'; import styles from './PDFList.module.scss'; -import PDFCard, { EmptyPDFCard } from './components/PDFCard/PDFCard'; -import PDFPreview from './components/PDFPreview/PDFPreview'; import { PDFItem } from './types'; const cx = classNames.bind(styles); interface PDFListProps { onView: (id: string, url: string, title: string) => void; + setShowList: React.Dispatch>; } -export default function PDFList({ onView }: PDFListProps) { - const [selectedId, setSelectedId] = useState(null); +export default function PDFList({ onView, setShowList }: PDFListProps) { const [pdfList, setPdfList] = useState([]); - const [isDragging, setIsDragging] = useState(false); + const [activePdfId, setActivePdfId] = useState(null); const fileInputRef = useRef(null); useEffect(() => { @@ -50,16 +44,15 @@ export default function PDFList({ onView }: PDFListProps) { fetchDocuments(); }, []); - const handlePreview = (id: string) => { - if (pdfList.find(pdf => pdf.id === id)?.isDisabled) return; - setSelectedId(id); - }; - const handleView = (id: string) => { + if (activePdfId === id) { + setShowList(false); + return; + } const pdf = pdfList.find(pdf => pdf.id === id); - if (pdf?.isDisabled) return; if (pdf) { onView(pdf.id, pdf.url, pdf.title); + setActivePdfId(pdf.id); } }; @@ -79,9 +72,6 @@ export default function PDFList({ onView }: PDFListProps) { } setPdfList(prev => prev.filter(pdf => pdf.id !== id)); - if (selectedId === id) { - setSelectedId(null); - } } catch (error) { console.error('Error deleting document:', error); alert(error instanceof Error ? error.message : 'Failed to delete PDF. Please try again.'); @@ -108,20 +98,19 @@ export default function PDFList({ onView }: PDFListProps) { } const data = await response.json(); - // if (data.id) { - // await fetchDocument(data.id); - // } + + const newPdf: PDFItem = { + id: data.id ? data.id.toString() : Date.now().toString(), + title: file.name, + url: data.id + ? `http://localhost:8000/api/v1/document/${data.id}` + : URL.createObjectURL(file), + }; + setPdfList(prev => [...prev, newPdf]); } catch (error) { console.error('Error uploading file:', error); alert(error instanceof Error ? error.message : 'Failed to upload PDF. Please try again.'); } - - const newPdf: PDFItem = { - id: Date.now().toString(), - title: file.name, - url: URL.createObjectURL(file), - }; - setPdfList(prev => [...prev, newPdf]); } }; @@ -129,79 +118,31 @@ export default function PDFList({ onView }: PDFListProps) { fileInputRef.current?.click(); }; - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - handleFileUpload(e.dataTransfer.files); - }; - - const handleClosePreview = () => { - setSelectedId(null); - }; - - const selectedPdf = pdfList.find(pdf => pdf.id === selectedId); - return ( - -
-
-

PDF λ¬Έμ„œ λͺ©λ‘

- - handleFileUpload(e.target.files)} - accept=".pdf" - style={{ display: 'none' }} - /> -
-
-
- {pdfList.map(pdf => ( -
- -
- ))} -
- -
-
-
- {isDragging &&
PDF νŒŒμΌμ„ 여기에 λ†“μœΌμ„Έμš”
} - {selectedPdf && } +
+
+

PDF λ¬Έμ„œ λͺ©λ‘

+ + handleFileUpload(e.target.files)} + accept=".pdf" + style={{ display: 'none' }} + /> +
+
+
    + {pdfList.map(pdf => ( +
  • + handleView(pdf.id)}>{pdf.title} + +
  • + ))} +
- +
); } From 4a705fc8f7340665850daf85514dec43d71c8bb2 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 15:47:18 +0900 Subject: [PATCH 02/11] modify pdf list --- frontend/src/pages/Main/Main.tsx | 2 +- frontend/src/pages/PDFList/PDFList.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Main/Main.tsx b/frontend/src/pages/Main/Main.tsx index b5cf904..2d34756 100644 --- a/frontend/src/pages/Main/Main.tsx +++ b/frontend/src/pages/Main/Main.tsx @@ -56,7 +56,7 @@ export default function Main() {
- +
{openTabs.length > 0 && ( diff --git a/frontend/src/pages/PDFList/PDFList.tsx b/frontend/src/pages/PDFList/PDFList.tsx index 6318420..e5ba858 100644 --- a/frontend/src/pages/PDFList/PDFList.tsx +++ b/frontend/src/pages/PDFList/PDFList.tsx @@ -6,13 +6,13 @@ import { PDFItem } from './types'; const cx = classNames.bind(styles); interface PDFListProps { + activePdfId?: string | null; onView: (id: string, url: string, title: string) => void; setShowList: React.Dispatch>; } -export default function PDFList({ onView, setShowList }: PDFListProps) { +export default function PDFList({ activePdfId, onView, setShowList }: PDFListProps) { const [pdfList, setPdfList] = useState([]); - const [activePdfId, setActivePdfId] = useState(null); const fileInputRef = useRef(null); useEffect(() => { @@ -52,7 +52,6 @@ export default function PDFList({ onView, setShowList }: PDFListProps) { const pdf = pdfList.find(pdf => pdf.id === id); if (pdf) { onView(pdf.id, pdf.url, pdf.title); - setActivePdfId(pdf.id); } }; From 5ac40f8914b69ae0d2b78ef9f40944f249cca9a4 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 16:53:01 +0900 Subject: [PATCH 03/11] minor fix --- frontend/src/pages/PDFList/PDFList.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/PDFList/PDFList.tsx b/frontend/src/pages/PDFList/PDFList.tsx index e5ba858..16b92fd 100644 --- a/frontend/src/pages/PDFList/PDFList.tsx +++ b/frontend/src/pages/PDFList/PDFList.tsx @@ -135,8 +135,12 @@ export default function PDFList({ activePdfId, onView, setShowList }: PDFListPro
    {pdfList.map(pdf => ( -
  • - handleView(pdf.id)}>{pdf.title} +
  • handleView(pdf.id)} + key={pdf.id} + className={cx('pdfItem', { active: activePdfId === pdf.id })} + > + {pdf.title}
  • ))} From 68b858aa645b09b9be206d270b13d2ef0b88b9c1 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 17:41:13 +0900 Subject: [PATCH 04/11] Add PDF renaming --- backend/app/api/routes/concept.py | 17 ++- backend/app/api/routes/document.py | 20 +++- backend/app/crud/crud_concept.py | 4 + backend/app/crud/crud_document.py | 11 +- backend/app/schemas/document.py | 1 + frontend/src/App.tsx | 77 ++++++------- .../src/pages/PDFList/PDFList.module.scss | 20 ++++ frontend/src/pages/PDFList/PDFList.tsx | 101 +++++++++++++++++- 8 files changed, 196 insertions(+), 55 deletions(-) diff --git a/backend/app/api/routes/concept.py b/backend/app/api/routes/concept.py index d7db0ae..c75135c 100644 --- a/backend/app/api/routes/concept.py +++ b/backend/app/api/routes/concept.py @@ -1,6 +1,6 @@ from typing import Any -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, HTTPException from odmantic import AIOEngine from app import schemas @@ -16,7 +16,14 @@ async def create_concept( engine: AIOEngine = Depends(deps.engine_generator), concept_in: schemas.ConceptCreate, ) -> Any: - concept = await crud_concept.create(engine, obj_in=concept_in) + # Check for duplicate concept name + duplicate = await crud_concept.get_by_name(engine, concept_in.name) + if duplicate: + raise HTTPException( + status_code=400, detail="Concept with this name already exists" + ) + + concept = await crud_concept.concept.create(engine, obj_in=concept_in) return concept @@ -36,6 +43,12 @@ async def update_concept( engine: AIOEngine = Depends(deps.engine_generator), concept_in: schemas.ConceptUpdate, ) -> Any: + # Check for duplicate concept name + duplicate = await crud_concept.get_by_name(engine, concept_in.name) + if duplicate and duplicate.id != concept_in.id: + raise HTTPException( + status_code=400, detail="Another concept with this name already exists." + ) db_obj = await crud_concept.get(engine, concept_in.id) concept = await crud_concept.update(engine, db_obj=db_obj, obj_in=concept_in) return concept diff --git a/backend/app/api/routes/document.py b/backend/app/api/routes/document.py index 21e52aa..5ee6054 100644 --- a/backend/app/api/routes/document.py +++ b/backend/app/api/routes/document.py @@ -81,6 +81,21 @@ async def delete_document( return {"msg": "File deleted successfully."} +@router.post("/update", response_model=schemas.DocumentBase) +async def update_document( + *, + engine: AIOEngine = Depends(deps.engine_generator), + document_in: schemas.DocumentUpdate, +) -> Any: + document = await crud_document.get(engine, document_in.id) + if not document: + raise HTTPException(status_code=404, detail="Document not found") + updated_document = await crud_document.update( + engine, db_obj=document, obj_in=document_in + ) + return updated_document + + @router.post("/process", response_model=schemas.Msg) async def process_document( *, @@ -212,14 +227,11 @@ async def get_document_metadata( engine: AIOEngine = Depends(deps.engine_generator), ) -> Any: """Get document metadata including annotations and concepts""" - document, annotations, concepts = await crud_document.get_with_related( - engine, document_id - ) + document, annotations = await crud_document.get_with_related(engine, document_id) if document is None: raise HTTPException(status_code=404, detail="Document not found") return { "document": document, "annotations": annotations, - "concepts": concepts, } diff --git a/backend/app/crud/crud_concept.py b/backend/app/crud/crud_concept.py index 49fb735..5bb47e1 100644 --- a/backend/app/crud/crud_concept.py +++ b/backend/app/crud/crud_concept.py @@ -7,6 +7,10 @@ class CRUDConcept(CRUDBase[Concept, ConceptCreate, ConceptUpdate]): + async def get_by_name(self, engine: AIOEngine, name: str) -> Concept | None: + # Check if a concept with the given name already exists + return await engine.find_one(Concept, Concept.name == name) + async def delete(self, engine: AIOEngine, id: str) -> Concept: concept = await super().delete(engine, id=id) diff --git a/backend/app/crud/crud_document.py b/backend/app/crud/crud_document.py index ac0fcf9..e20dd27 100644 --- a/backend/app/crud/crud_document.py +++ b/backend/app/crud/crud_document.py @@ -52,7 +52,7 @@ async def create(self, engine: AIOEngine, obj_in: DocumentCreate) -> Document: async def get_with_related( self, engine: AIOEngine, id: str - ) -> tuple[Document | None, list[Annotation], list[Concept]]: + ) -> tuple[Document | None, list[Annotation]]: """ Retrieves a document by its file_id along with its associated annotations and concepts. """ @@ -64,14 +64,7 @@ async def get_with_related( # Retrieve annotations associated with this document using get_multi for consistency. annotations = await crud_annotation.get_multi(engine, {"file_id": id}) - # Extract annotation IDs. - annotation_ids = [annotation.id for annotation in annotations] - - # Retrieve concepts linked to the annotations using get_multi. - concepts = await crud_concept.get_multi( - engine, {"annotation_ids": {"$in": annotation_ids}} - ) - return document, annotations, concepts + return document, annotations async def delete(self, engine: AIOEngine, id: str) -> Document: document = await super().delete(engine, id=id) diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index f001a0a..80a41e3 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -13,6 +13,7 @@ class DocumentCreate(BaseModel): class DocumentUpdate(BaseModel): + id: str name: str = None metadata: dict[str, Any] | None = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a25aa12..b33cebc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -318,18 +318,17 @@ function App({ documentId, onBack }: AppProps) { }); if (!response.ok) { - throw new Error('Failed to create concept'); + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to create concept'); } const createdConcept = await response.json(); - - // Update local state setDocumentConcepts(prev => [...prev, createdConcept]); setAllConcepts(prev => [...prev, createdConcept]); setNewConceptTitle(''); } catch (error) { console.error('Error creating concept:', error); - // You might want to show an error message to the user here + alert(error instanceof Error ? error.message : 'Failed to create concept.'); } } }; @@ -411,14 +410,15 @@ function App({ documentId, onBack }: AppProps) { body: JSON.stringify(activeConcept), }); if (!response.ok) { - throw new Error('Failed to update concept comment.'); + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to update concept comment.'); } const updatedConcept = await response.json(); setActiveConcept(updatedConcept); setDocumentConcepts(prev => prev.map(c => (c.id === updatedConcept.id ? updatedConcept : c))); } catch (error) { console.error('Error updating concept comment:', error); - alert('Error updating concept comment.'); + alert(error instanceof Error ? error.message : 'Error updating concept comment.'); } }; @@ -489,8 +489,7 @@ function App({ documentId, onBack }: AppProps) { const [error, setError] = useState(null); // Add these states near other state declarations - const [annotations, setAnnotations] = useState([]); - const [documentMetadata, setDocumentMetadata] = useState(null); + const [documentMetadata, setDocumentMetadata] = useState(null); // Update the fetchDocument function to properly handle the PDF data const fetchDocument = async (documentId: string) => { @@ -519,9 +518,7 @@ function App({ documentId, onBack }: AppProps) { // Handle metadata const metadata = await metadataResponse.json(); setDocumentMetadata(metadata.document); - setAnnotations(metadata.annotations); setNotes(metadata.annotations); - setDocumentConcepts(metadata.concepts); setIsLoading(false); } catch (err) { @@ -563,11 +560,15 @@ function App({ documentId, onBack }: AppProps) { fetchConcepts(); }, []); - // Helper function to return a concept's name by matching its id - const getConceptName = (conceptId: string) => { - const concept = allConcepts.find(c => c.id === conceptId); - return concept ? concept.name : conceptId; - }; + // Insert this just before the return statement in your App function: + const sortedConcepts = [...allConcepts].sort((a, b) => { + // Check if concept has any annotation from the current document + const aHas = a.annotation_ids.some(id => notes.some(note => note.id === id)); + const bHas = b.annotation_ids.some(id => notes.some(note => note.id === id)); + if (aHas && !bHas) return -1; + if (!aHas && bHas) return 1; + return a.name.localeCompare(b.name); + }); return (
    @@ -727,8 +728,8 @@ function App({ documentId, onBack }: AppProps) { >
    { - if (note.highlightAreas.length > 0) { - highlightPluginInstance.jumpToHighlightArea(note.highlightAreas[0]); + if (note.highlight_areas.length > 0) { + highlightPluginInstance.jumpToHighlightArea(note.highlight_areas[0]); } }} style={{ cursor: 'pointer' }} @@ -841,27 +842,29 @@ function App({ documentId, onBack }: AppProps) {
    - {/* List of Concepts */} + {/* Concept Selection */}
    - {documentConcepts.length === 0 ? ( -
    No concepts yet
    - ) : ( - documentConcepts.map(concept => ( -
    handleSelectConcept(concept)} - style={{ - border: '1px solid #ccc', - padding: '8px', - marginBottom: '8px', - cursor: 'pointer', - background: activeConcept?.id === concept.id ? '#eef' : 'transparent', - }} - > - {concept.name} -
    - )) - )} +
    {/* Active Concept Details */} diff --git a/frontend/src/pages/PDFList/PDFList.module.scss b/frontend/src/pages/PDFList/PDFList.module.scss index 25e84d7..bb8f43f 100644 --- a/frontend/src/pages/PDFList/PDFList.module.scss +++ b/frontend/src/pages/PDFList/PDFList.module.scss @@ -245,3 +245,23 @@ transform: translate(-50%, -50%); } } + +.actions { + display: flex; + gap: 0.5rem; + margin-left: auto; +} + +.renameInput { + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; + outline: none; + font-size: 14px; + transition: border-color 0.2s ease-in-out; +} + +.renameInput:focus { + border-color: #007bff; + box-shadow: 0 0 3px rgba(0, 123, 255, 0.5); +} diff --git a/frontend/src/pages/PDFList/PDFList.tsx b/frontend/src/pages/PDFList/PDFList.tsx index 16b92fd..6ff9b16 100644 --- a/frontend/src/pages/PDFList/PDFList.tsx +++ b/frontend/src/pages/PDFList/PDFList.tsx @@ -14,6 +14,8 @@ interface PDFListProps { export default function PDFList({ activePdfId, onView, setShowList }: PDFListProps) { const [pdfList, setPdfList] = useState([]); const fileInputRef = useRef(null); + const [editingPdfId, setEditingPdfId] = useState(null); + const [editingPdfName, setEditingPdfName] = useState(''); useEffect(() => { const fetchDocuments = async () => { @@ -77,6 +79,37 @@ export default function PDFList({ activePdfId, onView, setShowList }: PDFListPro } }; + const startEditing = (id: string, currentName: string) => { + setEditingPdfId(id); + setEditingPdfName(currentName); + }; + + const handleRenameSave = async (id: string) => { + try { + const response = await fetch('http://localhost:8000/api/v1/document/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name: editingPdfName }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to update document'); + } + + setPdfList(prev => + prev.map(pdf => (pdf.id === id ? { ...pdf, title: editingPdfName } : pdf)) + ); + setEditingPdfId(null); + setEditingPdfName(''); + } catch (error) { + console.error('Error updating document:', error); + alert( + error instanceof Error ? error.message : 'Failed to update document. Please try again.' + ); + } + }; + const handleFileUpload = async (files: FileList | null) => { if (!files) return; @@ -136,12 +169,74 @@ export default function PDFList({ activePdfId, onView, setShowList }: PDFListPro
      {pdfList.map(pdf => (
    • handleView(pdf.id)} + onClick={pdf.id === editingPdfId ? undefined : () => handleView(pdf.id)} key={pdf.id} className={cx('pdfItem', { active: activePdfId === pdf.id })} > - {pdf.title} - + {editingPdfId === pdf.id ? ( + <> + setEditingPdfName(e.target.value)} + onClick={e => e.stopPropagation()} + onKeyDown={e => { + if (e.key === 'Enter') { + handleRenameSave(pdf.id); + } + if (e.key === 'Escape') { + setEditingPdfId(null); + setEditingPdfName(''); + } + }} + className={cx('renameInput')} + /> + + ) : ( + {pdf.title} + )} +
      + {editingPdfId === pdf.id ? ( + <> + + + + ) : ( + <> + + + + )} +
    • ))}
    From ae864dafef24d56902cd53fef7ae0ab7247920f8 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 17:55:38 +0900 Subject: [PATCH 05/11] Add concept removal, annotation can select concept --- frontend/src/App.tsx | 209 ++++++++++++++++++++++++++++--------------- 1 file changed, 139 insertions(+), 70 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b33cebc..1fe0e2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,20 +32,6 @@ const MenuIcon = () => ( ); -// Add this custom UploadIcon component near the MenuIcon component -const UploadIcon = () => ( - - - - -); - // Add this BackIcon component near other icon components const BackIcon = () => ( @@ -89,6 +75,13 @@ function App({ documentId, onBack }: AppProps) { const [notes, setNotes] = useState([]); const noteEles = useRef(new Map()); + // New state: which annotation (note) is having its "Add to Concept" dropdown open + const [annotationForConceptSelection, setAnnotationForConceptSelection] = useState( + null + ); + // New ref for the concept list popup + const conceptListRef = useRef(null); + // ===== Left sidebar state (Notes/Thumbnails/Bookmarks) ===== const [sidebarVisible, setSidebarVisible] = useState(true); const [activeTab, setActiveTab] = useState<'notes' | 'thumbnails' | 'bookmarks'>('notes'); @@ -441,48 +434,6 @@ function App({ documentId, onBack }: AppProps) { return c.name.toLowerCase().includes(term); }); - // Add this state near other state declarations - const [isUploading, setIsUploading] = useState(false); - - // Update the handleFileUpload function to use fetchDocument instead of fetchPDF - const handleFileUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - setIsUploading(true); - const formData = new FormData(); - formData.append('file', file); - - try { - const response = await fetch('http://localhost:8000/api/v1/document/upload', { - method: 'POST', - headers: { - Accept: 'application/json', - }, - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Upload failed'); - } - - const data = await response.json(); - console.log('Upload successful:', data); - - // After successful upload, fetch and display the new PDF - if (data.id) { - await fetchDocument(data.id); - } - } catch (error) { - console.error('Error uploading file:', error); - alert(error instanceof Error ? error.message : 'Failed to upload PDF. Please try again.'); - } finally { - setIsUploading(false); - event.target.value = ''; - } - }; - // Add these states near other state declarations const [pdfData, setPdfData] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -570,6 +521,80 @@ function App({ documentId, onBack }: AppProps) { return a.name.localeCompare(b.name); }); + // New function to add an annotation to a selected concept + const addAnnotationToConcept = async (conceptId: string, noteId: string) => { + const concept = allConcepts.find(c => c.id === conceptId); + if (!concept) { + console.error('Concept not found'); + return; + } + if (concept.annotation_ids.includes(noteId)) { + console.log('Annotation already added to concept'); + return; + } + const updatedConcept = { ...concept, annotation_ids: [...concept.annotation_ids, noteId] }; + try { + const response = await fetch('http://localhost:8000/api/v1/concept/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedConcept), + }); + if (!response.ok) { + throw new Error('Failed to update annotation reference.'); + } + const updatedConceptFromServer = await response.json(); + updateConcept(updatedConceptFromServer); + } catch (error) { + console.error('Error updating annotation reference:', error); + } + }; + + // New function to remove an annotation from a concept + const removeAnnotationFromConcept = async (conceptId: string, noteId: string) => { + const concept = allConcepts.find(c => c.id === conceptId); + if (!concept) { + console.error('Concept not found'); + return; + } + if (!concept.annotation_ids.includes(noteId)) { + console.log('Annotation not associated with concept'); + return; + } + const updatedAnnotationIds = concept.annotation_ids.filter(id => id !== noteId); + const updatedConcept = { ...concept, annotation_ids: updatedAnnotationIds }; + try { + const response = await fetch('http://localhost:8000/api/v1/concept/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedConcept), + }); + if (!response.ok) { + throw new Error('Failed to update concept'); + } + const updatedConceptFromServer = await response.json(); + updateConcept(updatedConceptFromServer); + } catch (error) { + console.error('Error updating concept removal:', error); + } + }; + + // Close the concept list popup if clicked outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (conceptListRef.current && !conceptListRef.current.contains(event.target as Node)) { + setAnnotationForConceptSelection(null); + } + } + if (annotationForConceptSelection) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [annotationForConceptSelection]); + return (
    @@ -622,20 +647,6 @@ function App({ documentId, onBack }: AppProps) { gap: '8px', }} > -
    - - -
    @@ -747,16 +758,59 @@ function App({ documentId, onBack }: AppProps) { {note.quote}
    {note.comment}
    + {allConcepts.filter(concept => concept.annotation_ids.includes(note.id)) + .length > 0 && ( +
    + Concepts:{' '} + {allConcepts + .filter(concept => concept.annotation_ids.includes(note.id)) + .map(concept => concept.name) + .join(', ')} +
    + )}
    +
    + {annotationForConceptSelection === note.id && ( +
    + {allConcepts + .filter(concept => !concept.annotation_ids.includes(note.id)) + .map(concept => ( +
    { + await addAnnotationToConcept(concept.id, note.id); + setAnnotationForConceptSelection(null); + }} + style={{ padding: '4px', cursor: 'pointer' }} + > + {concept.name} +
    + ))} +
    + )}
    ))} @@ -888,8 +942,23 @@ function App({ documentId, onBack }: AppProps) { marginBottom: '8px', borderRadius: '4px', background: '#f9f9f9', + position: 'relative', }} > +
    { + await removeAnnotationFromConcept(activeConcept.id, note.id); + }} + > + Γ— +
    Quote: {truncate(note.quote, 50)}
    From 9572180c5148b78bf6296ddec129749f46c3d1c7 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 20:49:21 +0900 Subject: [PATCH 06/11] minor fix --- frontend/src/pages/Main/Main.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Main/Main.tsx b/frontend/src/pages/Main/Main.tsx index 2d34756..9b48c6a 100644 --- a/frontend/src/pages/Main/Main.tsx +++ b/frontend/src/pages/Main/Main.tsx @@ -18,6 +18,8 @@ export default function Main() { const [activeTabId, setActiveTabId] = useState(null); const [showList, setShowList] = useState(true); + const activeDocumentId = openTabs.find(tab => tab.id === activeTabId)?.documentId ?? null; + const handleViewPDF = (id: string, url: string, title: string) => { const newTab: PDFTab = { id: crypto.randomUUID(), @@ -56,7 +58,11 @@ export default function Main() {
    - +
    {openTabs.length > 0 && ( From 014d38514222f005db9acf8619998e09f9891374 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 21:30:18 +0900 Subject: [PATCH 07/11] minor fix --- frontend/src/pages/Main/Main.tsx | 39 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/Main/Main.tsx b/frontend/src/pages/Main/Main.tsx index 9b48c6a..449ea00 100644 --- a/frontend/src/pages/Main/Main.tsx +++ b/frontend/src/pages/Main/Main.tsx @@ -21,15 +21,21 @@ export default function Main() { const activeDocumentId = openTabs.find(tab => tab.id === activeTabId)?.documentId ?? null; const handleViewPDF = (id: string, url: string, title: string) => { - const newTab: PDFTab = { - id: crypto.randomUUID(), - documentId: id, - url, - title, - }; - - setOpenTabs(prev => [...prev, newTab]); - setActiveTabId(newTab.id); + // Check if a tab with the given documentId already exists. + // If so, reuse it by setting it as active, rather than creating a new tab. + const existingTab = openTabs.find(tab => tab.documentId === id); + if (existingTab) { + setActiveTabId(existingTab.id); + } else { + const newTab: PDFTab = { + id: crypto.randomUUID(), + documentId: id, + url, + title, + }; + setOpenTabs(prev => [...prev, newTab]); + setActiveTabId(newTab.id); + } setShowList(false); }; @@ -84,14 +90,13 @@ export default function Main() {
    ))}
    - {activeTabId && ( -
    - tab.id === activeTabId)!.documentId} - onBack={handleBackToList} - /> -
    - )} +
    + {openTabs.map(tab => ( +
    + +
    + ))} +
)}
From 6d3c01b56dd205398579469246d5a2cb0274ccd5 Mon Sep 17 00:00:00 2001 From: whikwon Date: Wed, 19 Feb 2025 21:54:27 +0900 Subject: [PATCH 08/11] minor fix --- frontend/src/App.tsx | 51 ++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fe0e2f..8e5da9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,16 +39,30 @@ const BackIcon = () => ( ); -interface AnnotationBase { +interface DocumentMetadata { + id: string; + name: string; + path: string; + content_type: string; + metadata: Record; +} + +interface Annotation { id: string; file_id: string; + comment: string; highlight_areas: HighlightArea[]; - created_at: string; - updated_at: string; + quote: string; +} + +interface Concept { + id: string; + name: string; comment: string; + annotation_ids: string[]; + linked_concept_ids: string[]; } -// Add this interface near other interfaces interface ConceptCreate { name: string; annotation_ids?: number[]; @@ -63,16 +77,8 @@ interface AppProps { function App({ documentId, onBack }: AppProps) { // ===== Note related interfaces and state ===== - interface Note { - id: string; - file_id: string; - comment: string; - highlight_areas: HighlightArea[]; - quote: string; - } - const [message, setMessage] = useState(''); - const [notes, setNotes] = useState([]); + const [notes, setAnnotations] = useState([]); const noteEles = useRef(new Map()); // New state: which annotation (note) is having its "Add to Concept" dropdown open @@ -87,13 +93,6 @@ function App({ documentId, onBack }: AppProps) { const [activeTab, setActiveTab] = useState<'notes' | 'thumbnails' | 'bookmarks'>('notes'); // ===== Concept (Zettelkasten permanent note) related interfaces and state ===== - interface Concept { - id: string; - name: string; - comment: string; - annotation_ids: string[]; - linked_concept_ids: string[]; - } const [documentConcepts, setDocumentConcepts] = useState([]); const [allConcepts, setAllConcepts] = useState([]); @@ -183,14 +182,14 @@ function App({ documentId, onBack }: AppProps) { } const responseJson = await response.json(); - const newNote: Note = { + const newNote: Annotation = { id: responseJson.id, file_id: responseJson.file_id, comment: responseJson.comment, highlight_areas: responseJson.highlight_areas, quote: responseJson.quote, }; - setNotes(prevNotes => [...prevNotes, newNote]); + setAnnotations(prevNotes => [...prevNotes, newNote]); setMessage(''); props.cancel(); } catch (error) { @@ -216,7 +215,7 @@ function App({ documentId, onBack }: AppProps) { ); }; - const jumpToNote = (note: Note) => { + const jumpToNote = (note: Annotation) => { const noteElement = noteEles.current.get(note.id); if (noteElement) { noteElement.scrollIntoView(); @@ -275,7 +274,7 @@ function App({ documentId, onBack }: AppProps) { if (!response.ok) { throw new Error('Failed to delete annotation'); } - setNotes(prev => prev.filter(note => note.id !== id)); + setAnnotations(prev => prev.filter(note => note.id !== id)); } catch (error) { console.error('Error deleting note:', error); setMessage('Failed to delete note'); @@ -440,7 +439,7 @@ function App({ documentId, onBack }: AppProps) { const [error, setError] = useState(null); // Add these states near other state declarations - const [documentMetadata, setDocumentMetadata] = useState(null); + const [documentMetadata, setDocumentMetadata] = useState(null); // Update the fetchDocument function to properly handle the PDF data const fetchDocument = async (documentId: string) => { @@ -469,7 +468,7 @@ function App({ documentId, onBack }: AppProps) { // Handle metadata const metadata = await metadataResponse.json(); setDocumentMetadata(metadata.document); - setNotes(metadata.annotations); + setAnnotations(metadata.annotations); setIsLoading(false); } catch (err) { From 0b3c3a225de36dd24b5778d83805c973b4fe9073 Mon Sep 17 00:00:00 2001 From: whikwon Date: Thu, 20 Feb 2025 21:58:31 +0900 Subject: [PATCH 09/11] add pdfjs web demo --- frontend/src/App.tsx | 1141 +++--------------------------- frontend/src/pages/Main/Main.tsx | 4 +- 2 files changed, 83 insertions(+), 1062 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e5da9d..8290a6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,44 +1,7 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import React from 'react'; -import { Viewer, Worker, Button, Position, PrimaryButton, Tooltip } from '@react-pdf-viewer/core'; -import '@react-pdf-viewer/core/lib/styles/index.css'; -import { - highlightPlugin, - HighlightArea, - MessageIcon, - RenderHighlightContentProps, - RenderHighlightsProps, - RenderHighlightTargetProps, -} from '@react-pdf-viewer/highlight'; -import '@react-pdf-viewer/highlight/lib/styles/index.css'; - -import { searchPlugin } from '@react-pdf-viewer/search'; -import { bookmarkPlugin } from '@react-pdf-viewer/bookmark'; - -import UpdateElectron from '@/components/update'; import './App.css'; -import { thumbnailPlugin } from '@react-pdf-viewer/thumbnail'; -import { toolbarPlugin, ToolbarSlot } from '@react-pdf-viewer/toolbar'; -import '@react-pdf-viewer/thumbnail/lib/styles/index.css'; -import '@react-pdf-viewer/toolbar/lib/styles/index.css'; - -// Dummy icon for the sidebar toggle. You can replace it with any icon component. -const MenuIcon = () => ( - - - - - -); - -// Add this BackIcon component near other icon components -const BackIcon = () => ( - - - -); - interface DocumentMetadata { id: string; name: string; @@ -47,1055 +10,115 @@ interface DocumentMetadata { metadata: Record; } -interface Annotation { - id: string; - file_id: string; - comment: string; - highlight_areas: HighlightArea[]; - quote: string; -} - -interface Concept { - id: string; - name: string; - comment: string; - annotation_ids: string[]; - linked_concept_ids: string[]; -} - -interface ConceptCreate { - name: string; - annotation_ids?: number[]; - comment: string; - linked_concept_ids?: number[]; -} - interface AppProps { documentId: string; onBack: () => void; } -function App({ documentId, onBack }: AppProps) { - // ===== Note related interfaces and state ===== - const [message, setMessage] = useState(''); - const [notes, setAnnotations] = useState([]); - const noteEles = useRef(new Map()); - - // New state: which annotation (note) is having its "Add to Concept" dropdown open - const [annotationForConceptSelection, setAnnotationForConceptSelection] = useState( - null - ); - // New ref for the concept list popup - const conceptListRef = useRef(null); - - // ===== Left sidebar state (Notes/Thumbnails/Bookmarks) ===== - const [sidebarVisible, setSidebarVisible] = useState(true); - const [activeTab, setActiveTab] = useState<'notes' | 'thumbnails' | 'bookmarks'>('notes'); - - // ===== Concept (Zettelkasten permanent note) related interfaces and state ===== - - const [documentConcepts, setDocumentConcepts] = useState([]); - const [allConcepts, setAllConcepts] = useState([]); - const [activeConcept, setActiveConcept] = useState(null); - - // Local state for concept creation and inputs - const [newConceptTitle, setNewConceptTitle] = useState(''); - - // For searching available notes and concepts when linking - const [annotationSearchTerm, setAnnotationSearchTerm] = useState(''); - const [conceptSearchTerm, setConceptSearchTerm] = useState(''); - - // ===== Helper function to truncate text ===== - const truncate = (text: string, max: number) => { - return text.length > max ? text.substring(0, max) + '...' : text; - }; - - // ===== Highlight plugin functions ===== - const renderHighlightTarget = (props: RenderHighlightTargetProps) => ( -
- - - - } - content={() =>
Add a note
} - offset={{ left: 0, top: -8 }} - /> -
- ); - - const renderHighlightContent = (props: RenderHighlightContentProps) => { - return ( -
-
- -
-
-
- { - if (message !== '') { - try { - const response = await fetch('http://localhost:8000/api/v1/annotation/create', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_id: documentMetadata.id, - comment: message, - highlight_areas: props.highlightAreas, - quote: props.selectedText, - }), - }); - - if (!response.ok) { - throw new Error('Failed to create annotation'); - } - - const responseJson = await response.json(); - const newNote: Annotation = { - id: responseJson.id, - file_id: responseJson.file_id, - comment: responseJson.comment, - highlight_areas: responseJson.highlight_areas, - quote: responseJson.quote, - }; - setAnnotations(prevNotes => [...prevNotes, newNote]); - setMessage(''); - props.cancel(); - } catch (error) { - console.error('Error creating annotation:', error); - alert('Failed to create annotation. Please try again.'); - } - } - }} - > - Add - -
- -
-
- ); - }; - - const jumpToNote = (note: Annotation) => { - const noteElement = noteEles.current.get(note.id); - if (noteElement) { - noteElement.scrollIntoView(); - } - }; - - const renderHighlights = (props: RenderHighlightsProps) => ( -
- {notes.map(note => ( - - {note.highlight_areas - .filter(area => area.pageIndex === props.pageIndex) - .map((area, idx) => ( -
jumpToNote(note)} - ref={(ref): void => { - if (ref) noteEles.current.set(note.id, ref); - }} - /> - ))} - - ))} -
- ); - - const highlightPluginInstance = highlightPlugin({ - renderHighlightTarget, - renderHighlightContent, - renderHighlights, - }); - - const searchPluginInstance = searchPlugin(); - const thumbnailPluginInstance = thumbnailPlugin(); - const toolbarPluginInstance = toolbarPlugin(); - const bookmarkPluginInstance = bookmarkPlugin(); - - const { Bookmarks } = bookmarkPluginInstance; - const { Thumbnails } = thumbnailPluginInstance; - const { Toolbar } = toolbarPluginInstance; - - const deleteNote = async (id: string) => { - try { - const response = await fetch('http://localhost:8000/api/v1/annotation/delete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id: id }), - }); - if (!response.ok) { - throw new Error('Failed to delete annotation'); - } - setAnnotations(prev => prev.filter(note => note.id !== id)); - } catch (error) { - console.error('Error deleting note:', error); - setMessage('Failed to delete note'); - } - }; - - // Toggle function for left sidebar visibility - const toggleSidebar = () => { - setSidebarVisible(prev => !prev); - }; - - // ===== Concept-related handlers ===== - - // Update the handleAddConcept function - const handleAddConcept = async () => { - if (newConceptTitle.trim()) { - try { - // Prepare the concept data - const newConcept: ConceptCreate = { - name: newConceptTitle, - annotation_ids: [], - comment: '', - linked_concept_ids: [], - }; - - // Make API request to create concept - const response = await fetch('http://localhost:8000/api/v1/concept/create', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(newConcept), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to create concept'); - } - - const createdConcept = await response.json(); - setDocumentConcepts(prev => [...prev, createdConcept]); - setAllConcepts(prev => [...prev, createdConcept]); - setNewConceptTitle(''); - } catch (error) { - console.error('Error creating concept:', error); - alert(error instanceof Error ? error.message : 'Failed to create concept.'); - } - } - }; - - // Set the active concept for viewing/editing - const handleSelectConcept = (concept: Concept) => { - setActiveConcept(concept); - // Reset search terms when switching concepts - setAnnotationSearchTerm(''); - setConceptSearchTerm(''); - }; - - // Add a note annotation reference (by note id) to the active concept - const addAnnotationRef = async (noteId: string) => { - if (activeConcept && !activeConcept.annotation_ids.includes(noteId)) { - const updatedConcept = { - ...activeConcept, - annotation_ids: [...activeConcept.annotation_ids, noteId], - }; - try { - const response = await fetch('http://localhost:8000/api/v1/concept/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedConcept), - }); - if (!response.ok) { - throw new Error('Failed to update annotation reference.'); - } - const updatedConceptFromServer = await response.json(); - updateConcept(updatedConceptFromServer); - } catch (error) { - console.error('Error updating annotation reference:', error); - } - } - }; - - // Update the addLinkedConcept function to accept a string - const addLinkedConcept = async (otherConceptId: string) => { - if ( - activeConcept && - otherConceptId !== activeConcept.id && - !activeConcept.linked_concept_ids.includes(otherConceptId) - ) { - try { - const payload = { concept_ids: [activeConcept.id, otherConceptId] }; - const response = await fetch('http://localhost:8000/api/v1/link/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error('Failed to create concept link'); - } - const updatedConcept = { - ...activeConcept, - linked_concept_ids: [...activeConcept.linked_concept_ids, otherConceptId], - }; - updateConcept(updatedConcept); - } catch (error) { - console.error('Error creating concept link:', error); - } - } - }; - - // Helper: update the concept in the state list and activeConcept if necessary - const updateConcept = (updated: Concept) => { - setDocumentConcepts(prev => prev.map(c => (c.id === updated.id ? updated : c))); - setAllConcepts(prev => prev.map(c => (c.id === updated.id ? updated : c))); - setActiveConcept(updated); - }; - - // New function to update concept comment on the backend - const handleCommentSubmit = async () => { - if (!activeConcept) return; - try { - const response = await fetch('http://localhost:8000/api/v1/concept/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(activeConcept), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to update concept comment.'); - } - const updatedConcept = await response.json(); - setActiveConcept(updatedConcept); - setDocumentConcepts(prev => prev.map(c => (c.id === updatedConcept.id ? updatedConcept : c))); - } catch (error) { - console.error('Error updating concept comment:', error); - alert(error instanceof Error ? error.message : 'Error updating concept comment.'); - } - }; - - // ===== Filtering functions for search-based linking ===== - const filteredNotes = notes.filter(n => { - // Exclude notes already referenced - if (activeConcept?.annotation_ids.includes(n.id)) return false; - const term = annotationSearchTerm.toLowerCase(); - return n.quote.toLowerCase().includes(term) || n.comment.toLowerCase().includes(term); - }); - - const filteredConcepts = allConcepts.filter(c => { - // Exclude the active concept and already linked ones - if ( - activeConcept && - (c.id === activeConcept.id || activeConcept.linked_concept_ids.includes(c.id)) - ) - return false; - const term = conceptSearchTerm.toLowerCase(); - return c.name.toLowerCase().includes(term); - }); +// BackIcon component for the header +const BackIcon = () => ( + + + +); - // Add these states near other state declarations - const [pdfData, setPdfData] = useState(null); +function App({ documentId, onBack }: AppProps) { + const [documentMetadata, setDocumentMetadata] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Add these states near other state declarations - const [documentMetadata, setDocumentMetadata] = useState(null); - - // Update the fetchDocument function to properly handle the PDF data - const fetchDocument = async (documentId: string) => { - setIsLoading(true); - setError(null); - - try { - // Fetch PDF content and metadata in parallel - const [pdfResponse, metadataResponse] = await Promise.all([ - fetch(`http://localhost:8000/api/v1/document/${documentId}`), - fetch(`http://localhost:8000/api/v1/document/${documentId}/metadata`), - ]); - - if (!pdfResponse.ok) { - throw new Error(`HTTP error! status: ${pdfResponse.status}`); - } - if (!metadataResponse.ok) { - throw new Error(`HTTP error! status: ${metadataResponse.status}`); - } - - // Handle PDF content - create a blob URL instead of base64 - const blob = await pdfResponse.blob(); - const pdfUrl = URL.createObjectURL(blob); - setPdfData(pdfUrl); + // Fetch document metadata + useEffect(() => { + const fetchDocument = async () => { + setIsLoading(true); + setError(null); - // Handle metadata - const metadata = await metadataResponse.json(); - setDocumentMetadata(metadata.document); - setAnnotations(metadata.annotations); + try { + const metadataResponse = await fetch( + `http://localhost:8000/api/v1/document/${documentId}/metadata` + ); - setIsLoading(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load document'); - setIsLoading(false); - } - }; + if (!metadataResponse.ok) { + throw new Error(`HTTP error! status: ${metadataResponse.status}`); + } - // Add cleanup for blob URL when component unmounts - useEffect(() => { - return () => { - // Cleanup blob URL when component unmounts - if (pdfData) { - URL.revokeObjectURL(pdfData); + const metadata = await metadataResponse.json(); + setDocumentMetadata(metadata.document); + setIsLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load document'); + setIsLoading(false); } }; - }, [pdfData]); - useEffect(() => { if (documentId) { - fetchDocument(documentId); + fetchDocument(); } }, [documentId]); - useEffect(() => { - const fetchConcepts = async () => { - try { - const response = await fetch('http://localhost:8000/api/v1/concept/all'); - if (!response.ok) { - throw new Error('Failed to fetch all concepts'); - } - const data = await response.json(); - setAllConcepts(data); - } catch (error) { - console.error('Error fetching concepts:', error); - } - }; - - fetchConcepts(); - }, []); - - // Insert this just before the return statement in your App function: - const sortedConcepts = [...allConcepts].sort((a, b) => { - // Check if concept has any annotation from the current document - const aHas = a.annotation_ids.some(id => notes.some(note => note.id === id)); - const bHas = b.annotation_ids.some(id => notes.some(note => note.id === id)); - if (aHas && !bHas) return -1; - if (!aHas && bHas) return 1; - return a.name.localeCompare(b.name); - }); - - // New function to add an annotation to a selected concept - const addAnnotationToConcept = async (conceptId: string, noteId: string) => { - const concept = allConcepts.find(c => c.id === conceptId); - if (!concept) { - console.error('Concept not found'); - return; - } - if (concept.annotation_ids.includes(noteId)) { - console.log('Annotation already added to concept'); - return; - } - const updatedConcept = { ...concept, annotation_ids: [...concept.annotation_ids, noteId] }; - try { - const response = await fetch('http://localhost:8000/api/v1/concept/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedConcept), - }); - if (!response.ok) { - throw new Error('Failed to update annotation reference.'); - } - const updatedConceptFromServer = await response.json(); - updateConcept(updatedConceptFromServer); - } catch (error) { - console.error('Error updating annotation reference:', error); - } - }; - - // New function to remove an annotation from a concept - const removeAnnotationFromConcept = async (conceptId: string, noteId: string) => { - const concept = allConcepts.find(c => c.id === conceptId); - if (!concept) { - console.error('Concept not found'); - return; - } - if (!concept.annotation_ids.includes(noteId)) { - console.log('Annotation not associated with concept'); - return; - } - const updatedAnnotationIds = concept.annotation_ids.filter(id => id !== noteId); - const updatedConcept = { ...concept, annotation_ids: updatedAnnotationIds }; - try { - const response = await fetch('http://localhost:8000/api/v1/concept/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedConcept), - }); - if (!response.ok) { - throw new Error('Failed to update concept'); - } - const updatedConceptFromServer = await response.json(); - updateConcept(updatedConceptFromServer); - } catch (error) { - console.error('Error updating concept removal:', error); - } - }; - - // Close the concept list popup if clicked outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (conceptListRef.current && !conceptListRef.current.contains(event.target as Node)) { - setAnnotationForConceptSelection(null); - } - } - if (annotationForConceptSelection) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [annotationForConceptSelection]); - return ( -
- -
+
+ {/* Header */} +
+ + {documentMetadata?.name || 'Loading...'} +
+ + {/* Main content */} +
+ {isLoading ? (
-
- - -
- - {(props: ToolbarSlot) => { - const { - CurrentPageInput, - Download, - EnterFullScreen, - GoToNextPage, - GoToPreviousPage, - NumberOfPages, - Print, - ShowSearchPopover, - Zoom, - ZoomIn, - ZoomOut, - } = props; - return ( -
-
- - - - -
-
- -
- - / -
- - - - -
-
- ); - }} -
+ Loading...
- {/* Main content area with left sidebar, PDF viewer, and right sidebar */} -
- {/* Left Sidebar: Notes/Thumbnails/Bookmarks */} - {sidebarVisible && ( -
-
- - - -
- {activeTab === 'notes' && ( - <> - {notes.length === 0 && ( -
There is no note
- )} - {notes.map(note => ( -
-
{ - if (note.highlight_areas.length > 0) { - highlightPluginInstance.jumpToHighlightArea(note.highlight_areas[0]); - } - }} - style={{ cursor: 'pointer' }} - > -
- {note.quote} -
-
{note.comment}
- {allConcepts.filter(concept => concept.annotation_ids.includes(note.id)) - .length > 0 && ( -
- Concepts:{' '} - {allConcepts - .filter(concept => concept.annotation_ids.includes(note.id)) - .map(concept => concept.name) - .join(', ')} -
- )} -
-
- - -
- {annotationForConceptSelection === note.id && ( -
- {allConcepts - .filter(concept => !concept.annotation_ids.includes(note.id)) - .map(concept => ( -
{ - await addAnnotationToConcept(concept.id, note.id); - setAnnotationForConceptSelection(null); - }} - style={{ padding: '4px', cursor: 'pointer' }} - > - {concept.name} -
- ))} -
- )} -
- ))} - - )} - {activeTab === 'thumbnails' && - (() => { - return ; - })()} - {activeTab === 'bookmarks' && - (() => { - return ; - })()} -
- )} - - {/* PDF Viewer */} -
- {isLoading ? ( -
- Loading PDF... -
- ) : error ? ( -
- Error: {error} -
- ) : ( - - )} -
- - {/* Right Sidebar: Concepts (Permanent Notes) */} -
-

Concepts

- {/* New Concept Creation Form */} -
- setNewConceptTitle(e.target.value)} - className="custom-input" - style={{ width: '100%', marginBottom: '4px' }} - /> - - Add Concept - -
- - {/* Concept Selection */} -
- -
- - {/* Active Concept Details */} - {activeConcept && ( -
- {/* Annotation References Section */} -
- Annotation References: - {activeConcept.annotation_ids.length === 0 ? ( -

No annotations referenced.

- ) : ( - activeConcept.annotation_ids.map(refId => { - const note = notes.find(n => n.id === refId); - if (!note) return null; - return ( -
-
{ - await removeAnnotationFromConcept(activeConcept.id, note.id); - }} - > - Γ— -
-
- Quote: {truncate(note.quote, 50)} -
-
- Comment: {truncate(note.comment, 100)} -
-
- ); - }) - )} - setAnnotationSearchTerm(e.target.value)} - className="custom-input" - style={{ width: '100%', marginBottom: '4px' }} - /> - {annotationSearchTerm && ( -
- {filteredNotes.length === 0 ? ( -
No matching notes
- ) : ( - filteredNotes.map(note => ( -
{ - addAnnotationRef(note.id); - setAnnotationSearchTerm(''); - }} - style={{ - padding: '4px', - cursor: 'pointer', - borderBottom: '1px solid #eee', - }} - > - #{note.id}: {note.quote.slice(0, 30)}... -
- )) - )} -
- )} -
- - {/* Comment Section */} -
- Concept Comment: - +
+
+
+
+
+ + +
+
+ + This is used for ornamental images, like borders or watermarks. + +
+
+
+
+ + +
+
+ + +
+
+ Edit alt text (image description) +
+
+
+
+
+
+ +
+ Short description for people who can’t see the image or when the image doesn’t load. +
This alt text was created automatically and may be inaccurate. Learn more
+
+
+ + +
+ +
+
+
+
+
+
+ Couldn’t create alt text automatically + Please write your own alt text or try again later. +
+ +
+
+
+ + + +
+
+
+ + +
+
+ Image alt text settings +
+
+ Automatic alt text +
+
+
+ + +
+
+ Suggests descriptions to help people who can’t see the image or when the image doesn’t load. Learn more +
+
+
+
+ Alt text AI model (180MB) +
+ Runs locally on your device so your data stays private. Required for automatic alt text. +
+
+ + +
+
+
+
+
+ Alt text editor +
+
+ + +
+
+ Helps you make sure all your images have alt text. +
+
+
+
+ +
+
+
+ + + + This modal allows the user to create a signature to add to a PDF document. The user can edit the name (which also serves as the alt text), and optionally save the signature for repeated use. + +
+
+ Add a signature +
+
+ + + +
+
+
+ +
+
+ + Draw your signature +
+
+ + +
+
+
+
+ +
+ Drag a file here to upload + + +
+
+
+
+
+ + + + + +
+ +
+
+ + + + You’ve reached the limit of 5 saved signatures. Remove one to save more. +
+
+ +
+ + +
+
+
+
+ + +
+
+ Edit description +
+
+
+ + + + + +
+ +
+
+ + +
+
+
+ + + +
+ Preparing document for printing… +
+
+ + 0% +
+
+ +
+
+ + + + +
+ + + +
+
+ + diff --git a/frontend/public/pdf.js/web/viewer.js b/frontend/public/pdf.js/web/viewer.js new file mode 100644 index 0000000..d3e99af --- /dev/null +++ b/frontend/public/pdf.js/web/viewer.js @@ -0,0 +1,318 @@ +/* Copyright 2016 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RenderingStates, ScrollMode, SpreadMode } from "./ui_utils.js"; +import { AppOptions } from "./app_options.js"; +import { LinkTarget } from "./pdf_link_service.js"; +import { PDFViewerApplication } from "./app.js"; + +/* eslint-disable-next-line no-unused-vars */ +const pdfjsVersion = + typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_VERSION") : void 0; +/* eslint-disable-next-line no-unused-vars */ +const pdfjsBuild = + typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_BUILD") : void 0; + +const AppConstants = + typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC") + ? { LinkTarget, RenderingStates, ScrollMode, SpreadMode } + : null; + +window.PDFViewerApplication = PDFViewerApplication; +window.PDFViewerApplicationConstants = AppConstants; +window.PDFViewerApplicationOptions = AppOptions; + +function getViewerConfiguration() { + return { + appContainer: document.body, + principalContainer: document.getElementById("mainContainer"), + mainContainer: document.getElementById("viewerContainer"), + viewerContainer: document.getElementById("viewer"), + toolbar: { + container: document.getElementById("toolbarContainer"), + numPages: document.getElementById("numPages"), + pageNumber: document.getElementById("pageNumber"), + scaleSelect: document.getElementById("scaleSelect"), + customScaleOption: document.getElementById("customScaleOption"), + previous: document.getElementById("previous"), + next: document.getElementById("next"), + zoomIn: document.getElementById("zoomInButton"), + zoomOut: document.getElementById("zoomOutButton"), + print: document.getElementById("printButton"), + editorFreeTextButton: document.getElementById("editorFreeTextButton"), + editorFreeTextParamsToolbar: document.getElementById( + "editorFreeTextParamsToolbar" + ), + editorHighlightButton: document.getElementById("editorHighlightButton"), + editorHighlightParamsToolbar: document.getElementById( + "editorHighlightParamsToolbar" + ), + editorHighlightColorPicker: document.getElementById( + "editorHighlightColorPicker" + ), + editorInkButton: document.getElementById("editorInkButton"), + editorInkParamsToolbar: document.getElementById("editorInkParamsToolbar"), + editorStampButton: document.getElementById("editorStampButton"), + editorStampParamsToolbar: document.getElementById( + "editorStampParamsToolbar" + ), + editorSignatureButton: document.getElementById("editorSignatureButton"), + editorSignatureParamsToolbar: document.getElementById( + "editorSignatureParamsToolbar" + ), + download: document.getElementById("downloadButton"), + }, + secondaryToolbar: { + toolbar: document.getElementById("secondaryToolbar"), + toggleButton: document.getElementById("secondaryToolbarToggleButton"), + presentationModeButton: document.getElementById("presentationMode"), + openFileButton: + typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC") + ? document.getElementById("secondaryOpenFile") + : null, + printButton: document.getElementById("secondaryPrint"), + downloadButton: document.getElementById("secondaryDownload"), + viewBookmarkButton: document.getElementById("viewBookmark"), + firstPageButton: document.getElementById("firstPage"), + lastPageButton: document.getElementById("lastPage"), + pageRotateCwButton: document.getElementById("pageRotateCw"), + pageRotateCcwButton: document.getElementById("pageRotateCcw"), + cursorSelectToolButton: document.getElementById("cursorSelectTool"), + cursorHandToolButton: document.getElementById("cursorHandTool"), + scrollPageButton: document.getElementById("scrollPage"), + scrollVerticalButton: document.getElementById("scrollVertical"), + scrollHorizontalButton: document.getElementById("scrollHorizontal"), + scrollWrappedButton: document.getElementById("scrollWrapped"), + spreadNoneButton: document.getElementById("spreadNone"), + spreadOddButton: document.getElementById("spreadOdd"), + spreadEvenButton: document.getElementById("spreadEven"), + imageAltTextSettingsButton: document.getElementById( + "imageAltTextSettings" + ), + imageAltTextSettingsSeparator: document.getElementById( + "imageAltTextSettingsSeparator" + ), + documentPropertiesButton: document.getElementById("documentProperties"), + }, + sidebar: { + // Divs (and sidebar button) + outerContainer: document.getElementById("outerContainer"), + sidebarContainer: document.getElementById("sidebarContainer"), + toggleButton: document.getElementById("sidebarToggleButton"), + resizer: document.getElementById("sidebarResizer"), + // Buttons + thumbnailButton: document.getElementById("viewThumbnail"), + outlineButton: document.getElementById("viewOutline"), + attachmentsButton: document.getElementById("viewAttachments"), + layersButton: document.getElementById("viewLayers"), + // Views + thumbnailView: document.getElementById("thumbnailView"), + outlineView: document.getElementById("outlineView"), + attachmentsView: document.getElementById("attachmentsView"), + layersView: document.getElementById("layersView"), + // View-specific options + currentOutlineItemButton: document.getElementById("currentOutlineItem"), + }, + findBar: { + bar: document.getElementById("findbar"), + toggleButton: document.getElementById("viewFindButton"), + findField: document.getElementById("findInput"), + highlightAllCheckbox: document.getElementById("findHighlightAll"), + caseSensitiveCheckbox: document.getElementById("findMatchCase"), + matchDiacriticsCheckbox: document.getElementById("findMatchDiacritics"), + entireWordCheckbox: document.getElementById("findEntireWord"), + findMsg: document.getElementById("findMsg"), + findResultsCount: document.getElementById("findResultsCount"), + findPreviousButton: document.getElementById("findPreviousButton"), + findNextButton: document.getElementById("findNextButton"), + }, + passwordOverlay: { + dialog: document.getElementById("passwordDialog"), + label: document.getElementById("passwordText"), + input: document.getElementById("password"), + submitButton: document.getElementById("passwordSubmit"), + cancelButton: document.getElementById("passwordCancel"), + }, + documentProperties: { + dialog: document.getElementById("documentPropertiesDialog"), + closeButton: document.getElementById("documentPropertiesClose"), + fields: { + fileName: document.getElementById("fileNameField"), + fileSize: document.getElementById("fileSizeField"), + title: document.getElementById("titleField"), + author: document.getElementById("authorField"), + subject: document.getElementById("subjectField"), + keywords: document.getElementById("keywordsField"), + creationDate: document.getElementById("creationDateField"), + modificationDate: document.getElementById("modificationDateField"), + creator: document.getElementById("creatorField"), + producer: document.getElementById("producerField"), + version: document.getElementById("versionField"), + pageCount: document.getElementById("pageCountField"), + pageSize: document.getElementById("pageSizeField"), + linearized: document.getElementById("linearizedField"), + }, + }, + altTextDialog: { + dialog: document.getElementById("altTextDialog"), + optionDescription: document.getElementById("descriptionButton"), + optionDecorative: document.getElementById("decorativeButton"), + textarea: document.getElementById("descriptionTextarea"), + cancelButton: document.getElementById("altTextCancel"), + saveButton: document.getElementById("altTextSave"), + }, + newAltTextDialog: { + dialog: document.getElementById("newAltTextDialog"), + title: document.getElementById("newAltTextTitle"), + descriptionContainer: document.getElementById( + "newAltTextDescriptionContainer" + ), + textarea: document.getElementById("newAltTextDescriptionTextarea"), + disclaimer: document.getElementById("newAltTextDisclaimer"), + learnMore: document.getElementById("newAltTextLearnMore"), + imagePreview: document.getElementById("newAltTextImagePreview"), + createAutomatically: document.getElementById( + "newAltTextCreateAutomatically" + ), + createAutomaticallyButton: document.getElementById( + "newAltTextCreateAutomaticallyButton" + ), + downloadModel: document.getElementById("newAltTextDownloadModel"), + downloadModelDescription: document.getElementById( + "newAltTextDownloadModelDescription" + ), + error: document.getElementById("newAltTextError"), + errorCloseButton: document.getElementById("newAltTextCloseButton"), + cancelButton: document.getElementById("newAltTextCancel"), + notNowButton: document.getElementById("newAltTextNotNow"), + saveButton: document.getElementById("newAltTextSave"), + }, + altTextSettingsDialog: { + dialog: document.getElementById("altTextSettingsDialog"), + createModelButton: document.getElementById("createModelButton"), + aiModelSettings: document.getElementById("aiModelSettings"), + learnMore: document.getElementById("altTextSettingsLearnMore"), + deleteModelButton: document.getElementById("deleteModelButton"), + downloadModelButton: document.getElementById("downloadModelButton"), + showAltTextDialogButton: document.getElementById( + "showAltTextDialogButton" + ), + altTextSettingsCloseButton: document.getElementById( + "altTextSettingsCloseButton" + ), + closeButton: document.getElementById("altTextSettingsCloseButton"), + }, + addSignatureDialog: { + dialog: document.getElementById("addSignatureDialog"), + panels: document.getElementById("addSignatureActionContainer"), + typeButton: document.getElementById("addSignatureTypeButton"), + typeInput: document.getElementById("addSignatureTypeInput"), + drawButton: document.getElementById("addSignatureDrawButton"), + drawSVG: document.getElementById("addSignatureDraw"), + drawPlaceholder: document.getElementById("addSignatureDrawPlaceholder"), + drawThickness: document.getElementById("addSignatureDrawThickness"), + imageButton: document.getElementById("addSignatureImageButton"), + imageSVG: document.getElementById("addSignatureImage"), + imagePlaceholder: document.getElementById("addSignatureImagePlaceholder"), + imagePicker: document.getElementById("addSignatureFilePicker"), + imagePickerLink: document.getElementById("addSignatureImageBrowse"), + description: document.getElementById("addSignatureDescription"), + clearButton: document.getElementById("clearSignatureButton"), + saveContainer: document.getElementById("addSignatureSaveContainer"), + saveCheckbox: document.getElementById("addSignatureSaveCheckbox"), + errorBar: document.getElementById("addSignatureError"), + errorCloseButton: document.getElementById("addSignatureErrorCloseButton"), + cancelButton: document.getElementById("addSignatureCancelButton"), + addButton: document.getElementById("addSignatureAddButton"), + }, + editSignatureDialog: { + dialog: document.getElementById("editSignatureDescriptionDialog"), + description: document.getElementById("editSignatureDescription"), + editSignatureView: document.getElementById("editSignatureView"), + cancelButton: document.getElementById("editSignatureCancelButton"), + updateButton: document.getElementById("editSignatureUpdateButton"), + }, + annotationEditorParams: { + editorFreeTextFontSize: document.getElementById("editorFreeTextFontSize"), + editorFreeTextColor: document.getElementById("editorFreeTextColor"), + editorInkColor: document.getElementById("editorInkColor"), + editorInkThickness: document.getElementById("editorInkThickness"), + editorInkOpacity: document.getElementById("editorInkOpacity"), + editorStampAddImage: document.getElementById("editorStampAddImage"), + editorSignatureAddSignature: document.getElementById( + "editorSignatureAddSignature" + ), + editorFreeHighlightThickness: document.getElementById( + "editorFreeHighlightThickness" + ), + editorHighlightShowAll: document.getElementById("editorHighlightShowAll"), + }, + printContainer: document.getElementById("printContainer"), + editorUndoBar: { + container: document.getElementById("editorUndoBar"), + message: document.getElementById("editorUndoBarMessage"), + undoButton: document.getElementById("editorUndoBarUndoButton"), + closeButton: document.getElementById("editorUndoBarCloseButton"), + }, + }; +} + +function webViewerLoad() { + const config = getViewerConfiguration(); + + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("GENERIC")) { + // Give custom implementations of the default viewer a simpler way to + // set various `AppOptions`, by dispatching an event once all viewer + // files are loaded but *before* the viewer initialization has run. + const event = new CustomEvent("webviewerloaded", { + bubbles: true, + cancelable: true, + detail: { + source: window, + }, + }); + try { + // Attempt to dispatch the event at the embedding `document`, + // in order to support cases where the viewer is embedded in + // a *dynamically* created