Seth commited on
Commit
2a0e4eb
·
1 Parent(s): 70e3c38
Dockerfile CHANGED
@@ -10,6 +10,11 @@ RUN npm run build
10
  FROM python:3.11-slim
11
  WORKDIR /app
12
 
 
 
 
 
 
13
  # Backend deps
14
  COPY backend/requirements.txt /app/backend/requirements.txt
15
  RUN pip install --no-cache-dir -r /app/backend/requirements.txt
 
10
  FROM python:3.11-slim
11
  WORKDIR /app
12
 
13
+ # Install system dependencies for PDF processing
14
+ RUN apt-get update && apt-get install -y \
15
+ poppler-utils \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
  # Backend deps
19
  COPY backend/requirements.txt /app/backend/requirements.txt
20
  RUN pip install --no-cache-dir -r /app/backend/requirements.txt
README.md CHANGED
@@ -84,6 +84,11 @@ cd backend
84
  pip install -r requirements.txt
85
  ```
86
 
 
 
 
 
 
87
  3. **Run Frontend** (development mode):
88
  ```bash
89
  cd frontend
 
84
  pip install -r requirements.txt
85
  ```
86
 
87
+ **Note for PDF Viewer**: The PDF viewer requires `poppler-utils` to be installed on your system:
88
+ - **macOS**: `brew install poppler`
89
+ - **Ubuntu/Debian**: `sudo apt-get install poppler-utils`
90
+ - **Windows**: Download from [poppler-windows](https://github.com/oschwartz10612/poppler-windows/releases) and add to PATH
91
+
92
  3. **Run Frontend** (development mode):
93
  ```bash
94
  cd frontend
backend/app/main.py CHANGED
@@ -598,6 +598,138 @@ async def get_assets(
598
  }
599
  ]
600
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  @app.get("/api/assets/{asset_id}/download")
602
  async def download_asset(asset_id: int, db: Session = Depends(get_db)):
603
  """Download or preview an asset file"""
 
598
  }
599
  ]
600
 
601
+ @app.delete("/api/assets/{asset_id}")
602
+ async def delete_asset(asset_id: int, db: Session = Depends(get_db)):
603
+ """Delete an asset from both filesystem and database"""
604
+ try:
605
+ from app.models import Asset
606
+
607
+ # Get asset from database
608
+ conn = get_direct_psycopg2_connection()
609
+ if not conn:
610
+ raise HTTPException(status_code=500, detail="Database connection failed")
611
+
612
+ try:
613
+ cursor = conn.cursor()
614
+ cursor.execute("""
615
+ SELECT id, name, file_path
616
+ FROM assets
617
+ WHERE id = %s
618
+ """, (asset_id,))
619
+ row = cursor.fetchone()
620
+
621
+ if not row:
622
+ cursor.close()
623
+ conn.close()
624
+ raise HTTPException(status_code=404, detail="Asset not found")
625
+
626
+ file_path = Path(row[2])
627
+
628
+ # Delete file from filesystem
629
+ if file_path.exists():
630
+ try:
631
+ file_path.unlink()
632
+ print(f"✓ Deleted file: {file_path}")
633
+ except Exception as file_error:
634
+ print(f"⚠ Could not delete file: {file_error}")
635
+ # Continue with database deletion even if file deletion fails
636
+
637
+ # Delete from database
638
+ cursor.execute("DELETE FROM assets WHERE id = %s", (asset_id,))
639
+ conn.commit()
640
+ cursor.close()
641
+ conn.close()
642
+
643
+ return {
644
+ "success": True,
645
+ "message": f"Asset '{row[1]}' deleted successfully",
646
+ "asset_id": asset_id
647
+ }
648
+ except Exception as db_error:
649
+ if conn:
650
+ conn.close()
651
+ raise HTTPException(status_code=500, detail=f"Delete failed: {str(db_error)}")
652
+ except HTTPException:
653
+ raise
654
+ except Exception as e:
655
+ raise HTTPException(status_code=500, detail=str(e))
656
+
657
+ @app.get("/api/assets/{asset_id}/pdf-pages")
658
+ async def get_pdf_pages(asset_id: int, db: Session = Depends(get_db)):
659
+ """Convert PDF to images and return page URLs"""
660
+ try:
661
+ from app.models import Asset
662
+ try:
663
+ from pdf2image import convert_from_path
664
+ except ImportError:
665
+ raise HTTPException(
666
+ status_code=503,
667
+ detail="PDF conversion not available. Please install pdf2image and poppler-utils."
668
+ )
669
+ import base64
670
+ from io import BytesIO
671
+
672
+ # Get asset from database
673
+ conn = get_direct_psycopg2_connection()
674
+ if not conn:
675
+ raise HTTPException(status_code=500, detail="Database connection failed")
676
+
677
+ try:
678
+ cursor = conn.cursor()
679
+ cursor.execute("""
680
+ SELECT id, name, file_path, file_type
681
+ FROM assets
682
+ WHERE id = %s
683
+ """, (asset_id,))
684
+ row = cursor.fetchone()
685
+ cursor.close()
686
+ conn.close()
687
+
688
+ if not row:
689
+ raise HTTPException(status_code=404, detail="Asset not found")
690
+
691
+ file_path = Path(row[2])
692
+ if not file_path.exists():
693
+ raise HTTPException(status_code=404, detail="File not found on disk")
694
+
695
+ if row[3] != "document" or not str(file_path).lower().endswith('.pdf'):
696
+ raise HTTPException(status_code=400, detail="File is not a PDF")
697
+
698
+ # Convert PDF pages to images
699
+ try:
700
+ # Convert PDF to images (one per page)
701
+ images = convert_from_path(str(file_path), dpi=150)
702
+
703
+ # Convert images to base64
704
+ page_images = []
705
+ for i, image in enumerate(images):
706
+ buffered = BytesIO()
707
+ image.save(buffered, format="PNG")
708
+ img_str = base64.b64encode(buffered.getvalue()).decode()
709
+ page_images.append({
710
+ "page_number": i + 1,
711
+ "image_data": f"data:image/png;base64,{img_str}"
712
+ })
713
+
714
+ return {
715
+ "asset_id": asset_id,
716
+ "asset_name": row[1],
717
+ "total_pages": len(page_images),
718
+ "pages": page_images
719
+ }
720
+ except Exception as pdf_error:
721
+ raise HTTPException(status_code=500, detail=f"PDF conversion failed: {str(pdf_error)}")
722
+ except HTTPException:
723
+ raise
724
+ except Exception as db_error:
725
+ if conn:
726
+ conn.close()
727
+ raise HTTPException(status_code=500, detail=str(db_error))
728
+ except HTTPException:
729
+ raise
730
+ except Exception as e:
731
+ raise HTTPException(status_code=500, detail=str(e))
732
+
733
  @app.get("/api/assets/{asset_id}/download")
734
  async def download_asset(asset_id: int, db: Session = Depends(get_db)):
735
  """Download or preview an asset file"""
backend/requirements.txt CHANGED
@@ -11,4 +11,7 @@ passlib[bcrypt]
11
  sqlalchemy
12
  alembic
13
  psycopg2-binary
14
- sqlalchemy-cockroachdb
 
 
 
 
11
  sqlalchemy
12
  alembic
13
  psycopg2-binary
14
+ sqlalchemy-cockroachdb
15
+ pdf2image
16
+ Pillow
17
+ pypdf
frontend/src/pages/Repository.jsx CHANGED
@@ -110,6 +110,9 @@ export default function Repository() {
110
  const [previewAsset, setPreviewAsset] = useState(null);
111
  const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
112
  const [uploadProgress, setUploadProgress] = useState({});
 
 
 
113
  const fileInputRef = useRef(null);
114
 
115
  const toggleProduct = (productId) => {
@@ -183,6 +186,60 @@ export default function Repository() {
183
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
184
  };
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  // Fetch assets on component mount and when upload dialog closes
187
  useEffect(() => {
188
  fetchAssets();
@@ -764,9 +821,15 @@ export default function Repository() {
764
  </Button>
765
  </DropdownMenuTrigger>
766
  <DropdownMenuContent align="end">
767
- <DropdownMenuItem onClick={() => {
768
  setPreviewAsset(asset);
769
  setPreviewDialogOpen(true);
 
 
 
 
 
 
770
  }}>
771
  <Eye className="w-4 h-4 mr-2" /> Preview
772
  </DropdownMenuItem>
@@ -775,7 +838,14 @@ export default function Repository() {
775
  }}>
776
  <Download className="w-4 h-4 mr-2" /> Download
777
  </DropdownMenuItem>
778
- <DropdownMenuItem className="text-red-600">
 
 
 
 
 
 
 
779
  <Trash2 className="w-4 h-4 mr-2" /> Delete
780
  </DropdownMenuItem>
781
  </DropdownMenuContent>
@@ -836,9 +906,15 @@ export default function Repository() {
836
  </Button>
837
  </DropdownMenuTrigger>
838
  <DropdownMenuContent align="end">
839
- <DropdownMenuItem onClick={() => {
840
  setPreviewAsset(asset);
841
  setPreviewDialogOpen(true);
 
 
 
 
 
 
842
  }}>
843
  <Eye className="w-4 h-4 mr-2" /> Preview
844
  </DropdownMenuItem>
@@ -847,7 +923,14 @@ export default function Repository() {
847
  }}>
848
  <Download className="w-4 h-4 mr-2" /> Download
849
  </DropdownMenuItem>
850
- <DropdownMenuItem className="text-red-600">
 
 
 
 
 
 
 
851
  <Trash2 className="w-4 h-4 mr-2" /> Delete
852
  </DropdownMenuItem>
853
  </DropdownMenuContent>
@@ -863,28 +946,93 @@ export default function Repository() {
863
  </div>
864
 
865
  {/* Preview Dialog */}
866
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
867
- <DialogContent className="max-w-4xl max-h-[90vh] overflow-auto">
 
 
 
 
 
 
868
  <DialogHeader>
869
- <DialogTitle>{previewAsset?.name || 'Preview'}</DialogTitle>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  </DialogHeader>
871
- <div className="mt-4">
872
  {previewAsset && (
873
  <div className="space-y-4">
874
  {previewAsset.type === 'image' ? (
875
  <img
876
  src={`/api/assets/${previewAsset.id}/download`}
877
  alt={previewAsset.name}
878
- className="max-w-full h-auto rounded-lg"
879
  />
880
  ) : previewAsset.type === 'video' ? (
881
  <video
882
  src={`/api/assets/${previewAsset.id}/download`}
883
  controls
884
- className="max-w-full rounded-lg"
885
  >
886
  Your browser does not support the video tag.
887
  </video>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
  ) : (
889
  <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg">
890
  <FileText className="w-16 h-16 text-slate-400 mb-4" />
@@ -898,19 +1046,34 @@ export default function Repository() {
898
  </Button>
899
  </div>
900
  )}
901
- <div className="flex items-center justify-between pt-4 border-t">
902
  <div className="text-sm text-slate-600">
903
  <p><strong>Type:</strong> {previewAsset.type}</p>
904
  <p><strong>Size:</strong> {previewAsset.size}</p>
905
  <p><strong>Date:</strong> {previewAsset.date}</p>
906
  </div>
907
- <Button
908
- onClick={() => window.open(`/api/assets/${previewAsset.id}/download`, '_blank')}
909
- variant="outline"
910
- >
911
- <Download className="w-4 h-4 mr-2" />
912
- Download
913
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
  </div>
915
  </div>
916
  )}
 
110
  const [previewAsset, setPreviewAsset] = useState(null);
111
  const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
112
  const [uploadProgress, setUploadProgress] = useState({});
113
+ const [pdfPages, setPdfPages] = useState(null);
114
+ const [isLoadingPdf, setIsLoadingPdf] = useState(false);
115
+ const [isDeleting, setIsDeleting] = useState(false);
116
  const fileInputRef = useRef(null);
117
 
118
  const toggleProduct = (productId) => {
 
186
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
187
  };
188
 
189
+ // Handle delete asset
190
+ const handleDeleteAsset = async (assetId) => {
191
+ setIsDeleting(true);
192
+ try {
193
+ const response = await fetch(`/api/assets/${assetId}`, {
194
+ method: 'DELETE',
195
+ });
196
+
197
+ if (!response.ok) {
198
+ const errorData = await response.json().catch(() => ({ detail: 'Delete failed' }));
199
+ throw new Error(errorData.detail || 'Delete failed');
200
+ }
201
+
202
+ const result = await response.json();
203
+ console.log('Delete result:', result);
204
+
205
+ // Refresh assets list
206
+ await fetchAssets();
207
+
208
+ // Close preview if deleted asset was being previewed
209
+ if (previewAsset && previewAsset.id === assetId) {
210
+ setPreviewDialogOpen(false);
211
+ setPreviewAsset(null);
212
+ setPdfPages(null);
213
+ }
214
+
215
+ alert('Asset deleted successfully');
216
+ } catch (error) {
217
+ console.error('Delete error:', error);
218
+ alert(`Delete failed: ${error.message}`);
219
+ } finally {
220
+ setIsDeleting(false);
221
+ }
222
+ };
223
+
224
+ // Load PDF pages when previewing a PDF
225
+ const loadPdfPages = async (assetId) => {
226
+ setIsLoadingPdf(true);
227
+ setPdfPages(null);
228
+ try {
229
+ const response = await fetch(`/api/assets/${assetId}/pdf-pages`);
230
+ if (response.ok) {
231
+ const data = await response.json();
232
+ setPdfPages(data);
233
+ } else {
234
+ console.error('Failed to load PDF pages');
235
+ }
236
+ } catch (error) {
237
+ console.error('Error loading PDF pages:', error);
238
+ } finally {
239
+ setIsLoadingPdf(false);
240
+ }
241
+ };
242
+
243
  // Fetch assets on component mount and when upload dialog closes
244
  useEffect(() => {
245
  fetchAssets();
 
821
  </Button>
822
  </DropdownMenuTrigger>
823
  <DropdownMenuContent align="end">
824
+ <DropdownMenuItem onClick={async () => {
825
  setPreviewAsset(asset);
826
  setPreviewDialogOpen(true);
827
+ // Load PDF pages if it's a PDF
828
+ if (asset.type === 'document' && asset.name.toLowerCase().endsWith('.pdf')) {
829
+ await loadPdfPages(asset.id);
830
+ } else {
831
+ setPdfPages(null);
832
+ }
833
  }}>
834
  <Eye className="w-4 h-4 mr-2" /> Preview
835
  </DropdownMenuItem>
 
838
  }}>
839
  <Download className="w-4 h-4 mr-2" /> Download
840
  </DropdownMenuItem>
841
+ <DropdownMenuItem
842
+ className="text-red-600"
843
+ onClick={async () => {
844
+ if (confirm(`Are you sure you want to delete "${asset.name}"?`)) {
845
+ await handleDeleteAsset(asset.id);
846
+ }
847
+ }}
848
+ >
849
  <Trash2 className="w-4 h-4 mr-2" /> Delete
850
  </DropdownMenuItem>
851
  </DropdownMenuContent>
 
906
  </Button>
907
  </DropdownMenuTrigger>
908
  <DropdownMenuContent align="end">
909
+ <DropdownMenuItem onClick={async () => {
910
  setPreviewAsset(asset);
911
  setPreviewDialogOpen(true);
912
+ // Load PDF pages if it's a PDF
913
+ if (asset.type === 'document' && asset.name.toLowerCase().endsWith('.pdf')) {
914
+ await loadPdfPages(asset.id);
915
+ } else {
916
+ setPdfPages(null);
917
+ }
918
  }}>
919
  <Eye className="w-4 h-4 mr-2" /> Preview
920
  </DropdownMenuItem>
 
923
  }}>
924
  <Download className="w-4 h-4 mr-2" /> Download
925
  </DropdownMenuItem>
926
+ <DropdownMenuItem
927
+ className="text-red-600"
928
+ onClick={async () => {
929
+ if (confirm(`Are you sure you want to delete "${asset.name}"?`)) {
930
+ await handleDeleteAsset(asset.id);
931
+ }
932
+ }}
933
+ >
934
  <Trash2 className="w-4 h-4 mr-2" /> Delete
935
  </DropdownMenuItem>
936
  </DropdownMenuContent>
 
946
  </div>
947
 
948
  {/* Preview Dialog */}
949
+ <Dialog open={previewDialogOpen} onOpenChange={(open) => {
950
+ setPreviewDialogOpen(open);
951
+ if (!open) {
952
+ setPreviewAsset(null);
953
+ setPdfPages(null);
954
+ }
955
+ }}>
956
+ <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
957
  <DialogHeader>
958
+ <DialogTitle className="flex items-center justify-between">
959
+ <span>{previewAsset?.name || 'Preview'}</span>
960
+ {previewAsset && (
961
+ <Button
962
+ variant="ghost"
963
+ size="icon"
964
+ className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
965
+ onClick={async () => {
966
+ if (confirm(`Are you sure you want to delete "${previewAsset.name}"?`)) {
967
+ await handleDeleteAsset(previewAsset.id);
968
+ }
969
+ }}
970
+ disabled={isDeleting}
971
+ >
972
+ <Trash2 className="w-4 h-4" />
973
+ </Button>
974
+ )}
975
+ </DialogTitle>
976
  </DialogHeader>
977
+ <div className="mt-4 flex-1 overflow-auto">
978
  {previewAsset && (
979
  <div className="space-y-4">
980
  {previewAsset.type === 'image' ? (
981
  <img
982
  src={`/api/assets/${previewAsset.id}/download`}
983
  alt={previewAsset.name}
984
+ className="max-w-full h-auto rounded-lg mx-auto"
985
  />
986
  ) : previewAsset.type === 'video' ? (
987
  <video
988
  src={`/api/assets/${previewAsset.id}/download`}
989
  controls
990
+ className="max-w-full rounded-lg mx-auto"
991
  >
992
  Your browser does not support the video tag.
993
  </video>
994
+ ) : previewAsset.type === 'document' && previewAsset.name.toLowerCase().endsWith('.pdf') ? (
995
+ <div className="space-y-4">
996
+ {isLoadingPdf ? (
997
+ <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg">
998
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
999
+ <p className="text-slate-600">Converting PDF pages to images...</p>
1000
+ </div>
1001
+ ) : pdfPages && pdfPages.pages ? (
1002
+ <div className="space-y-6">
1003
+ <div className="text-sm text-slate-600 bg-blue-50 p-3 rounded-lg">
1004
+ <strong>PDF Viewer:</strong> {pdfPages.total_pages} page{pdfPages.total_pages !== 1 ? 's' : ''}
1005
+ </div>
1006
+ <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
1007
+ {pdfPages.pages.map((page, index) => (
1008
+ <div key={index} className="border border-slate-200 rounded-lg p-4 bg-white shadow-sm">
1009
+ <div className="text-xs text-slate-500 mb-2 font-medium">
1010
+ Page {page.page_number} of {pdfPages.total_pages}
1011
+ </div>
1012
+ <img
1013
+ src={page.image_data}
1014
+ alt={`Page ${page.page_number}`}
1015
+ className="max-w-full h-auto rounded border border-slate-100 shadow-sm"
1016
+ style={{ maxHeight: '800px' }}
1017
+ />
1018
+ </div>
1019
+ ))}
1020
+ </div>
1021
+ </div>
1022
+ ) : (
1023
+ <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg">
1024
+ <FileText className="w-16 h-16 text-slate-400 mb-4" />
1025
+ <p className="text-slate-600 mb-4">Failed to load PDF preview</p>
1026
+ <Button
1027
+ onClick={() => window.open(`/api/assets/${previewAsset.id}/download`, '_blank')}
1028
+ variant="outline"
1029
+ >
1030
+ <Download className="w-4 h-4 mr-2" />
1031
+ Download to view
1032
+ </Button>
1033
+ </div>
1034
+ )}
1035
+ </div>
1036
  ) : (
1037
  <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg">
1038
  <FileText className="w-16 h-16 text-slate-400 mb-4" />
 
1046
  </Button>
1047
  </div>
1048
  )}
1049
+ <div className="flex items-center justify-between pt-4 border-t sticky bottom-0 bg-white">
1050
  <div className="text-sm text-slate-600">
1051
  <p><strong>Type:</strong> {previewAsset.type}</p>
1052
  <p><strong>Size:</strong> {previewAsset.size}</p>
1053
  <p><strong>Date:</strong> {previewAsset.date}</p>
1054
  </div>
1055
+ <div className="flex gap-2">
1056
+ <Button
1057
+ onClick={() => window.open(`/api/assets/${previewAsset.id}/download`, '_blank')}
1058
+ variant="outline"
1059
+ >
1060
+ <Download className="w-4 h-4 mr-2" />
1061
+ Download
1062
+ </Button>
1063
+ <Button
1064
+ variant="outline"
1065
+ className="text-red-600 border-red-200 hover:bg-red-50"
1066
+ onClick={async () => {
1067
+ if (confirm(`Are you sure you want to delete "${previewAsset.name}"?`)) {
1068
+ await handleDeleteAsset(previewAsset.id);
1069
+ }
1070
+ }}
1071
+ disabled={isDeleting}
1072
+ >
1073
+ <Trash2 className="w-4 h-4 mr-2" />
1074
+ {isDeleting ? 'Deleting...' : 'Delete'}
1075
+ </Button>
1076
+ </div>
1077
  </div>
1078
  </div>
1079
  )}