Spaces:
Sleeping
Sleeping
Seth
commited on
Commit
·
2a0e4eb
1
Parent(s):
70e3c38
update
Browse files- Dockerfile +5 -0
- README.md +5 -0
- backend/app/main.py +132 -0
- backend/requirements.txt +4 -1
- frontend/src/pages/Repository.jsx +181 -18
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 867 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
<DialogHeader>
|
| 869 |
-
<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 |
-
<
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
)}
|