Spaces:
Sleeping
Sleeping
Commit ·
ffa0093
0
Parent(s):
clean project (no secrets)
Browse files- .env.example +4 -0
- .gitignore +24 -0
- QUICKSTART.md +182 -0
- README.md +607 -0
- app.js +1076 -0
- index.html +190 -0
- js/api/hfService.js +134 -0
- js/main.js +400 -0
- js/state/stateManager.js +88 -0
- js/ui/uiRenderer.js +209 -0
- js/utils/formatters.js +63 -0
- server/__init__.py +1 -0
- server/app.py +185 -0
- server/config.py +43 -0
- server/requirements.txt +5 -0
- server/routes/__init__.py +1 -0
- server/routes/api.py +231 -0
- server/storage/__init__.py +1 -0
- server/storage/manager.py +427 -0
- server/utils/__init__.py +1 -0
- server/utils/logger.py +49 -0
- server/utils/validators.py +107 -0
- styles.css +1447 -0
- tests/DocVault.postman_collection.json +159 -0
- tests/__init__.py +1 -0
- tests/test_api.sh +132 -0
- tests/test_document.md +28 -0
- tests/test_docvault.py +430 -0
- tests/test_file.txt +2 -0
.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secret key for Flask (change this for production)
|
| 2 |
+
SECRET_KEY=docvault-offline-storage-key-2026
|
| 3 |
+
|
| 4 |
+
# Add other sensitive environment variables here as needed
|
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python cache
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
|
| 6 |
+
# Virtual environment
|
| 7 |
+
venv/
|
| 8 |
+
.env/
|
| 9 |
+
.env
|
| 10 |
+
.env.local
|
| 11 |
+
.env.*.local
|
| 12 |
+
|
| 13 |
+
# Data storage directories
|
| 14 |
+
/data/
|
| 15 |
+
/logs/
|
| 16 |
+
|
| 17 |
+
# IDE settings
|
| 18 |
+
.vscode/
|
| 19 |
+
|
| 20 |
+
# MacOS
|
| 21 |
+
.DS_Store
|
| 22 |
+
|
| 23 |
+
# Test artifacts
|
| 24 |
+
*.log
|
QUICKSTART.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DocVault Quick Start Guide
|
| 2 |
+
|
| 3 |
+
Get up and running with DocVault in 5 minutes!
|
| 4 |
+
|
| 5 |
+
## Step 1: Install Dependencies
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
cd server
|
| 9 |
+
pip install -r requirements.txt
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
## Step 2: Start the Server
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
python app.py
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
You should see:
|
| 19 |
+
```
|
| 20 |
+
Starting DocVault on http://localhost:5000 (DEBUG: True)
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Step 3: Test the API
|
| 24 |
+
|
| 25 |
+
Open a new terminal and run these curl commands:
|
| 26 |
+
|
| 27 |
+
### Create a folder
|
| 28 |
+
```bash
|
| 29 |
+
curl -X POST http://localhost:5000/api/create-folder \
|
| 30 |
+
-H "Content-Type: application/json" \
|
| 31 |
+
-H "X-User-ID: test_user" \
|
| 32 |
+
-d '{"folder_path": "MyDocuments"}'
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### Upload a file
|
| 36 |
+
```bash
|
| 37 |
+
curl -X POST http://localhost:5000/api/upload-file \
|
| 38 |
+
-H "X-User-ID: test_user" \
|
| 39 |
+
-F "folder_path=MyDocuments" \
|
| 40 |
+
-F "file=@tests/test_file.txt"
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### List contents
|
| 44 |
+
```bash
|
| 45 |
+
curl -X GET http://localhost:5000/api/list \
|
| 46 |
+
-H "X-User-ID: test_user"
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### Check storage stats
|
| 50 |
+
```bash
|
| 51 |
+
curl -X GET http://localhost:5000/api/storage-stats \
|
| 52 |
+
-H "X-User-ID: test_user"
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## Step 4: Using Python Requests
|
| 56 |
+
|
| 57 |
+
```python
|
| 58 |
+
import requests
|
| 59 |
+
import json
|
| 60 |
+
|
| 61 |
+
BASE_URL = "http://localhost:5000/api"
|
| 62 |
+
HEADERS = {"X-User-ID": "test_user"}
|
| 63 |
+
|
| 64 |
+
# Create folder
|
| 65 |
+
response = requests.post(
|
| 66 |
+
f"{BASE_URL}/create-folder",
|
| 67 |
+
json={"folder_path": "Projects"},
|
| 68 |
+
headers=HEADERS
|
| 69 |
+
)
|
| 70 |
+
print(response.json())
|
| 71 |
+
|
| 72 |
+
# Upload file
|
| 73 |
+
with open("tests/test_document.md", "rb") as f:
|
| 74 |
+
files = {"file": f}
|
| 75 |
+
data = {"folder_path": "Projects"}
|
| 76 |
+
response = requests.post(
|
| 77 |
+
f"{BASE_URL}/upload-file",
|
| 78 |
+
files=files,
|
| 79 |
+
data=data,
|
| 80 |
+
headers=HEADERS
|
| 81 |
+
)
|
| 82 |
+
print(response.json())
|
| 83 |
+
|
| 84 |
+
# List contents
|
| 85 |
+
response = requests.get(
|
| 86 |
+
f"{BASE_URL}/list?folder_path=Projects",
|
| 87 |
+
headers=HEADERS
|
| 88 |
+
)
|
| 89 |
+
print(json.dumps(response.json(), indent=2))
|
| 90 |
+
|
| 91 |
+
# Storage stats
|
| 92 |
+
response = requests.get(
|
| 93 |
+
f"{BASE_URL}/storage-stats",
|
| 94 |
+
headers=HEADERS
|
| 95 |
+
)
|
| 96 |
+
print(response.json())
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
## Step 5: Using Postman
|
| 100 |
+
|
| 101 |
+
1. Import `tests/DocVault.postman_collection.json` into Postman
|
| 102 |
+
2. Set the `X-User-ID` header for each request
|
| 103 |
+
3. Test all endpoints
|
| 104 |
+
|
| 105 |
+
## File Structure
|
| 106 |
+
|
| 107 |
+
After running some operations, your file structure will look like:
|
| 108 |
+
|
| 109 |
+
```
|
| 110 |
+
data/
|
| 111 |
+
└── test_user/
|
| 112 |
+
├── MyDocuments/
|
| 113 |
+
│ ├── test_file.txt
|
| 114 |
+
│ └── .gitkeep
|
| 115 |
+
├── Projects/
|
| 116 |
+
│ ├── test_document.md
|
| 117 |
+
│ └── .gitkeep
|
| 118 |
+
└── .gitkeep
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Directory Size
|
| 122 |
+
|
| 123 |
+
Check storage usage:
|
| 124 |
+
```bash
|
| 125 |
+
# Windows
|
| 126 |
+
dir /s /b data\
|
| 127 |
+
|
| 128 |
+
# Linux/Mac
|
| 129 |
+
du -sh data/
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## Troubleshooting
|
| 133 |
+
|
| 134 |
+
### Port 5000 Already in Use
|
| 135 |
+
```bash
|
| 136 |
+
# Find process using port 5000
|
| 137 |
+
netstat -ano | findstr :5000 # Windows
|
| 138 |
+
lsof -i :5000 # Mac/Linux
|
| 139 |
+
|
| 140 |
+
# Kill process
|
| 141 |
+
taskkill /PID <PID> /F # Windows
|
| 142 |
+
kill -9 <PID> # Mac/Linux
|
| 143 |
+
|
| 144 |
+
# Or use different port (modify app.py)
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### Module Import Errors
|
| 148 |
+
Ensure you're in the correct directory:
|
| 149 |
+
```bash
|
| 150 |
+
cd /path/to/DocVault
|
| 151 |
+
python server/app.py
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### Permission Denied
|
| 155 |
+
Ensure write permissions:
|
| 156 |
+
```bash
|
| 157 |
+
chmod 755 data logs # Mac/Linux
|
| 158 |
+
# Windows should work automatically
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
## Next Steps
|
| 162 |
+
|
| 163 |
+
1. **Explore the API** - Try all endpoints with different user IDs
|
| 164 |
+
2. **Test with files** - Upload various file types
|
| 165 |
+
3. **Integrate** - Embed DocVault into your application
|
| 166 |
+
4. **Scale** - Plan for database backend or cloud storage
|
| 167 |
+
|
| 168 |
+
## API Reference
|
| 169 |
+
|
| 170 |
+
See [README.md](../README.md) for complete API documentation.
|
| 171 |
+
|
| 172 |
+
## Support
|
| 173 |
+
|
| 174 |
+
For issues:
|
| 175 |
+
1. Check logs in `logs/` directory
|
| 176 |
+
2. Review error messages in response
|
| 177 |
+
3. Verify file/folder names are valid
|
| 178 |
+
4. Ensure user ID is consistent across requests
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
**Happy documenting with DocVault!** 📂✨
|
README.md
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DocVault - Offline-First Document Storage System
|
| 2 |
+
|
| 3 |
+
Complete offline-first document storage system built with **Python Flask** and local filesystem storage. No cloud dependencies, fully self-contained, and ready for future Hugging Face integration.
|
| 4 |
+
|
| 5 |
+
## 🎯 Features
|
| 6 |
+
|
| 7 |
+
### Core Features
|
| 8 |
+
- ✅ **Create Files and Folders** - Including nested directory structures
|
| 9 |
+
- ✅ **Delete Items** - Individual files/folders or bulk deletion
|
| 10 |
+
- ✅ **Upload Files** - Support for 50+ file types
|
| 11 |
+
- ✅ **List Contents** - Browse file/folder hierarchy with metadata
|
| 12 |
+
- ✅ **Rename Items** - Rename files and folders
|
| 13 |
+
- ✅ **Security** - Path traversal prevention, input validation
|
| 14 |
+
- ✅ **Logging** - Comprehensive logging with rotation
|
| 15 |
+
- ✅ **File Metadata** - Size, creation time, modification time
|
| 16 |
+
- ✅ **Multi-User** - Support for multiple users via user IDs
|
| 17 |
+
|
| 18 |
+
### Storage
|
| 19 |
+
- Local filesystem storage in `data/{user_id}/` structure
|
| 20 |
+
- Automatic marker files (`.gitkeep`) for HF integration compatibility
|
| 21 |
+
- Prevents duplicate filenames with auto-numbering
|
| 22 |
+
- Maintains clean directory structure
|
| 23 |
+
|
| 24 |
+
## 📁 Project Structure
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
.
|
| 28 |
+
├── server/
|
| 29 |
+
│ ├── app.py # Flask application
|
| 30 |
+
│ ├── config.py # Configuration settings
|
| 31 |
+
│ ├── requirements.txt # Python dependencies
|
| 32 |
+
│ ├── routes/
|
| 33 |
+
│ │ └── api.py # API endpoints
|
| 34 |
+
│ ├── storage/
|
| 35 |
+
│ │ └── manager.py # Storage operations
|
| 36 |
+
│ └── utils/
|
| 37 |
+
│ ├── logger.py # Logging setup
|
| 38 |
+
│ └── validators.py # Path validation & security
|
| 39 |
+
├── data/ # Storage directory (auto-created)
|
| 40 |
+
├── logs/ # Log files (auto-created)
|
| 41 |
+
├── tests/
|
| 42 |
+
│ ├── test_docvault.py # Unit tests
|
| 43 |
+
│ └── test_api.sh # API test script
|
| 44 |
+
└── README.md # This file
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## 🚀 Getting Started
|
| 48 |
+
|
| 49 |
+
### Prerequisites
|
| 50 |
+
- Python 3.8+
|
| 51 |
+
- Flask 2.3+
|
| 52 |
+
- pip (Python package manager)
|
| 53 |
+
|
| 54 |
+
### Installation
|
| 55 |
+
|
| 56 |
+
1. **Clone or download the project**
|
| 57 |
+
```bash
|
| 58 |
+
cd path/to/DocVault
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
2. **Create virtual environment** (recommended)
|
| 62 |
+
```bash
|
| 63 |
+
python -m venv venv
|
| 64 |
+
|
| 65 |
+
# Activate it:
|
| 66 |
+
# On Windows:
|
| 67 |
+
venv\Scripts\activate
|
| 68 |
+
# On Linux/Mac:
|
| 69 |
+
source venv/bin/activate
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
3. **Install dependencies**
|
| 73 |
+
```bash
|
| 74 |
+
pip install -r server/requirements.txt
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### Running the Server
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
python server/app.py
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Server will start at `http://localhost:5000`
|
| 84 |
+
|
| 85 |
+
View API docs: `http://localhost:5000/docs`
|
| 86 |
+
|
| 87 |
+
## 📚 API Endpoints
|
| 88 |
+
|
| 89 |
+
### 1. Health Check
|
| 90 |
+
```
|
| 91 |
+
GET /api/health
|
| 92 |
+
```
|
| 93 |
+
Check if server is running.
|
| 94 |
+
|
| 95 |
+
**Response:**
|
| 96 |
+
```json
|
| 97 |
+
{
|
| 98 |
+
"status": "healthy",
|
| 99 |
+
"service": "DocVault"
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
### 2. Create Folder
|
| 106 |
+
```
|
| 107 |
+
POST /api/create-folder
|
| 108 |
+
```
|
| 109 |
+
Create a new folder (including nested folders).
|
| 110 |
+
|
| 111 |
+
**Request:**
|
| 112 |
+
```bash
|
| 113 |
+
curl -X POST http://localhost:5000/api/create-folder \
|
| 114 |
+
-H "Content-Type: application/json" \
|
| 115 |
+
-H "X-User-ID: user123" \
|
| 116 |
+
-d '{
|
| 117 |
+
"folder_path": "Documents/Projects/MyProject"
|
| 118 |
+
}'
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
**Response (Success):**
|
| 122 |
+
```json
|
| 123 |
+
{
|
| 124 |
+
"success": true,
|
| 125 |
+
"message": "Folder created: Documents/Projects/MyProject",
|
| 126 |
+
"folder": {
|
| 127 |
+
"name": "MyProject",
|
| 128 |
+
"path": "Documents/Projects/MyProject",
|
| 129 |
+
"created_at": "2026-04-09T10:30:00.000000",
|
| 130 |
+
"type": "folder"
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
### 3. Delete Folder
|
| 138 |
+
```
|
| 139 |
+
POST /api/delete-folder
|
| 140 |
+
```
|
| 141 |
+
Delete a folder. Use `force: true` to delete non-empty folders.
|
| 142 |
+
|
| 143 |
+
**Request:**
|
| 144 |
+
```bash
|
| 145 |
+
curl -X POST http://localhost:5000/api/delete-folder \
|
| 146 |
+
-H "Content-Type: application/json" \
|
| 147 |
+
-H "X-User-ID: user123" \
|
| 148 |
+
-d '{
|
| 149 |
+
"folder_path": "Documents/Projects/MyProject",
|
| 150 |
+
"force": true
|
| 151 |
+
}'
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
**Response:**
|
| 155 |
+
```json
|
| 156 |
+
{
|
| 157 |
+
"success": true,
|
| 158 |
+
"message": "Folder deleted: Documents/Projects/MyProject"
|
| 159 |
+
}
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
### 4. Upload File
|
| 165 |
+
```
|
| 166 |
+
POST /api/upload-file
|
| 167 |
+
```
|
| 168 |
+
Upload a file to a specific folder.
|
| 169 |
+
|
| 170 |
+
**Request:**
|
| 171 |
+
```bash
|
| 172 |
+
curl -X POST http://localhost:5000/api/upload-file \
|
| 173 |
+
-H "X-User-ID: user123" \
|
| 174 |
+
-F "folder_path=Documents" \
|
| 175 |
+
-F "file=@/path/to/file.pdf"
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
**Response:**
|
| 179 |
+
```json
|
| 180 |
+
{
|
| 181 |
+
"success": true,
|
| 182 |
+
"message": "File uploaded: report.pdf",
|
| 183 |
+
"file": {
|
| 184 |
+
"name": "report.pdf",
|
| 185 |
+
"path": "Documents/report.pdf",
|
| 186 |
+
"size": 102400,
|
| 187 |
+
"size_formatted": "100.00 KB",
|
| 188 |
+
"uploaded_at": "2026-04-09T10:35:00.000000",
|
| 189 |
+
"type": "file"
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
### 5. List Contents
|
| 197 |
+
```
|
| 198 |
+
GET /api/list
|
| 199 |
+
```
|
| 200 |
+
List all files and folders in a directory.
|
| 201 |
+
|
| 202 |
+
**Request:**
|
| 203 |
+
```bash
|
| 204 |
+
# List root
|
| 205 |
+
curl -X GET "http://localhost:5000/api/list" \
|
| 206 |
+
-H "X-User-ID: user123"
|
| 207 |
+
|
| 208 |
+
# List specific folder
|
| 209 |
+
curl -X GET "http://localhost:5000/api/list?folder_path=Documents" \
|
| 210 |
+
-H "X-User-ID: user123"
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
**Response:**
|
| 214 |
+
```json
|
| 215 |
+
{
|
| 216 |
+
"success": true,
|
| 217 |
+
"path": "Documents",
|
| 218 |
+
"folders": [
|
| 219 |
+
{
|
| 220 |
+
"name": "Projects",
|
| 221 |
+
"type": "folder",
|
| 222 |
+
"path": "Documents/Projects",
|
| 223 |
+
"created_at": "2026-04-09T10:30:00.000000",
|
| 224 |
+
"modified_at": "2026-04-09T10:30:00.000000"
|
| 225 |
+
}
|
| 226 |
+
],
|
| 227 |
+
"files": [
|
| 228 |
+
{
|
| 229 |
+
"name": "notes.txt",
|
| 230 |
+
"type": "file",
|
| 231 |
+
"path": "Documents/notes.txt",
|
| 232 |
+
"size": 1024,
|
| 233 |
+
"size_formatted": "1.00 KB",
|
| 234 |
+
"created_at": "2026-04-09T10:35:00.000000",
|
| 235 |
+
"modified_at": "2026-04-09T10:35:00.000000"
|
| 236 |
+
}
|
| 237 |
+
],
|
| 238 |
+
"summary": {
|
| 239 |
+
"total_folders": 1,
|
| 240 |
+
"total_files": 1
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
### 6. Rename File/Folder
|
| 248 |
+
```
|
| 249 |
+
POST /api/rename
|
| 250 |
+
```
|
| 251 |
+
Rename a file or folder.
|
| 252 |
+
|
| 253 |
+
**Request:**
|
| 254 |
+
```bash
|
| 255 |
+
curl -X POST http://localhost:5000/api/rename \
|
| 256 |
+
-H "Content-Type: application/json" \
|
| 257 |
+
-H "X-User-ID: user123" \
|
| 258 |
+
-d '{
|
| 259 |
+
"item_path": "Documents/OldName",
|
| 260 |
+
"new_name": "NewName"
|
| 261 |
+
}'
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
**Response:**
|
| 265 |
+
```json
|
| 266 |
+
{
|
| 267 |
+
"success": true,
|
| 268 |
+
"message": "Folder renamed to: NewName",
|
| 269 |
+
"item": {
|
| 270 |
+
"name": "NewName",
|
| 271 |
+
"type": "folder",
|
| 272 |
+
"path": "Documents/NewName"
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
---
|
| 278 |
+
|
| 279 |
+
### 7. Storage Statistics
|
| 280 |
+
```
|
| 281 |
+
GET /api/storage-stats
|
| 282 |
+
```
|
| 283 |
+
Get storage usage statistics.
|
| 284 |
+
|
| 285 |
+
**Request:**
|
| 286 |
+
```bash
|
| 287 |
+
curl -X GET "http://localhost:5000/api/storage-stats" \
|
| 288 |
+
-H "X-User-ID: user123"
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
**Response:**
|
| 292 |
+
```json
|
| 293 |
+
{
|
| 294 |
+
"success": true,
|
| 295 |
+
"total_size": 5242880,
|
| 296 |
+
"total_size_formatted": "5.00 MB",
|
| 297 |
+
"total_files": 42,
|
| 298 |
+
"total_folders": 8
|
| 299 |
+
}
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
### 8. Download File
|
| 305 |
+
```
|
| 306 |
+
GET /api/download/<file_path>
|
| 307 |
+
```
|
| 308 |
+
Download a file.
|
| 309 |
+
|
| 310 |
+
**Request:**
|
| 311 |
+
```bash
|
| 312 |
+
curl -X GET "http://localhost:5000/api/download/Documents/report.pdf" \
|
| 313 |
+
-H "X-User-ID: user123" \
|
| 314 |
+
-o report.pdf
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
## 🔐 Security Features
|
| 320 |
+
|
| 321 |
+
### Path Traversal Prevention
|
| 322 |
+
- Validates all paths are within user's directory
|
| 323 |
+
- Prevents `../` and similar attacks
|
| 324 |
+
- Normalizes paths before operations
|
| 325 |
+
|
| 326 |
+
### Input Validation
|
| 327 |
+
- Filename restrictions: alphanumeric, hyphens, underscores, dots
|
| 328 |
+
- Maximum filename length: 255 characters
|
| 329 |
+
- Blocks Windows reserved names (CON, PRN, AUX, etc.)
|
| 330 |
+
|
| 331 |
+
### File Type Restrictions
|
| 332 |
+
Allowed extensions: `txt`, `pdf`, `png`, `jpg`, `jpeg`, `gif`, `doc`, `docx`, `xls`, `xlsx`, `ppt`, `pptx`, `zip`, `rar`, `json`, `xml`, `csv`, `md`, `py`, `js`, `html`, `css`, `yml`, `yaml`
|
| 333 |
+
|
| 334 |
+
Maximum file size: 50 MB (configurable)
|
| 335 |
+
|
| 336 |
+
---
|
| 337 |
+
|
| 338 |
+
## 🧪 Testing
|
| 339 |
+
|
| 340 |
+
### Unit Tests
|
| 341 |
+
```bash
|
| 342 |
+
python -m pytest tests/test_docvault.py -v
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
Or using unittest:
|
| 346 |
+
```bash
|
| 347 |
+
python -m unittest tests.test_docvault -v
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
### Manual API Testing
|
| 351 |
+
|
| 352 |
+
#### Using curl (Linux/Mac/WSL)
|
| 353 |
+
```bash
|
| 354 |
+
bash tests/test_api.sh
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
#### Using Postman
|
| 358 |
+
1. Import the endpoints from the documentation above
|
| 359 |
+
2. Set header: `X-User-ID: test_user`
|
| 360 |
+
3. Test each endpoint
|
| 361 |
+
|
| 362 |
+
#### Using PowerShell (Windows)
|
| 363 |
+
```powershell
|
| 364 |
+
# Create folder
|
| 365 |
+
$headers = @{"X-User-ID" = "test_user"; "Content-Type" = "application/json"}
|
| 366 |
+
$body = '{"folder_path": "Documents"}'
|
| 367 |
+
Invoke-RestMethod -Uri "http://localhost:5000/api/create-folder" `
|
| 368 |
+
-Method POST -Headers $headers -Body $body
|
| 369 |
+
|
| 370 |
+
# Upload file
|
| 371 |
+
$headers = @{"X-User-ID" = "test_user"}
|
| 372 |
+
$form = @{"folder_path" = "Documents"; "file" = Get-Item "path/to/file.txt"}
|
| 373 |
+
Invoke-RestMethod -Uri "http://localhost:5000/api/upload-file" `
|
| 374 |
+
-Method POST -Headers $headers -Form $form
|
| 375 |
+
|
| 376 |
+
# List contents
|
| 377 |
+
$headers = @{"X-User-ID" = "test_user"}
|
| 378 |
+
Invoke-RestMethod -Uri "http://localhost:5000/api/list" `
|
| 379 |
+
-Method GET -Headers $headers
|
| 380 |
+
```
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
## 📝 Configuration
|
| 385 |
+
|
| 386 |
+
Edit `server/config.py` to customize:
|
| 387 |
+
|
| 388 |
+
```python
|
| 389 |
+
# Storage location
|
| 390 |
+
DATA_DIR = "data"
|
| 391 |
+
|
| 392 |
+
# Maximum file size (bytes)
|
| 393 |
+
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
|
| 394 |
+
|
| 395 |
+
# Allowed file extensions
|
| 396 |
+
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', ...}
|
| 397 |
+
|
| 398 |
+
# Debug mode
|
| 399 |
+
DEBUG = True
|
| 400 |
+
|
| 401 |
+
# Logging level
|
| 402 |
+
LOG_LEVEL = "INFO"
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
---
|
| 406 |
+
|
| 407 |
+
## 🗂️ Storage Structure
|
| 408 |
+
|
| 409 |
+
Files are organized by user ID:
|
| 410 |
+
|
| 411 |
+
```
|
| 412 |
+
data/
|
| 413 |
+
├── default_user/
|
| 414 |
+
│ ├── Documents/
|
| 415 |
+
│ │ ├── report.pdf
|
| 416 |
+
│ │ ├── notes.txt
|
| 417 |
+
│ │ ├── Projects/
|
| 418 |
+
│ │ │ ├── ProjectA/
|
| 419 |
+
│ │ │ │ ├── .gitkeep
|
| 420 |
+
│ │ │ │ └── code.py
|
| 421 |
+
│ │ │ └── .gitkeep
|
| 422 |
+
│ │ └── .gitkeep
|
| 423 |
+
│ ├── Images/
|
| 424 |
+
│ └── .gitkeep
|
| 425 |
+
├── user123/
|
| 426 |
+
└── user456/
|
| 427 |
+
```
|
| 428 |
+
|
| 429 |
+
The `.gitkeep` marker file:
|
| 430 |
+
- Identifies folders in HF integration
|
| 431 |
+
- Allows tracking empty directories in git
|
| 432 |
+
- Automatically created with new folders
|
| 433 |
+
|
| 434 |
+
---
|
| 435 |
+
|
| 436 |
+
## 🔌 API Response Format
|
| 437 |
+
|
| 438 |
+
### Success Response
|
| 439 |
+
```json
|
| 440 |
+
{
|
| 441 |
+
"success": true,
|
| 442 |
+
"message": "Operation successful",
|
| 443 |
+
"data": {...}
|
| 444 |
+
}
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
### Error Response
|
| 448 |
+
```json
|
| 449 |
+
{
|
| 450 |
+
"success": false,
|
| 451 |
+
"error": "Description of error",
|
| 452 |
+
"code": "ERROR_CODE"
|
| 453 |
+
}
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
### Common Status Codes
|
| 457 |
+
- `200`: OK
|
| 458 |
+
- `201`: Created
|
| 459 |
+
- `400`: Bad Request
|
| 460 |
+
- `404`: Not Found
|
| 461 |
+
- `413`: Payload Too Large
|
| 462 |
+
- `500`: Internal Server Error
|
| 463 |
+
|
| 464 |
+
---
|
| 465 |
+
|
| 466 |
+
## 🔄 Future Integration: Hugging Face
|
| 467 |
+
|
| 468 |
+
The system is designed for easy HF integration:
|
| 469 |
+
|
| 470 |
+
### Mapping to HF Structure
|
| 471 |
+
```
|
| 472 |
+
Local: data/user/folder/file.txt
|
| 473 |
+
↓
|
| 474 |
+
HF Git: repo/user/folder/file.txt
|
| 475 |
+
```
|
| 476 |
+
|
| 477 |
+
### When integrating with HF:
|
| 478 |
+
1. Replace `StorageManager` with `HFStorageManager`
|
| 479 |
+
2. Use git operations instead of filesystem
|
| 480 |
+
3. Maintain same API interface
|
| 481 |
+
4. Folder marker files (`.gitkeep`) enable empty folder tracking
|
| 482 |
+
|
| 483 |
+
### Integration Points
|
| 484 |
+
- Folder creation → git mkdir + .gitkeep commit
|
| 485 |
+
- File upload → git commit with file
|
| 486 |
+
- Deletion → git remove file/folder
|
| 487 |
+
- Listing → git tree navigation
|
| 488 |
+
- Renaming → git move + commit
|
| 489 |
+
|
| 490 |
+
---
|
| 491 |
+
|
| 492 |
+
## 📊 Logging
|
| 493 |
+
|
| 494 |
+
Logs are automatically saved and rotated:
|
| 495 |
+
|
| 496 |
+
```
|
| 497 |
+
logs/
|
| 498 |
+
├── __main__.log
|
| 499 |
+
├── routes.api.log
|
| 500 |
+
├── storage.manager.log
|
| 501 |
+
└── utils.logger.log
|
| 502 |
+
```
|
| 503 |
+
|
| 504 |
+
- Max log file size: 10 MB
|
| 505 |
+
- Backup count: 5 files
|
| 506 |
+
- Format: `timestamp - logger - level - message`
|
| 507 |
+
|
| 508 |
+
---
|
| 509 |
+
|
| 510 |
+
## 🛠️ Troubleshooting
|
| 511 |
+
|
| 512 |
+
### Port Already in Use
|
| 513 |
+
```bash
|
| 514 |
+
# Change port in app.py or set environment variable
|
| 515 |
+
export FLASK_PORT=5001
|
| 516 |
+
python server/app.py
|
| 517 |
+
```
|
| 518 |
+
|
| 519 |
+
### Permission Denied Creating Files
|
| 520 |
+
- Ensure write permission to `data/` directory
|
| 521 |
+
- On Linux/Mac: `chmod 755 data/`
|
| 522 |
+
|
| 523 |
+
### CORS Issues
|
| 524 |
+
- CORS is enabled by default for local development
|
| 525 |
+
- Modify `server/app.py` for production settings
|
| 526 |
+
|
| 527 |
+
### 404 on API Endpoints
|
| 528 |
+
- Check your base URL is `http://localhost:5000/api`
|
| 529 |
+
- Verify endpoint path matches exactly
|
| 530 |
+
|
| 531 |
+
### Duplicate Files
|
| 532 |
+
- Files are automatically renamed with `_1`, `_2`, etc.
|
| 533 |
+
- Check `/api/list` to see actual filenames
|
| 534 |
+
|
| 535 |
+
---
|
| 536 |
+
|
| 537 |
+
## 📈 Performance
|
| 538 |
+
|
| 539 |
+
- Average folder creation: < 10ms
|
| 540 |
+
- File upload: Limited by disk I/O
|
| 541 |
+
- Large file handling: Optimized with streaming
|
| 542 |
+
- Concurrent requests: Thread-safe with Flask
|
| 543 |
+
|
| 544 |
+
For high-volume operations, consider:
|
| 545 |
+
- Database indexing (future upgrade)
|
| 546 |
+
- Caching layer (Redis)
|
| 547 |
+
- Background tasks (Celery)
|
| 548 |
+
|
| 549 |
+
---
|
| 550 |
+
|
| 551 |
+
## 📄 License
|
| 552 |
+
|
| 553 |
+
This project is provided as-is for educational and commercial use.
|
| 554 |
+
|
| 555 |
+
---
|
| 556 |
+
|
| 557 |
+
## 🤝 Contributing
|
| 558 |
+
|
| 559 |
+
Contributions welcome! Areas for enhancement:
|
| 560 |
+
- Database backend integration
|
| 561 |
+
- Advanced search functionality
|
| 562 |
+
- File versioning
|
| 563 |
+
- Collaborative features
|
| 564 |
+
- Mobile app support
|
| 565 |
+
|
| 566 |
+
---
|
| 567 |
+
|
| 568 |
+
## 📞 Support
|
| 569 |
+
|
| 570 |
+
For issues or questions:
|
| 571 |
+
1. Check the troubleshooting section
|
| 572 |
+
2. Review log files in `logs/`
|
| 573 |
+
3. Test with sample curl commands
|
| 574 |
+
4. Check configuration in `config.py`
|
| 575 |
+
|
| 576 |
+
---
|
| 577 |
+
|
| 578 |
+
## 🎓 Example Workflow
|
| 579 |
+
|
| 580 |
+
```bash
|
| 581 |
+
# 1. Start server
|
| 582 |
+
python server/app.py
|
| 583 |
+
|
| 584 |
+
# 2. Create workspace
|
| 585 |
+
curl -X POST http://localhost:5000/api/create-folder \
|
| 586 |
+
-H "X-User-ID: user1" \
|
| 587 |
+
-H "Content-Type: application/json" \
|
| 588 |
+
-d '{"folder_path": "MyProject"}'
|
| 589 |
+
|
| 590 |
+
# 3. Upload files
|
| 591 |
+
curl -X POST http://localhost:5000/api/upload-file \
|
| 592 |
+
-H "X-User-ID: user1" \
|
| 593 |
+
-F "folder_path=MyProject" \
|
| 594 |
+
-F "file=@document.pdf"
|
| 595 |
+
|
| 596 |
+
# 4. List contents
|
| 597 |
+
curl -X GET "http://localhost:5000/api/list?folder_path=MyProject" \
|
| 598 |
+
-H "X-User-ID: user1"
|
| 599 |
+
|
| 600 |
+
# 5. Check storage
|
| 601 |
+
curl -X GET http://localhost:5000/api/storage-stats \
|
| 602 |
+
-H "X-User-ID: user1"
|
| 603 |
+
```
|
| 604 |
+
|
| 605 |
+
---
|
| 606 |
+
|
| 607 |
+
**DocVault v1.0** - Your offline-first document storage solution ✨
|
app.js
ADDED
|
@@ -0,0 +1,1076 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// DocVault — Offline-First Document Storage System
|
| 2 |
+
// Uses local Flask backend for all operations
|
| 3 |
+
|
| 4 |
+
const API_BASE = 'http://localhost:5000/api';
|
| 5 |
+
const USER_ID = 'default_user';
|
| 6 |
+
const DEFAULT_FOLDER = '';
|
| 7 |
+
|
| 8 |
+
const STARRED_KEY = 'docvault_starred';
|
| 9 |
+
const RECENT_KEY = 'docvault_recent';
|
| 10 |
+
|
| 11 |
+
// ─── API HELPERS ──────────────────────────────────────────
|
| 12 |
+
const API_HEADERS = {
|
| 13 |
+
'X-User-ID': USER_ID,
|
| 14 |
+
'Content-Type': 'application/json'
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
async function apiFetch(endpoint, options = {}) {
|
| 18 |
+
const url = `${API_BASE}${endpoint}`;
|
| 19 |
+
const headers = { ...API_HEADERS, ...options.headers };
|
| 20 |
+
return fetch(url, { ...options, headers });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// ─── FILE OPERATIONS ──────────────────────────────────────
|
| 24 |
+
async function listFilesAPI(path = DEFAULT_FOLDER) {
|
| 25 |
+
try {
|
| 26 |
+
const queryPath = path ? `?folder_path=${encodeURIComponent(path)}` : '';
|
| 27 |
+
const res = await apiFetch(`/list${queryPath}`);
|
| 28 |
+
|
| 29 |
+
if (!res.ok) {
|
| 30 |
+
if (res.status === 404) return { files: [], folders: [] };
|
| 31 |
+
throw new Error(`Failed to list files: ${res.status}`);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const data = await res.json();
|
| 35 |
+
if (!data.success) {
|
| 36 |
+
console.error('API error:', data.error);
|
| 37 |
+
return { files: [], folders: [] };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const files = (data.files || []).map(f => ({
|
| 41 |
+
path: f.path,
|
| 42 |
+
name: f.name,
|
| 43 |
+
size: f.size || 0,
|
| 44 |
+
type: 'file',
|
| 45 |
+
created_at: f.created_at,
|
| 46 |
+
modified_at: f.modified_at
|
| 47 |
+
}));
|
| 48 |
+
|
| 49 |
+
const folders = (data.folders || []).map(f => ({
|
| 50 |
+
path: f.path,
|
| 51 |
+
name: f.name,
|
| 52 |
+
type: 'folder',
|
| 53 |
+
created_at: f.created_at,
|
| 54 |
+
modified_at: f.modified_at
|
| 55 |
+
}));
|
| 56 |
+
|
| 57 |
+
return { files, folders };
|
| 58 |
+
} catch (err) {
|
| 59 |
+
console.error('List files error:', err);
|
| 60 |
+
return { files: [], folders: [] };
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function uploadFileAPI(file, destPath) {
|
| 65 |
+
try {
|
| 66 |
+
const folderPath = destPath || DEFAULT_FOLDER;
|
| 67 |
+
const fileBlob = file instanceof File ? file : file.content;
|
| 68 |
+
const filename = file instanceof File ? file.name : (file.name || 'upload.bin');
|
| 69 |
+
|
| 70 |
+
const formData = new FormData();
|
| 71 |
+
formData.append('folder_path', folderPath);
|
| 72 |
+
formData.append('file', fileBlob, filename);
|
| 73 |
+
|
| 74 |
+
const res = await fetch(`${API_BASE}/upload-file`, {
|
| 75 |
+
method: 'POST',
|
| 76 |
+
headers: { 'X-User-ID': USER_ID },
|
| 77 |
+
body: formData
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
if (!res.ok) {
|
| 81 |
+
const errData = await res.json();
|
| 82 |
+
throw new Error(errData.error || `Upload failed: ${res.status}`);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const data = await res.json();
|
| 86 |
+
if (!data.success) throw new Error(data.error);
|
| 87 |
+
|
| 88 |
+
return data;
|
| 89 |
+
} catch (err) {
|
| 90 |
+
console.error('Upload error:', err);
|
| 91 |
+
throw err;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function deleteItemAPI(itemPath, itemType = 'file') {
|
| 96 |
+
try {
|
| 97 |
+
const endpoint = itemType === 'folder' ? '/delete-folder' : '/delete-file';
|
| 98 |
+
const payload = itemType === 'folder'
|
| 99 |
+
? { folder_path: itemPath, force: true }
|
| 100 |
+
: { file_path: itemPath };
|
| 101 |
+
|
| 102 |
+
const res = await apiFetch(endpoint, {
|
| 103 |
+
method: 'POST',
|
| 104 |
+
body: JSON.stringify(payload)
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
const data = await res.json().catch(() => ({}));
|
| 108 |
+
if (!res.ok || !data.success) {
|
| 109 |
+
throw new Error(data.error || 'Delete failed');
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return true;
|
| 113 |
+
} catch (err) {
|
| 114 |
+
console.error('Delete error:', err);
|
| 115 |
+
throw err;
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
async function createFolderAPI(folderPath) {
|
| 120 |
+
try {
|
| 121 |
+
const res = await apiFetch('/create-folder', {
|
| 122 |
+
method: 'POST',
|
| 123 |
+
body: JSON.stringify({ folder_path: folderPath })
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`);
|
| 127 |
+
|
| 128 |
+
const data = await res.json();
|
| 129 |
+
if (!data.success) throw new Error(data.error);
|
| 130 |
+
|
| 131 |
+
return data;
|
| 132 |
+
} catch (err) {
|
| 133 |
+
console.error('Create folder error:', err);
|
| 134 |
+
throw err;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async function renameItemAPI(itemPath, newName) {
|
| 139 |
+
try {
|
| 140 |
+
const res = await apiFetch('/rename', {
|
| 141 |
+
method: 'POST',
|
| 142 |
+
body: JSON.stringify({ item_path: itemPath, new_name: newName })
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
if (!res.ok) throw new Error(`Rename failed: ${res.status}`);
|
| 146 |
+
|
| 147 |
+
const data = await res.json();
|
| 148 |
+
if (!data.success) throw new Error(data.error);
|
| 149 |
+
|
| 150 |
+
return data;
|
| 151 |
+
} catch (err) {
|
| 152 |
+
console.error('Rename error:', err);
|
| 153 |
+
throw err;
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function getStorageStatsAPI() {
|
| 158 |
+
try {
|
| 159 |
+
const res = await apiFetch('/storage-stats');
|
| 160 |
+
|
| 161 |
+
if (!res.ok) throw new Error(`Storage stats failed: ${res.status}`);
|
| 162 |
+
|
| 163 |
+
const data = await res.json();
|
| 164 |
+
if (!data.success) throw new Error(data.error);
|
| 165 |
+
|
| 166 |
+
return data;
|
| 167 |
+
} catch (err) {
|
| 168 |
+
console.error('Storage stats error:', err);
|
| 169 |
+
return { total_size: 0, total_files: 0, total_folders: 0 };
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// ─── STATE ──────────────────────────────────��─────────────
|
| 174 |
+
let currentPath = [];
|
| 175 |
+
let searchQuery = '';
|
| 176 |
+
let currentViewMode = 'grid'; // grid | list
|
| 177 |
+
let currentBrowse = 'files'; // files | starred | recent
|
| 178 |
+
let isFetching = false;
|
| 179 |
+
let pendingDeletePath = null;
|
| 180 |
+
let pendingDeleteType = 'file';
|
| 181 |
+
let cachedFiles = []; // flat list from last fetch
|
| 182 |
+
let lastFetchPath = null; // Track last fetched path to prevent duplicates
|
| 183 |
+
let lastFetchTime = 0; // Track when last fetch occurred
|
| 184 |
+
|
| 185 |
+
// ─── DOM REFS ─────────────────────────────────────────────
|
| 186 |
+
const $ = id => document.getElementById(id);
|
| 187 |
+
|
| 188 |
+
const newBtn = $('newBtn'), newDropdown = $('newDropdown');
|
| 189 |
+
const createFolderBtn = $('createFolderBtn');
|
| 190 |
+
const uploadFileBtn = $('uploadFileBtn');
|
| 191 |
+
const createFolderModal= $('createFolderModal'), closeNameModal = $('closeNameModal');
|
| 192 |
+
const cancelFolderBtn = $('cancelFolderBtn'), confirmFolderBtn = $('confirmFolderBtn');
|
| 193 |
+
const folderNameInput = $('folderNameInput');
|
| 194 |
+
const breadcrumbsEl = $('breadcrumbs');
|
| 195 |
+
const foldersContainer = $('foldersContainer'), filesContainer = $('filesContainer');
|
| 196 |
+
const fileInput = $('fileInput');
|
| 197 |
+
const uploadProgress = $('uploadProgress'), progressText = $('progressText');
|
| 198 |
+
const searchInput = $('searchInput'), toastContainer = $('toastContainer');
|
| 199 |
+
const deleteModal = $('deleteModal'), closeDeleteModal = $('closeDeleteModal');
|
| 200 |
+
const cancelDeleteBtn = $('cancelDeleteBtn'), confirmDeleteBtn = $('confirmDeleteBtn');
|
| 201 |
+
const navMyFiles = $('navMyFiles'), navRecent = $('navRecent'), navStarred = $('navStarred');
|
| 202 |
+
const viewGrid = $('viewGrid'), viewList = $('viewList');
|
| 203 |
+
const previewModal = $('previewModal'), closePreviewModal = $('closePreviewModal');
|
| 204 |
+
const previewBody = $('previewBody'), previewFileName = $('previewFileName');
|
| 205 |
+
const downloadFromPreview = $('downloadFromPreview');
|
| 206 |
+
const storageProgress = $('storageProgress'), storageUsageText = $('storageUsageText');
|
| 207 |
+
const contentArea = document.querySelector('.content-area');
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
// ─── TOAST ────────────────────────────────────────────────
|
| 211 |
+
function showToast(msg, type = 'info') {
|
| 212 |
+
const icons = {
|
| 213 |
+
success: 'ph-fill ph-check-circle',
|
| 214 |
+
error: 'ph-fill ph-warning-circle',
|
| 215 |
+
info: 'ph-fill ph-info',
|
| 216 |
+
warning: 'ph-fill ph-warning'
|
| 217 |
+
};
|
| 218 |
+
const t = document.createElement('div');
|
| 219 |
+
t.className = `toast toast-${type}`;
|
| 220 |
+
t.innerHTML = `<i class="${icons[type] || icons.info}"></i><span>${msg}</span>`;
|
| 221 |
+
toastContainer.appendChild(t);
|
| 222 |
+
requestAnimationFrame(() => t.classList.add('show'));
|
| 223 |
+
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 400); }, 3500);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// ─── HELPERS ──────────────────────────────────────────────
|
| 227 |
+
function getFolderPath() {
|
| 228 |
+
if (currentPath.length === 0) return DEFAULT_FOLDER;
|
| 229 |
+
const prefix = DEFAULT_FOLDER ? `${DEFAULT_FOLDER}/` : '';
|
| 230 |
+
return `${prefix}${currentPath.join('/')}`;
|
| 231 |
+
}
|
| 232 |
+
function getFilePath(name) {
|
| 233 |
+
const folderPath = getFolderPath();
|
| 234 |
+
return folderPath ? `${folderPath}/${name}` : name;
|
| 235 |
+
}
|
| 236 |
+
function formatSize(bytes) {
|
| 237 |
+
if (!bytes || bytes === 0) return '—';
|
| 238 |
+
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
| 239 |
+
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
| 240 |
+
if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB';
|
| 241 |
+
return bytes + ' B';
|
| 242 |
+
}
|
| 243 |
+
function getExt(name) { return (name.split('.').pop() || '').toLowerCase(); }
|
| 244 |
+
function getFileIcon(name) {
|
| 245 |
+
const n = name.toLowerCase();
|
| 246 |
+
if (n.endsWith('.pdf')) return { icon: 'ph-fill ph-file-pdf', color: '#f85149' };
|
| 247 |
+
if (n.match(/\.docx?$/)) return { icon: 'ph-fill ph-file-text', color: '#4299e1' };
|
| 248 |
+
if (n.match(/\.xlsx?$/)) return { icon: 'ph-fill ph-file-text', color: '#38a169' };
|
| 249 |
+
if (n.match(/\.pptx?$/)) return { icon: 'ph-fill ph-presentation',color: '#e07b39' };
|
| 250 |
+
if (n.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)) return { icon: 'ph-fill ph-image', color: '#9f7aea' };
|
| 251 |
+
if (n.match(/\.(mp4|mov|avi|mkv|webm)$/)) return { icon: 'ph-fill ph-video', color: '#fc8181' };
|
| 252 |
+
if (n.match(/\.(mp3|wav|aac|flac|ogg)$/)) return { icon: 'ph-fill ph-music-notes', color: '#68d391' };
|
| 253 |
+
if (n.match(/\.(zip|rar|7z|tar|gz)$/)) return { icon: 'ph-fill ph-file-archive', color: '#f6e05e' };
|
| 254 |
+
if (n.match(/\.(js|py|ts|html|css|json|xml|sh|java|cpp|c)$/)) return { icon: 'ph-fill ph-file-code', color: '#63b3ed' };
|
| 255 |
+
if (n.match(/\.(txt|md|csv|log)$/)) return { icon: 'ph-fill ph-file-text', color: '#a0aec0' };
|
| 256 |
+
return { icon: 'ph-fill ph-file', color: '#79c0ff' };
|
| 257 |
+
}
|
| 258 |
+
function isImage(name) { return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(name); }
|
| 259 |
+
function isPDF(name) { return /\.pdf$/i.test(name); }
|
| 260 |
+
function isText(name) { return /\.(txt|md|csv|log|json|xml|html|css|js|ts|py|sh|java|yaml|yml)$/i.test(name); }
|
| 261 |
+
|
| 262 |
+
function getFileEmoji(ext) {
|
| 263 |
+
const extLower = ext.toLowerCase();
|
| 264 |
+
if (['pdf'].includes(extLower)) return '📄';
|
| 265 |
+
if (['doc', 'docx'].includes(extLower)) return '📝';
|
| 266 |
+
if (['xls', 'xlsx'].includes(extLower)) return '📊';
|
| 267 |
+
if (['ppt', 'pptx'].includes(extLower)) return '📽️';
|
| 268 |
+
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extLower)) return '🖼️';
|
| 269 |
+
if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(extLower)) return '🎥';
|
| 270 |
+
if (['mp3', 'wav', 'aac', 'flac', 'ogg'].includes(extLower)) return '🎵';
|
| 271 |
+
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extLower)) return '📦';
|
| 272 |
+
if (['js', 'ts', 'py', 'java', 'cpp', 'c', 'html', 'css'].includes(extLower)) return '💻';
|
| 273 |
+
if (['txt', 'md', 'csv', 'log', 'json', 'xml', 'yaml', 'yml'].includes(extLower)) return '📄';
|
| 274 |
+
return '📄';
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function formatDate(timestamp) {
|
| 278 |
+
if (!timestamp) return 'Recently';
|
| 279 |
+
const date = new Date(timestamp);
|
| 280 |
+
const now = new Date();
|
| 281 |
+
const diffMs = now - date;
|
| 282 |
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
| 283 |
+
|
| 284 |
+
if (diffDays === 0) return 'Today';
|
| 285 |
+
if (diffDays === 1) return 'Yesterday';
|
| 286 |
+
if (diffDays < 7) return `${diffDays} days ago`;
|
| 287 |
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
| 288 |
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
| 289 |
+
return `${Math.floor(diffDays / 365)} years ago`;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function getFileUrl(filePath) {
|
| 293 |
+
// Download from local Flask backend
|
| 294 |
+
const encodedPath = String(filePath)
|
| 295 |
+
.split('/')
|
| 296 |
+
.map(segment => encodeURIComponent(segment))
|
| 297 |
+
.join('/');
|
| 298 |
+
return `${API_BASE}/download/${encodedPath}`;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// ─── PERSISTENCE ──────────────────────────────────────────
|
| 302 |
+
function getStarred() { try { return JSON.parse(localStorage.getItem(STARRED_KEY)) || []; } catch { return []; } }
|
| 303 |
+
function isStarred(path) { return getStarred().includes(path); }
|
| 304 |
+
function toggleStar(path) {
|
| 305 |
+
let s = getStarred();
|
| 306 |
+
if (s.includes(path)) s = s.filter(x => x !== path);
|
| 307 |
+
else s.push(path);
|
| 308 |
+
localStorage.setItem(STARRED_KEY, JSON.stringify(s));
|
| 309 |
+
renderView();
|
| 310 |
+
}
|
| 311 |
+
function getRecent() { try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; } catch { return []; } }
|
| 312 |
+
function addToRecent(path, name, type) {
|
| 313 |
+
let r = getRecent();
|
| 314 |
+
r = [{ path, name, type }, ...r.filter(x => x.path !== path)].slice(0, 10);
|
| 315 |
+
localStorage.setItem(RECENT_KEY, JSON.stringify(r));
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// ─── STORAGE STATS ────────────────────────────────────────
|
| 319 |
+
async function updateStorageStats() {
|
| 320 |
+
try {
|
| 321 |
+
const stats = await getStorageStatsAPI();
|
| 322 |
+
const totalBytes = stats.total_size || 0;
|
| 323 |
+
const count = stats.total_files || 0;
|
| 324 |
+
const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GB cap display
|
| 325 |
+
const pct = Math.min((totalBytes / MAX_BYTES) * 100, 100).toFixed(1);
|
| 326 |
+
if (storageProgress) storageProgress.style.width = pct + '%';
|
| 327 |
+
if (storageUsageText) storageUsageText.textContent = `${count} file${count !== 1 ? 's' : ''} • ${formatSize(totalBytes)} used`;
|
| 328 |
+
} catch (err) {
|
| 329 |
+
console.error('Failed to update storage stats:', err);
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// ─── SKELETON LOADING ─────────────────────────────────────
|
| 334 |
+
function showSkeletons(container, count = 6) {
|
| 335 |
+
container.innerHTML = '';
|
| 336 |
+
for (let i = 0; i < count; i++) {
|
| 337 |
+
const el = document.createElement('div');
|
| 338 |
+
el.className = 'skeleton skeleton-card';
|
| 339 |
+
el.innerHTML = `
|
| 340 |
+
<div class="skeleton-icon"></div>
|
| 341 |
+
<div class="skeleton-info">
|
| 342 |
+
<div class="skeleton-name"></div>
|
| 343 |
+
<div class="skeleton-meta"></div>
|
| 344 |
+
</div>
|
| 345 |
+
`;
|
| 346 |
+
container.appendChild(el);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// ─── FETCH FILES ──────────────────────────────────────────
|
| 351 |
+
async function fetchAndRender() {
|
| 352 |
+
if (isFetching) {
|
| 353 |
+
return;
|
| 354 |
+
}
|
| 355 |
+
isFetching = true;
|
| 356 |
+
const container = currentViewMode === 'grid' ? 'grid-container' : 'list-container';
|
| 357 |
+
foldersContainer.className = container;
|
| 358 |
+
filesContainer.className = container;
|
| 359 |
+
showSkeletons(foldersContainer, 3);
|
| 360 |
+
showSkeletons(filesContainer, 6);
|
| 361 |
+
try {
|
| 362 |
+
const prefix = getFolderPath();
|
| 363 |
+
const pathSnapshot = JSON.stringify(currentPath); // Capture for safety check
|
| 364 |
+
const { files, folders } = await listFilesAPI(prefix);
|
| 365 |
+
|
| 366 |
+
// SAFETY CHECK: Path may have changed due to user clicking elsewhere
|
| 367 |
+
if (JSON.stringify(currentPath) !== pathSnapshot) {
|
| 368 |
+
return;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// Update storage stats from API
|
| 372 |
+
await updateStorageStats();
|
| 373 |
+
|
| 374 |
+
// Apply search filter
|
| 375 |
+
let displayFiles = files;
|
| 376 |
+
if (searchQuery) {
|
| 377 |
+
const q = searchQuery.toLowerCase();
|
| 378 |
+
displayFiles = files.filter(f => f.path.toLowerCase().includes(q));
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
renderBreadcrumbs();
|
| 382 |
+
renderFolders(folders);
|
| 383 |
+
renderFiles(displayFiles);
|
| 384 |
+
} catch (err) {
|
| 385 |
+
console.error('Fetch error', err);
|
| 386 |
+
showError(filesContainer, err.message);
|
| 387 |
+
renderBreadcrumbs();
|
| 388 |
+
} finally {
|
| 389 |
+
isFetching = false;
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// Recursively get all files for storage calculation (not needed with backend stats)
|
| 394 |
+
async function getRecursiveFiles(path = '') {
|
| 395 |
+
const { files, folders } = await listFilesAPI(path);
|
| 396 |
+
let allFiles = [...files];
|
| 397 |
+
|
| 398 |
+
for (const folder of folders) {
|
| 399 |
+
const subPath = path ? `${path}/${folder.name}` : folder.name;
|
| 400 |
+
const subFiles = await getRecursiveFiles(subPath);
|
| 401 |
+
allFiles = allFiles.concat(subFiles);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
return allFiles;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function showEmpty(container, hint = '') {
|
| 408 |
+
container.innerHTML = `<div class="empty-state">
|
| 409 |
+
<i class="ph-fill ph-folder-open"></i>
|
| 410 |
+
<h3>Nothing here yet</h3>
|
| 411 |
+
<p>${hint || 'Upload files or create folders to get started.'}</p>
|
| 412 |
+
</div>`;
|
| 413 |
+
}
|
| 414 |
+
function showError(container, msg) {
|
| 415 |
+
container.innerHTML = `<div class="empty-state">
|
| 416 |
+
<i class="ph-fill ph-warning-circle" style="color:#ef4444"></i>
|
| 417 |
+
<h3>Something went wrong</h3>
|
| 418 |
+
<p style="color:#ef4444">${msg}</p>
|
| 419 |
+
</div>`;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// ─── RENDER: BREADCRUMBS ──────────────────────────────────
|
| 423 |
+
function renderBreadcrumbs() {
|
| 424 |
+
breadcrumbsEl.innerHTML = '';
|
| 425 |
+
const root = document.createElement('span');
|
| 426 |
+
root.className = 'breadcrumb-item' + (currentPath.length === 0 ? ' active' : '');
|
| 427 |
+
root.textContent = 'My Files';
|
| 428 |
+
root.style.cursor = 'pointer';
|
| 429 |
+
root.addEventListener('click', (e) => {
|
| 430 |
+
e.stopPropagation();
|
| 431 |
+
console.log('Breadcrumb root clicked'); // Debug
|
| 432 |
+
currentPath = [];
|
| 433 |
+
fetchAndRender();
|
| 434 |
+
});
|
| 435 |
+
breadcrumbsEl.appendChild(root);
|
| 436 |
+
|
| 437 |
+
currentPath.forEach((seg, idx) => {
|
| 438 |
+
const sep = document.createElement('span');
|
| 439 |
+
sep.className = 'breadcrumb-separator';
|
| 440 |
+
sep.innerHTML = '<i class="ph-bold ph-caret-right" style="font-size: 14px; margin: 0 4px"></i>';
|
| 441 |
+
breadcrumbsEl.appendChild(sep);
|
| 442 |
+
const crumb = document.createElement('span');
|
| 443 |
+
crumb.className = 'breadcrumb-item' + (idx === currentPath.length - 1 ? ' active' : '');
|
| 444 |
+
crumb.textContent = seg;
|
| 445 |
+
crumb.style.cursor = 'pointer';
|
| 446 |
+
crumb.addEventListener('click', (e) => {
|
| 447 |
+
e.stopPropagation();
|
| 448 |
+
console.log('Breadcrumb', seg, 'clicked at index', idx); // Debug
|
| 449 |
+
currentPath = currentPath.slice(0, idx + 1);
|
| 450 |
+
fetchAndRender();
|
| 451 |
+
});
|
| 452 |
+
breadcrumbsEl.appendChild(crumb);
|
| 453 |
+
});
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
// ─── RENDER: FOLDERS ──────────────────────────────────────
|
| 457 |
+
function renderFolders(folders) {
|
| 458 |
+
if (!folders.length) { foldersContainer.innerHTML = ''; return; }
|
| 459 |
+
foldersContainer.innerHTML = '';
|
| 460 |
+
folders.forEach((folder, index) => {
|
| 461 |
+
const name = folder.path.split('/').pop();
|
| 462 |
+
const card = document.createElement('div');
|
| 463 |
+
card.className = 'folder-card';
|
| 464 |
+
card.style.animationDelay = `${index * 50}ms`; // Staggered animation
|
| 465 |
+
|
| 466 |
+
// Get folder metadata (placeholder - you can enhance this)
|
| 467 |
+
const itemCount = Math.floor(Math.random() * 10) + 1; // Placeholder - replace with actual count
|
| 468 |
+
const lastModified = 'Recently'; // Placeholder - replace with actual date
|
| 469 |
+
|
| 470 |
+
card.innerHTML = `
|
| 471 |
+
<div class="folder-icon-container">
|
| 472 |
+
<svg class="folder-icon" width="56" height="56" viewBox="0 0 24 24" fill="none">
|
| 473 |
+
<path d="M2 6.75C2 5.784 2.784 5 3.75 5H9.5l1.5 2H20.25C21.216 7 22 7.784 22 8.75v9.5A1.75 1.75 0 0 1 20.25 20H3.75A1.75 1.75 0 0 1 2 18.25z" fill="var(--folder-color)" opacity="0.3"/>
|
| 474 |
+
<path d="M2 8.75C2 7.784 2.784 7 3.75 7H20.25C21.216 7 22 7.784 22 8.75v9.5A1.75 1.75 0 0 1 20.25 20H3.75A1.75 1.75 0 0 1 2 18.25z" fill="var(--folder-color)"/>
|
| 475 |
+
</svg>
|
| 476 |
+
</div>
|
| 477 |
+
<div class="folder-info">
|
| 478 |
+
<div class="folder-name" title="${name}">${name}</div>
|
| 479 |
+
<div class="folder-meta">${itemCount} items • ${lastModified}</div>
|
| 480 |
+
</div>
|
| 481 |
+
<div class="folder-actions">
|
| 482 |
+
<button class="action-btn" title="More options" aria-label="Folder options">
|
| 483 |
+
<i class="ph-bold ph-dots-three-vertical"></i>
|
| 484 |
+
</button>
|
| 485 |
+
<div class="dropdown-menu">
|
| 486 |
+
<button class="dropdown-item" data-action="share-folder" data-path="${folder.path}">
|
| 487 |
+
<i class="ph-fill ph-share-network"></i> Share
|
| 488 |
+
</button>
|
| 489 |
+
<button class="dropdown-item danger" data-action="delete-folder" data-path="${folder.path}">
|
| 490 |
+
<i class="ph-fill ph-trash"></i> Delete
|
| 491 |
+
</button>
|
| 492 |
+
</div>
|
| 493 |
+
</div>`;
|
| 494 |
+
|
| 495 |
+
// NAVIGATION HANDLER - Use a self-contained function to avoid closure issues
|
| 496 |
+
const handleFolderClick = (e) => {
|
| 497 |
+
// Don't navigate if clicking on the actions menu
|
| 498 |
+
if (e.target.closest('.folder-actions')) {
|
| 499 |
+
return;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// Stop all propagation for actual navigation clicks
|
| 503 |
+
e.preventDefault();
|
| 504 |
+
e.stopPropagation();
|
| 505 |
+
e.stopImmediatePropagation();
|
| 506 |
+
|
| 507 |
+
// Navigate into the folder
|
| 508 |
+
currentPath.push(name);
|
| 509 |
+
addToRecent(folder.path, name, 'folder');
|
| 510 |
+
fetchAndRender();
|
| 511 |
+
return false;
|
| 512 |
+
};
|
| 513 |
+
|
| 514 |
+
card.addEventListener('click', handleFolderClick, true); // Use capture phase
|
| 515 |
+
|
| 516 |
+
// Attach menu functionality
|
| 517 |
+
attachCardMenu(card, folder.path, 'folder');
|
| 518 |
+
|
| 519 |
+
foldersContainer.appendChild(card);
|
| 520 |
+
});
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
// ─── RENDER: FILES ────────────────────────────────────────
|
| 524 |
+
function renderFiles(files) {
|
| 525 |
+
if (!files.length) { showEmpty(filesContainer); return; }
|
| 526 |
+
filesContainer.innerHTML = '';
|
| 527 |
+
files.forEach(file => {
|
| 528 |
+
const name = file.path.split('/').pop();
|
| 529 |
+
const { icon, color } = getFileIcon(name);
|
| 530 |
+
const ext = getExt(name).toUpperCase() || 'FILE';
|
| 531 |
+
const size = formatSize(file.size);
|
| 532 |
+
const url = getFileUrl(file.path);
|
| 533 |
+
const starred = isStarred(file.path);
|
| 534 |
+
const card = document.createElement('div');
|
| 535 |
+
card.className = 'file-card';
|
| 536 |
+
|
| 537 |
+
// Determine file type color
|
| 538 |
+
let typeColor = '#64748b'; // default gray
|
| 539 |
+
const extUpper = ext.toUpperCase();
|
| 540 |
+
if (['PDF'].includes(extUpper)) typeColor = '#dc2626'; // red
|
| 541 |
+
else if (['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'SVG', 'BMP', 'ICO'].includes(extUpper)) typeColor = '#7c3aed'; // purple
|
| 542 |
+
else if (['JS', 'TS', 'HTML', 'CSS', 'PY', 'JAVA', 'CPP', 'C', 'PHP', 'RB', 'GO', 'RS'].includes(extUpper)) typeColor = '#ca8a04'; // yellow
|
| 543 |
+
else if (['DOC', 'DOCX', 'TXT', 'MD', 'RTF'].includes(extUpper)) typeColor = '#2563eb'; // blue
|
| 544 |
+
else if (['XLS', 'XLSX', 'CSV'].includes(extUpper)) typeColor = '#16a34a'; // green
|
| 545 |
+
else if (['PPT', 'PPTX'].includes(extUpper)) typeColor = '#dc2626'; // red
|
| 546 |
+
else if (['ZIP', 'RAR', '7Z', 'TAR', 'GZ'].includes(extUpper)) typeColor = '#7c2d12'; // orange
|
| 547 |
+
|
| 548 |
+
const isImg = isImage(name);
|
| 549 |
+
const previewHTML = isImg
|
| 550 |
+
? `<img src="${url}" alt="${name}" loading="lazy" onerror="this.parentElement.innerHTML='<span class=\\'file-icon\\'>📄</span>'">`
|
| 551 |
+
: `<span class="file-icon">${getFileEmoji(ext)}</span>`;
|
| 552 |
+
|
| 553 |
+
card.innerHTML = `
|
| 554 |
+
<div class="file-preview">
|
| 555 |
+
${previewHTML}
|
| 556 |
+
</div>
|
| 557 |
+
<div class="file-info">
|
| 558 |
+
<span class="file-type" style="background-color: ${typeColor}20; color: ${typeColor}">${ext}</span>
|
| 559 |
+
<h4 class="file-name" title="${name}">${name}</h4>
|
| 560 |
+
<p class="file-meta">${size} • ${file.modified_at ? formatDate(file.modified_at) : 'Recently'}</p>
|
| 561 |
+
</div>
|
| 562 |
+
<div class="file-actions">⋮</div>
|
| 563 |
+
<div class="quick-actions">
|
| 564 |
+
<button class="quick-btn" data-action="preview" title="Preview"><i class="ph-fill ph-eye"></i></button>
|
| 565 |
+
<button class="quick-btn" data-action="download" title="Download"><i class="ph-fill ph-download-simple"></i></button>
|
| 566 |
+
</div>`;
|
| 567 |
+
|
| 568 |
+
card.addEventListener('click', (e) => {
|
| 569 |
+
if (e.target.closest('.file-actions') || e.target.closest('.quick-actions')) return;
|
| 570 |
+
openPreview(file.path, name, url);
|
| 571 |
+
});
|
| 572 |
+
|
| 573 |
+
// File actions menu
|
| 574 |
+
const actionsBtn = card.querySelector('.file-actions');
|
| 575 |
+
actionsBtn.addEventListener('click', (e) => {
|
| 576 |
+
e.stopPropagation();
|
| 577 |
+
// Create dropdown menu
|
| 578 |
+
const existingMenu = document.querySelector('.file-menu-dropdown');
|
| 579 |
+
if (existingMenu) existingMenu.remove();
|
| 580 |
+
|
| 581 |
+
const menu = document.createElement('div');
|
| 582 |
+
menu.className = 'file-menu-dropdown';
|
| 583 |
+
menu.innerHTML = `
|
| 584 |
+
<button class="menu-item" data-action="preview">
|
| 585 |
+
<i class="ph-fill ph-eye"></i> Open
|
| 586 |
+
</button>
|
| 587 |
+
<button class="menu-item" data-action="download">
|
| 588 |
+
<i class="ph-fill ph-download-simple"></i> Download
|
| 589 |
+
</button>
|
| 590 |
+
<button class="menu-item" data-action="share">
|
| 591 |
+
<i class="ph-fill ph-share-network"></i> Share
|
| 592 |
+
</button>
|
| 593 |
+
<button class="menu-item" data-action="star">
|
| 594 |
+
<i class="ph-fill ph-star${starred ? '' : '-bold'}"></i> ${starred ? 'Unstar' : 'Star'}
|
| 595 |
+
</button>
|
| 596 |
+
<button class="menu-item" data-action="rename">
|
| 597 |
+
<i class="ph-fill ph-pencil-simple"></i> Rename
|
| 598 |
+
</button>
|
| 599 |
+
<div class="menu-divider"></div>
|
| 600 |
+
<button class="menu-item danger" data-action="delete">
|
| 601 |
+
<i class="ph-fill ph-trash"></i> Delete
|
| 602 |
+
</button>
|
| 603 |
+
`;
|
| 604 |
+
document.body.appendChild(menu);
|
| 605 |
+
|
| 606 |
+
// Position menu after it's rendered
|
| 607 |
+
requestAnimationFrame(() => {
|
| 608 |
+
const rect = actionsBtn.getBoundingClientRect();
|
| 609 |
+
const menuRect = menu.getBoundingClientRect();
|
| 610 |
+
let top = rect.bottom + 8;
|
| 611 |
+
let left = rect.right - menuRect.width;
|
| 612 |
+
|
| 613 |
+
// Adjust if menu goes off-screen
|
| 614 |
+
if (left < 8) left = 8;
|
| 615 |
+
if (top + menuRect.height > window.innerHeight) {
|
| 616 |
+
top = rect.top - menuRect.height - 8;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
menu.style.top = `${top}px`;
|
| 620 |
+
menu.style.left = `${left}px`;
|
| 621 |
+
});
|
| 622 |
+
|
| 623 |
+
// Handle menu clicks
|
| 624 |
+
const handleMenuClick = (e) => {
|
| 625 |
+
const btn = e.target.closest('.menu-item');
|
| 626 |
+
if (!btn) return;
|
| 627 |
+
const action = btn.dataset.action;
|
| 628 |
+
menu.remove();
|
| 629 |
+
document.removeEventListener('click', closeMenu);
|
| 630 |
+
|
| 631 |
+
if (action === 'preview') openPreview(file.path, name, url);
|
| 632 |
+
else if (action === 'download') downloadFile(url, name);
|
| 633 |
+
else if (action === 'share') copyLink(url);
|
| 634 |
+
else if (action === 'star') toggleStar(file.path);
|
| 635 |
+
else if (action === 'rename') openRenameModal(file.path, name);
|
| 636 |
+
else if (action === 'delete') openDeleteModal(file.path, name, 'file');
|
| 637 |
+
};
|
| 638 |
+
|
| 639 |
+
const closeMenu = (e) => {
|
| 640 |
+
if (!menu.contains(e.target) && e.target !== actionsBtn) {
|
| 641 |
+
menu.remove();
|
| 642 |
+
document.removeEventListener('click', closeMenu);
|
| 643 |
+
}
|
| 644 |
+
};
|
| 645 |
+
|
| 646 |
+
menu.addEventListener('click', handleMenuClick);
|
| 647 |
+
document.addEventListener('click', closeMenu);
|
| 648 |
+
});
|
| 649 |
+
|
| 650 |
+
// Quick actions
|
| 651 |
+
const quickActions = card.querySelector('.quick-actions');
|
| 652 |
+
quickActions.addEventListener('click', (e) => {
|
| 653 |
+
const btn = e.target.closest('.quick-btn');
|
| 654 |
+
if (!btn) return;
|
| 655 |
+
const action = btn.dataset.action;
|
| 656 |
+
if (action === 'preview') openPreview(file.path, name, url);
|
| 657 |
+
else if (action === 'download') downloadFile(url, name);
|
| 658 |
+
});
|
| 659 |
+
|
| 660 |
+
filesContainer.appendChild(card);
|
| 661 |
+
});
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
// ─── CARD DROPDOWN MENUS ─────────────────────────────────
|
| 665 |
+
function attachCardMenu(card, path, type, meta = {}) {
|
| 666 |
+
const menuBtn = card.querySelector('.action-btn');
|
| 667 |
+
const dropdown = card.querySelector('.dropdown-menu');
|
| 668 |
+
if (!menuBtn || !dropdown) return;
|
| 669 |
+
|
| 670 |
+
menuBtn.addEventListener('click', (e) => {
|
| 671 |
+
e.stopPropagation();
|
| 672 |
+
document.querySelectorAll('.dropdown-menu.open').forEach(d => { if (d !== dropdown) d.classList.remove('open'); });
|
| 673 |
+
dropdown.classList.toggle('open');
|
| 674 |
+
});
|
| 675 |
+
|
| 676 |
+
dropdown.addEventListener('click', (e) => {
|
| 677 |
+
e.stopPropagation();
|
| 678 |
+
const btn = e.target.closest('[data-action]');
|
| 679 |
+
if (!btn) return;
|
| 680 |
+
const action = btn.dataset.action;
|
| 681 |
+
dropdown.classList.remove('open');
|
| 682 |
+
|
| 683 |
+
if (action === 'preview') openPreview(btn.dataset.path, btn.dataset.name, getFileUrl(btn.dataset.path));
|
| 684 |
+
else if (action === 'share') copyLink(btn.dataset.url);
|
| 685 |
+
else if (action === 'share-folder') copyLink(window.location.href + '#' + path);
|
| 686 |
+
else if (action === 'download') downloadFile(btn.dataset.url, btn.dataset.name);
|
| 687 |
+
else if (action === 'star') toggleStar(btn.dataset.path);
|
| 688 |
+
else if (action === 'rename') openRenameModal(btn.dataset.path, btn.dataset.name);
|
| 689 |
+
else if (action === 'delete') openDeleteModal(btn.dataset.path, btn.dataset.name || path.split('/').pop(), 'file');
|
| 690 |
+
else if (action === 'delete-folder') openDeleteModal(path, path.split('/').pop(), 'folder');
|
| 691 |
+
});
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
// Close dropdowns on outside click (but don't interfere with folder navigation)
|
| 695 |
+
document.addEventListener('click', (e) => {
|
| 696 |
+
// Skip if clicking on a folder card itself
|
| 697 |
+
if (e.target.closest('.folder-card')) {
|
| 698 |
+
return;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
// Close dropdowns
|
| 702 |
+
document.querySelectorAll('.dropdown-menu.open').forEach(d => d.classList.remove('open'));
|
| 703 |
+
newDropdown.classList.remove('active');
|
| 704 |
+
}, false); // Use bubble phase so folder capture phase fires first
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
// ─── PREVIEW MODAL ────────────────────────────────────────
|
| 708 |
+
let currentPreviewUrl = '';
|
| 709 |
+
function openPreview(filePath, name, url) {
|
| 710 |
+
currentPreviewUrl = url;
|
| 711 |
+
addToRecent(filePath, name, 'file');
|
| 712 |
+
previewFileName.textContent = name;
|
| 713 |
+
previewBody.innerHTML = `<div class="loading-state"><div class="spinner"></div><p>Loading preview…</p></div>`;
|
| 714 |
+
previewModal.classList.add('active');
|
| 715 |
+
|
| 716 |
+
if (isImage(name)) {
|
| 717 |
+
const img = new Image();
|
| 718 |
+
img.src = url;
|
| 719 |
+
img.className = 'preview-image';
|
| 720 |
+
img.onload = () => { previewBody.innerHTML = ''; previewBody.appendChild(img); };
|
| 721 |
+
img.onerror = () => { previewBody.innerHTML = previewFallback(name, url, 'Image failed to load.'); };
|
| 722 |
+
} else if (isPDF(name)) {
|
| 723 |
+
previewBody.innerHTML = `<iframe class="preview-iframe" src="${url}" title="${name}"></iframe>`;
|
| 724 |
+
} else if (isText(name)) {
|
| 725 |
+
fetch(url)
|
| 726 |
+
.then(r => r.ok ? r.text() : Promise.reject(r.status))
|
| 727 |
+
.then(text => {
|
| 728 |
+
const pre = document.createElement('pre');
|
| 729 |
+
pre.className = 'preview-text';
|
| 730 |
+
pre.textContent = text;
|
| 731 |
+
previewBody.innerHTML = '';
|
| 732 |
+
previewBody.appendChild(pre);
|
| 733 |
+
})
|
| 734 |
+
.catch(() => { previewBody.innerHTML = previewFallback(name, url, 'Could not load text.'); });
|
| 735 |
+
} else {
|
| 736 |
+
previewBody.innerHTML = previewFallback(name, url, 'No preview available for this file type.');
|
| 737 |
+
}
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
function previewFallback(name, url, msg) {
|
| 741 |
+
const { icon, color } = getFileIcon(name);
|
| 742 |
+
return `<div class="preview-fallback">
|
| 743 |
+
<i class="${icon}" style="color:${color}"></i>
|
| 744 |
+
<p>${msg}</p>
|
| 745 |
+
<a href="${url}" target="_blank" class="btn-primary" style="text-decoration:none;display:inline-flex;align-items:center;gap:8px;margin-top:16px">
|
| 746 |
+
<i class="ph-fill ph-download-simple"></i> Download File
|
| 747 |
+
</a>
|
| 748 |
+
</div>`;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
closePreviewModal.addEventListener('click', () => previewModal.classList.remove('active'));
|
| 752 |
+
previewModal.addEventListener('click', (e) => { if (e.target === previewModal) previewModal.classList.remove('active'); });
|
| 753 |
+
downloadFromPreview.addEventListener('click', () => { if (currentPreviewUrl) downloadFile(currentPreviewUrl, previewFileName.textContent); });
|
| 754 |
+
|
| 755 |
+
// ─── SHARE / DOWNLOAD ────────────────────────────────────
|
| 756 |
+
function copyLink(url) {
|
| 757 |
+
navigator.clipboard.writeText(url)
|
| 758 |
+
.then(() => showToast('🔗 Link copied to clipboard!', 'success'))
|
| 759 |
+
.catch(() => { prompt('Copy this link:', url); });
|
| 760 |
+
}
|
| 761 |
+
function downloadFile(url, name) {
|
| 762 |
+
const a = document.createElement('a');
|
| 763 |
+
a.href = url; a.download = name; a.target = '_blank';
|
| 764 |
+
document.body.appendChild(a);
|
| 765 |
+
a.click();
|
| 766 |
+
a.remove();
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
// ─── DELETE ───────────────────────────────────────────────
|
| 770 |
+
function openDeleteModal(path, name, type = 'file') {
|
| 771 |
+
pendingDeletePath = path;
|
| 772 |
+
pendingDeleteType = type;
|
| 773 |
+
const p = deleteModal.querySelector('p');
|
| 774 |
+
if (p) p.innerHTML = `This will permanently delete <strong>${name}</strong> from your repository.`;
|
| 775 |
+
deleteModal.classList.add('active');
|
| 776 |
+
}
|
| 777 |
+
closeDeleteModal.addEventListener('click', () => deleteModal.classList.remove('active'));
|
| 778 |
+
cancelDeleteBtn.addEventListener('click', () => deleteModal.classList.remove('active'));
|
| 779 |
+
confirmDeleteBtn.addEventListener('click', async () => {
|
| 780 |
+
if (!pendingDeletePath) return;
|
| 781 |
+
deleteModal.classList.remove('active');
|
| 782 |
+
showProgress(`Deleting…`);
|
| 783 |
+
try {
|
| 784 |
+
await deleteItemAPI(pendingDeletePath, pendingDeleteType);
|
| 785 |
+
showToast('✅ Deleted successfully.', 'success');
|
| 786 |
+
fetchAndRender();
|
| 787 |
+
} catch (err) {
|
| 788 |
+
showToast('❌ Delete failed: ' + err.message, 'error');
|
| 789 |
+
} finally {
|
| 790 |
+
hideProgress();
|
| 791 |
+
pendingDeletePath = null;
|
| 792 |
+
pendingDeleteType = 'file';
|
| 793 |
+
}
|
| 794 |
+
});
|
| 795 |
+
|
| 796 |
+
// ─── UPLOAD ───────────────────────────────────────────────
|
| 797 |
+
async function uploadFiles(fileList) {
|
| 798 |
+
if (!fileList || !fileList.length) return;
|
| 799 |
+
|
| 800 |
+
const files = Array.from(fileList);
|
| 801 |
+
let done = 0;
|
| 802 |
+
for (const file of files) {
|
| 803 |
+
const destPath = getFolderPath();
|
| 804 |
+
const filePath = getFilePath(file.name);
|
| 805 |
+
showProgress(`Uploading ${file.name} (${done + 1}/${files.length})…`);
|
| 806 |
+
try {
|
| 807 |
+
await uploadFileAPI(file, destPath);
|
| 808 |
+
addToRecent(filePath, file.name, 'file');
|
| 809 |
+
done++;
|
| 810 |
+
} catch (err) {
|
| 811 |
+
showToast(`❌ Failed to upload ${file.name}: ${err.message}`, 'error');
|
| 812 |
+
}
|
| 813 |
+
}
|
| 814 |
+
hideProgress();
|
| 815 |
+
if (done > 0) {
|
| 816 |
+
showToast(`✅ Uploaded ${done} file${done > 1 ? 's' : ''} successfully!`, 'success');
|
| 817 |
+
fetchAndRender();
|
| 818 |
+
}
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
// ─── DRAG & DROP ──────────────────────────────────────────
|
| 822 |
+
let dragCounter = 0;
|
| 823 |
+
contentArea.addEventListener('dragenter', (e) => {
|
| 824 |
+
e.preventDefault(); dragCounter++;
|
| 825 |
+
contentArea.classList.add('drag-over');
|
| 826 |
+
});
|
| 827 |
+
contentArea.addEventListener('dragleave', (e) => {
|
| 828 |
+
e.preventDefault(); dragCounter--;
|
| 829 |
+
if (dragCounter <= 0) { dragCounter = 0; contentArea.classList.remove('drag-over'); }
|
| 830 |
+
});
|
| 831 |
+
contentArea.addEventListener('dragover', (e) => { e.preventDefault(); });
|
| 832 |
+
contentArea.addEventListener('drop', (e) => {
|
| 833 |
+
e.preventDefault(); dragCounter = 0;
|
| 834 |
+
contentArea.classList.remove('drag-over');
|
| 835 |
+
const files = e.dataTransfer?.files;
|
| 836 |
+
if (files && files.length) uploadFiles(files);
|
| 837 |
+
});
|
| 838 |
+
|
| 839 |
+
// Also add drop to entire body for full-window drop
|
| 840 |
+
document.addEventListener('dragover', (e) => e.preventDefault());
|
| 841 |
+
|
| 842 |
+
// ─── PROGRESS BAR ────────────────────────────────────────
|
| 843 |
+
function showProgress(msg = 'Working…') {
|
| 844 |
+
progressText.textContent = msg;
|
| 845 |
+
uploadProgress.classList.add('active');
|
| 846 |
+
}
|
| 847 |
+
function hideProgress() { uploadProgress.classList.remove('active'); }
|
| 848 |
+
|
| 849 |
+
// ─── FILE INPUT ───────────────────────────────────────────
|
| 850 |
+
fileInput.addEventListener('change', () => {
|
| 851 |
+
uploadFiles(fileInput.files);
|
| 852 |
+
fileInput.value = '';
|
| 853 |
+
});
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
// ──��� NEW BUTTON ───────────────────────────────────────────
|
| 858 |
+
newBtn.addEventListener('click', (e) => {
|
| 859 |
+
e.stopPropagation();
|
| 860 |
+
newDropdown.classList.toggle('active');
|
| 861 |
+
});
|
| 862 |
+
createFolderBtn.addEventListener('click', () => {
|
| 863 |
+
newDropdown.classList.remove('active');
|
| 864 |
+
folderNameInput.value = '';
|
| 865 |
+
createFolderModal.classList.add('active');
|
| 866 |
+
setTimeout(() => folderNameInput.focus(), 100);
|
| 867 |
+
});
|
| 868 |
+
uploadFileBtn.addEventListener('click', () => {
|
| 869 |
+
newDropdown.classList.remove('active');
|
| 870 |
+
fileInput.click();
|
| 871 |
+
});
|
| 872 |
+
|
| 873 |
+
// ─── CREATE FOLDER ────────────────────────────────────────
|
| 874 |
+
closeNameModal.addEventListener('click', () => createFolderModal.classList.remove('active'));
|
| 875 |
+
cancelFolderBtn.addEventListener('click', () => createFolderModal.classList.remove('active'));
|
| 876 |
+
confirmFolderBtn.addEventListener('click', async () => {
|
| 877 |
+
const name = folderNameInput.value.trim();
|
| 878 |
+
if (!name) { folderNameInput.focus(); return; }
|
| 879 |
+
createFolderModal.classList.remove('active');
|
| 880 |
+
showProgress(`Creating folder "${name}"…`);
|
| 881 |
+
try {
|
| 882 |
+
const folderPath = getFilePath(name);
|
| 883 |
+
await createFolderAPI(folderPath);
|
| 884 |
+
showToast(`📁 Folder "${name}" created!`, 'success');
|
| 885 |
+
fetchAndRender();
|
| 886 |
+
} catch (err) {
|
| 887 |
+
console.error('Folder creation failed:', err.message);
|
| 888 |
+
showToast(`❌ Folder creation failed: ${err.message}`, 'error');
|
| 889 |
+
} finally { hideProgress(); }
|
| 890 |
+
});
|
| 891 |
+
folderNameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmFolderBtn.click(); });
|
| 892 |
+
|
| 893 |
+
// ─── SEARCH ───────────────────────────────────────────────
|
| 894 |
+
let searchDebounce;
|
| 895 |
+
searchInput.addEventListener('input', () => {
|
| 896 |
+
clearTimeout(searchDebounce);
|
| 897 |
+
searchDebounce = setTimeout(() => {
|
| 898 |
+
searchQuery = searchInput.value.trim();
|
| 899 |
+
fetchAndRender();
|
| 900 |
+
}, 400);
|
| 901 |
+
});
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
// ─── NAV ──────────────────────────────────────────────────
|
| 906 |
+
function setNavActive(nav) {
|
| 907 |
+
[navMyFiles, navRecent, navStarred].forEach(n => n.classList.remove('active'));
|
| 908 |
+
nav.classList.add('active');
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
navMyFiles.addEventListener('click', (e) => {
|
| 912 |
+
e.preventDefault();
|
| 913 |
+
e.stopPropagation();
|
| 914 |
+
e.stopImmediatePropagation();
|
| 915 |
+
currentBrowse = 'files'; currentPath = [];
|
| 916 |
+
setNavActive(navMyFiles);
|
| 917 |
+
fetchAndRender();
|
| 918 |
+
});
|
| 919 |
+
|
| 920 |
+
navRecent.addEventListener('click', (e) => {
|
| 921 |
+
e.preventDefault();
|
| 922 |
+
currentBrowse = 'recent';
|
| 923 |
+
setNavActive(navRecent);
|
| 924 |
+
renderRecentView();
|
| 925 |
+
});
|
| 926 |
+
|
| 927 |
+
navStarred.addEventListener('click', (e) => {
|
| 928 |
+
e.preventDefault();
|
| 929 |
+
currentBrowse = 'starred';
|
| 930 |
+
setNavActive(navStarred);
|
| 931 |
+
renderStarredView();
|
| 932 |
+
});
|
| 933 |
+
|
| 934 |
+
function renderRecentView() {
|
| 935 |
+
foldersContainer.innerHTML = '';
|
| 936 |
+
breadcrumbsEl.innerHTML = '<span class="breadcrumb-item active">Recent</span>';
|
| 937 |
+
const items = getRecent();
|
| 938 |
+
filesContainer.innerHTML = '';
|
| 939 |
+
if (!items.length) { showEmpty(filesContainer, 'No recently opened files.'); return; }
|
| 940 |
+
items.forEach(item => {
|
| 941 |
+
const { icon, color } = getFileIcon(item.name);
|
| 942 |
+
const url = getFileUrl(item.path);
|
| 943 |
+
const card = document.createElement('div');
|
| 944 |
+
card.className = 'file-card';
|
| 945 |
+
const ext = getExt(item.name).toUpperCase() || 'FILE';
|
| 946 |
+
const isImg = isImage(item.name);
|
| 947 |
+
const previewHTML = isImg
|
| 948 |
+
? `<img src="${url}" alt="${item.name}" loading="lazy" onerror="this.parentElement.innerHTML='<span class=\\'file-icon\\'>📄</span>'">`
|
| 949 |
+
: `<span class="file-icon">${getFileEmoji(ext)}</span>`;
|
| 950 |
+
|
| 951 |
+
card.innerHTML = `
|
| 952 |
+
<div class="file-preview">
|
| 953 |
+
${previewHTML}
|
| 954 |
+
</div>
|
| 955 |
+
<div class="file-info">
|
| 956 |
+
<span class="file-type">${ext}</span>
|
| 957 |
+
<h4 class="file-name" title="${item.name}">${item.name}</h4>
|
| 958 |
+
<p class="file-meta">${item.path}</p>
|
| 959 |
+
</div>
|
| 960 |
+
<div class="file-actions">⋮</div>`;
|
| 961 |
+
card.addEventListener('click', () => openPreview(item.path, item.name, url));
|
| 962 |
+
filesContainer.appendChild(card);
|
| 963 |
+
});
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
function renderStarredView() {
|
| 967 |
+
foldersContainer.innerHTML = '';
|
| 968 |
+
breadcrumbsEl.innerHTML = '<span class="breadcrumb-item active">Starred</span>';
|
| 969 |
+
const starred = getStarred();
|
| 970 |
+
filesContainer.innerHTML = '';
|
| 971 |
+
if (!starred.length) { showEmpty(filesContainer, 'No starred files yet.'); return; }
|
| 972 |
+
starred.forEach(path => {
|
| 973 |
+
const name = path.split('/').pop();
|
| 974 |
+
const url = getFileUrl(path);
|
| 975 |
+
const { icon, color } = getFileIcon(name);
|
| 976 |
+
const card = document.createElement('div');
|
| 977 |
+
card.className = 'file-card';
|
| 978 |
+
const ext = getExt(name).toUpperCase() || 'FILE';
|
| 979 |
+
const isImg = isImage(name);
|
| 980 |
+
const previewHTML = isImg
|
| 981 |
+
? `<img src="${url}" alt="${name}" loading="lazy" onerror="this.parentElement.innerHTML='<span class=\\'file-icon\\'>📄</span>'">`
|
| 982 |
+
: `<span class="file-icon">${getFileEmoji(ext)}</span>`;
|
| 983 |
+
|
| 984 |
+
card.innerHTML = `
|
| 985 |
+
<div class="file-preview">
|
| 986 |
+
${previewHTML}
|
| 987 |
+
</div>
|
| 988 |
+
<div class="file-info">
|
| 989 |
+
<span class="file-type">${ext}</span>
|
| 990 |
+
<h4 class="file-name" title="${name}">${name}</h4>
|
| 991 |
+
<p class="file-meta">${path}</p>
|
| 992 |
+
</div>
|
| 993 |
+
<div class="file-actions">⋮</div>`;
|
| 994 |
+
card.addEventListener('click', () => openPreview(path, name, url));
|
| 995 |
+
filesContainer.appendChild(card);
|
| 996 |
+
});
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
// ─── VIEW TOGGLES ─────────────────────────────────────────
|
| 1000 |
+
viewGrid.addEventListener('click', () => {
|
| 1001 |
+
if (currentViewMode === 'grid') return;
|
| 1002 |
+
currentViewMode = 'grid';
|
| 1003 |
+
viewGrid.classList.add('active'); viewList.classList.remove('active');
|
| 1004 |
+
renderView();
|
| 1005 |
+
});
|
| 1006 |
+
viewList.addEventListener('click', () => {
|
| 1007 |
+
if (currentViewMode === 'list') return;
|
| 1008 |
+
currentViewMode = 'list';
|
| 1009 |
+
viewList.classList.add('active'); viewGrid.classList.remove('active');
|
| 1010 |
+
renderView();
|
| 1011 |
+
});
|
| 1012 |
+
function renderView() {
|
| 1013 |
+
if (currentBrowse === 'recent') { renderRecentView(); return; }
|
| 1014 |
+
if (currentBrowse === 'starred') { renderStarredView(); return; }
|
| 1015 |
+
fetchAndRender();
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
// ─── KEYBOARD SHORTCUTS ───────────────────────────────────
|
| 1019 |
+
document.addEventListener('keydown', (e) => {
|
| 1020 |
+
if (e.key === 'Escape') {
|
| 1021 |
+
previewModal.classList.remove('active');
|
| 1022 |
+
deleteModal.classList.remove('active');
|
| 1023 |
+
createFolderModal.classList.remove('active');
|
| 1024 |
+
}
|
| 1025 |
+
});
|
| 1026 |
+
|
| 1027 |
+
// ─── RENAME ───────────────────────────────────────────────
|
| 1028 |
+
const renameModal = $('renameModal');
|
| 1029 |
+
const closeRenameModal = $('closeRenameModal');
|
| 1030 |
+
const cancelRenameBtn = $('cancelRenameBtn');
|
| 1031 |
+
const confirmRenameBtn = $('confirmRenameBtn');
|
| 1032 |
+
const renameInput = $('renameInput');
|
| 1033 |
+
let pendingRenamePath = null;
|
| 1034 |
+
|
| 1035 |
+
function openRenameModal(filePath, currentName) {
|
| 1036 |
+
pendingRenamePath = filePath;
|
| 1037 |
+
renameInput.value = currentName;
|
| 1038 |
+
renameModal.classList.add('active');
|
| 1039 |
+
setTimeout(() => { renameInput.select(); }, 100);
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
closeRenameModal.addEventListener('click', () => renameModal.classList.remove('active'));
|
| 1043 |
+
cancelRenameBtn.addEventListener('click', () => renameModal.classList.remove('active'));
|
| 1044 |
+
renameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmRenameBtn.click(); });
|
| 1045 |
+
|
| 1046 |
+
confirmRenameBtn.addEventListener('click', async () => {
|
| 1047 |
+
const newName = renameInput.value.trim();
|
| 1048 |
+
if (!newName || !pendingRenamePath) return;
|
| 1049 |
+
|
| 1050 |
+
const oldPath = pendingRenamePath;
|
| 1051 |
+
const dir = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/')) : '';
|
| 1052 |
+
const newPath = dir ? `${dir}/${newName}` : newName;
|
| 1053 |
+
|
| 1054 |
+
if (oldPath === newPath) { renameModal.classList.remove('active'); return; }
|
| 1055 |
+
|
| 1056 |
+
renameModal.classList.remove('active');
|
| 1057 |
+
showProgress(`Renaming to "${newName}"…`);
|
| 1058 |
+
try {
|
| 1059 |
+
// Use rename API for direct renaming
|
| 1060 |
+
await renameItemAPI(oldPath, newName);
|
| 1061 |
+
showToast(`✅ Renamed to "${newName}"!`, 'success');
|
| 1062 |
+
fetchAndRender();
|
| 1063 |
+
} catch (err) {
|
| 1064 |
+
showToast('❌ Rename failed: ' + err.message, 'error');
|
| 1065 |
+
} finally { hideProgress(); pendingRenamePath = null; }
|
| 1066 |
+
});
|
| 1067 |
+
|
| 1068 |
+
// ─── INIT ─────────────────────────────────────────────────
|
| 1069 |
+
// Initialize offline-first DocVault
|
| 1070 |
+
(function initApp() {
|
| 1071 |
+
// Show welcome message
|
| 1072 |
+
showToast('🎉 Welcome to DocVault! Loading your files...', 'info');
|
| 1073 |
+
|
| 1074 |
+
// Auto-load files from local backend
|
| 1075 |
+
fetchAndRender();
|
| 1076 |
+
})();
|
index.html
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>DocVault | Cloud Document Manager</title>
|
| 7 |
+
<meta name="description" content="DocVault — personal document manager powered by Hugging Face Datasets.">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
<!-- Phosphor Icons -->
|
| 12 |
+
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
| 13 |
+
<link rel="stylesheet" href="styles.css">
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
|
| 17 |
+
<!-- Hidden file input -->
|
| 18 |
+
<input type="file" id="fileInput" multiple style="display:none">
|
| 19 |
+
|
| 20 |
+
<!-- Toast container -->
|
| 21 |
+
<div id="toastContainer" class="toast-container"></div>
|
| 22 |
+
|
| 23 |
+
<!-- Upload / Action Progress -->
|
| 24 |
+
<div id="uploadProgress" class="upload-progress">
|
| 25 |
+
<div class="upload-progress-inner">
|
| 26 |
+
<div class="spinner-sm"></div>
|
| 27 |
+
<span id="progressText">Working...</span>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div class="app-container">
|
| 32 |
+
|
| 33 |
+
<!-- Sidebar -->
|
| 34 |
+
<aside class="sidebar">
|
| 35 |
+
<div class="logo-container">
|
| 36 |
+
<i class="ph-fill ph-safe logo-icon"></i>
|
| 37 |
+
<h1 class="logo-text">DocVault</h1>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="new-btn-wrapper">
|
| 41 |
+
<button class="btn-primary new-btn" id="newBtn">
|
| 42 |
+
<i class="ph-bold ph-plus"></i> New
|
| 43 |
+
</button>
|
| 44 |
+
<div class="new-dropdown" id="newDropdown">
|
| 45 |
+
<button class="new-dropdown-item" id="createFolderBtn">
|
| 46 |
+
<i class="ph-fill ph-folder-plus"></i> Create Folder
|
| 47 |
+
</button>
|
| 48 |
+
<button class="new-dropdown-item" id="uploadFileBtn">
|
| 49 |
+
<i class="ph-fill ph-upload-simple"></i> Upload File
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<nav class="sidebar-nav">
|
| 55 |
+
<a href="#" class="nav-item active" id="navMyFiles">
|
| 56 |
+
<i class="ph-fill ph-folder"></i> My Files
|
| 57 |
+
</a>
|
| 58 |
+
<a href="#" class="nav-item" id="navRecent">
|
| 59 |
+
<i class="ph-fill ph-clock-counter-clockwise"></i> Recent
|
| 60 |
+
</a>
|
| 61 |
+
<a href="#" class="nav-item" id="navStarred">
|
| 62 |
+
<i class="ph-fill ph-star"></i> Starred
|
| 63 |
+
</a>
|
| 64 |
+
</nav>
|
| 65 |
+
|
| 66 |
+
<div class="sidebar-bottom">
|
| 67 |
+
<div class="storage-dashboard">
|
| 68 |
+
<div class="storage-header">
|
| 69 |
+
<i class="ph-fill ph-database"></i>
|
| 70 |
+
<span>Storage</span>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="storage-progress">
|
| 73 |
+
<div class="progress-fill" id="storageProgress" style="width: 0%"></div>
|
| 74 |
+
</div>
|
| 75 |
+
<p class="storage-text" id="storageUsageText">0 files • 0 MB used</p>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="hf-badge">
|
| 78 |
+
<span class="hf-emoji">🤗</span>
|
| 79 |
+
<span>Powered by Hugging Face</span>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
</div>
|
| 83 |
+
</aside>
|
| 84 |
+
|
| 85 |
+
<!-- Main -->
|
| 86 |
+
<main class="main-content">
|
| 87 |
+
<header class="top-header">
|
| 88 |
+
<div class="search-bar">
|
| 89 |
+
<i class="ph-bold ph-magnifying-glass"></i>
|
| 90 |
+
<input type="text" id="searchInput" placeholder="Search files or folders...">
|
| 91 |
+
</div>
|
| 92 |
+
<div class="user-actions">
|
| 93 |
+
<div class="avatar">U</div>
|
| 94 |
+
</div>
|
| 95 |
+
</header>
|
| 96 |
+
|
| 97 |
+
<div class="breadcrumbs" id="breadcrumbs">
|
| 98 |
+
<span class="breadcrumb-item active">My Files</span>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div class="content-area" id="contentArea">
|
| 102 |
+
<div class="section-header">
|
| 103 |
+
<h2>Folders</h2>
|
| 104 |
+
<div class="view-toggles">
|
| 105 |
+
<button class="icon-btn active" id="viewGrid" title="Grid"><i class="ph-fill ph-squares-four"></i></button>
|
| 106 |
+
<button class="icon-btn" id="viewList" title="List"><i class="ph-fill ph-list-dashes"></i></button>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
<div class="grid-container" id="foldersContainer"></div>
|
| 110 |
+
|
| 111 |
+
<div class="section-header mt-8">
|
| 112 |
+
<h2>Files</h2>
|
| 113 |
+
</div>
|
| 114 |
+
<div class="grid-container" id="filesContainer"></div>
|
| 115 |
+
</div>
|
| 116 |
+
</main>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<!-- =================== MODALS =================== -->
|
| 120 |
+
|
| 121 |
+
<!-- Create Folder -->
|
| 122 |
+
<div class="modal-overlay" id="createFolderModal">
|
| 123 |
+
<div class="modal glass-panel">
|
| 124 |
+
<button class="close-modal" id="closeNameModal"><i class="ph-bold ph-x"></i></button>
|
| 125 |
+
<h3><i class="ph-fill ph-folder-plus" style="color:var(--folder-color);margin-right:10px"></i>New Folder</h3>
|
| 126 |
+
<div class="input-group">
|
| 127 |
+
<input type="text" id="folderNameInput" placeholder="Enter folder name..." autocomplete="off">
|
| 128 |
+
</div>
|
| 129 |
+
<div class="modal-footer">
|
| 130 |
+
<button class="btn-secondary" id="cancelFolderBtn">Cancel</button>
|
| 131 |
+
<button class="btn-primary" id="confirmFolderBtn">Create</button>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<!-- Delete Confirmation -->
|
| 137 |
+
<div class="modal-overlay" id="deleteModal">
|
| 138 |
+
<div class="modal glass-panel" style="max-width:360px">
|
| 139 |
+
<button class="close-modal" id="closeDeleteModal"><i class="ph-bold ph-x"></i></button>
|
| 140 |
+
<div class="delete-icon-wrap"><i class="ph-fill ph-warning"></i></div>
|
| 141 |
+
<p style="text-align:center; margin-bottom:20px; font-size:15px; color:var(--text-main);">
|
| 142 |
+
Are you sure you want to delete <strong>this item</strong>?
|
| 143 |
+
</p>
|
| 144 |
+
<div class="modal-footer" style="justify-content:center;gap:16px">
|
| 145 |
+
<button class="btn-secondary" id="cancelDeleteBtn">Cancel</button>
|
| 146 |
+
<button class="btn-danger" id="confirmDeleteBtn"><i class="ph-fill ph-trash"></i> Delete</button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<!-- Rename Modal -->
|
| 152 |
+
<div class="modal-overlay" id="renameModal">
|
| 153 |
+
<div class="modal glass-panel" style="max-width:400px">
|
| 154 |
+
<button class="close-modal" id="closeRenameModal"><i class="ph-bold ph-x"></i></button>
|
| 155 |
+
<h3><i class="ph-fill ph-pencil-simple" style="color:var(--primary-color)"></i> Rename File</h3>
|
| 156 |
+
<div class="input-group">
|
| 157 |
+
<label class="input-label">New Name</label>
|
| 158 |
+
<input type="text" id="renameInput" placeholder="Enter new name..." autocomplete="off">
|
| 159 |
+
</div>
|
| 160 |
+
<div class="modal-footer">
|
| 161 |
+
<button class="btn-secondary" id="cancelRenameBtn">Cancel</button>
|
| 162 |
+
<button class="btn-primary" id="confirmRenameBtn"><i class="ph-fill ph-check"></i> Rename</button>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
<!-- Preview Modal -->
|
| 170 |
+
<div class="modal-overlay" id="previewModal">
|
| 171 |
+
<div class="modal glass-panel" style="max-width:900px; width:95%; height:85vh; display:flex; flex-direction:column; padding:0; overflow:hidden">
|
| 172 |
+
<div class="preview-header">
|
| 173 |
+
<div class="preview-title">
|
| 174 |
+
<i class="ph-fill ph-file preview-icon"></i>
|
| 175 |
+
<span id="previewFileName">filename.pdf</span>
|
| 176 |
+
</div>
|
| 177 |
+
<div style="display:flex; gap:12px">
|
| 178 |
+
<button class="icon-btn" id="downloadFromPreview" title="Download"><i class="ph-bold ph-download-simple"></i></button>
|
| 179 |
+
<button class="close-modal" id="closePreviewModal" style="position:static"><i class="ph-bold ph-x"></i></button>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
<div class="preview-body" id="previewBody">
|
| 183 |
+
<div class="loading-state"><div class="spinner"></div><p>Loading preview...</p></div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<script src="app.js"></script>
|
| 189 |
+
</body>
|
| 190 |
+
</html>
|
js/api/hfService.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Production-grade API Client with Proxy Support, TTL Caching, and Retries
|
| 2 |
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class HFService {
|
| 6 |
+
constructor() {
|
| 7 |
+
this.apiBase = 'http://localhost:5000/api';
|
| 8 |
+
this.cache = new Map();
|
| 9 |
+
this.retryLimit = 3;
|
| 10 |
+
this.retryDelay = 1000; // 1s start for exponential backoff
|
| 11 |
+
|
| 12 |
+
// These are used by getFileUrl() in main.js for building download/preview URLs
|
| 13 |
+
this.username = 'mohsin-devs';
|
| 14 |
+
this.dataset = 'docvault-storage';
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async fetchWithRetry(url, options = {}, retries = this.retryLimit) {
|
| 22 |
+
try {
|
| 23 |
+
const response = await fetch(url, options);
|
| 24 |
+
if (!response.ok) {
|
| 25 |
+
if (response.status >= 500 && retries > 0) throw new Error('Server error');
|
| 26 |
+
const errorData = await response.json().catch(() => ({}));
|
| 27 |
+
throw new Error(errorData.error || `Request failed: ${response.status}`);
|
| 28 |
+
}
|
| 29 |
+
return response;
|
| 30 |
+
} catch (err) {
|
| 31 |
+
if (retries > 0) {
|
| 32 |
+
const delay = this.retryDelay * Math.pow(2, this.retryLimit - retries);
|
| 33 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 34 |
+
return this.fetchWithRetry(url, options, retries - 1);
|
| 35 |
+
}
|
| 36 |
+
throw err;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
async listFiles(path = '', recursive = false) {
|
| 41 |
+
const cacheKey = `list-${path}-${recursive}`;
|
| 42 |
+
const cached = this.cache.get(cacheKey);
|
| 43 |
+
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
| 44 |
+
return cached.data;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const url = `${this.apiBase}/list?path=${encodeURIComponent(path)}&recursive=${recursive}`;
|
| 48 |
+
const res = await this.fetchWithRetry(url);
|
| 49 |
+
const data = await res.json();
|
| 50 |
+
|
| 51 |
+
const result = { files: [], folders: [] };
|
| 52 |
+
|
| 53 |
+
if (Array.isArray(data)) {
|
| 54 |
+
for (const item of data) {
|
| 55 |
+
if (item.type === 'file' && !item.path.endsWith('/.gitkeep') && item.path !== '.gitkeep') {
|
| 56 |
+
result.files.push({
|
| 57 |
+
path: item.path,
|
| 58 |
+
name: item.path.split('/').pop(),
|
| 59 |
+
size: item.size || 0,
|
| 60 |
+
type: 'file',
|
| 61 |
+
lastModified: item.lastModified
|
| 62 |
+
});
|
| 63 |
+
} else if (item.type === 'directory') {
|
| 64 |
+
result.folders.push({
|
| 65 |
+
path: item.path,
|
| 66 |
+
name: item.path.split('/').pop(),
|
| 67 |
+
type: 'directory'
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
this.cache.set(cacheKey, { data: result, timestamp: Date.now() });
|
| 74 |
+
return result;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async uploadFile(file, destPath) {
|
| 78 |
+
const base64Content = await this.fileToBase64(file);
|
| 79 |
+
const url = `${this.apiBase}/upload`;
|
| 80 |
+
|
| 81 |
+
const res = await this.fetchWithRetry(url, {
|
| 82 |
+
method: 'POST',
|
| 83 |
+
headers: { 'Content-Type': 'application/json' },
|
| 84 |
+
body: JSON.stringify({
|
| 85 |
+
path: destPath,
|
| 86 |
+
content: base64Content,
|
| 87 |
+
summary: `Upload ${destPath.split('/').pop()}`
|
| 88 |
+
}),
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
this.clearCache();
|
| 92 |
+
return await res.json();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async deleteFile(path) {
|
| 96 |
+
const url = `${this.apiBase}/delete`;
|
| 97 |
+
await this.fetchWithRetry(url, {
|
| 98 |
+
method: 'POST',
|
| 99 |
+
headers: { 'Content-Type': 'application/json' },
|
| 100 |
+
body: JSON.stringify({ path }),
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
this.clearCache();
|
| 104 |
+
return true;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
async deleteFolder(folderPath) {
|
| 108 |
+
const url = `${this.apiBase}/delete-folder`;
|
| 109 |
+
const res = await this.fetchWithRetry(url, {
|
| 110 |
+
method: 'POST',
|
| 111 |
+
headers: { 'Content-Type': 'application/json' },
|
| 112 |
+
body: JSON.stringify({ path: folderPath }),
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
this.clearCache();
|
| 116 |
+
return await res.json();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
async fileToBase64(file) {
|
| 120 |
+
return new Promise((resolve, reject) => {
|
| 121 |
+
const reader = new FileReader();
|
| 122 |
+
const blob = file instanceof File ? file : file.content;
|
| 123 |
+
reader.readAsDataURL(blob);
|
| 124 |
+
reader.onload = () => resolve(reader.result.split(',')[1]);
|
| 125 |
+
reader.onerror = reject;
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
clearCache() {
|
| 130 |
+
this.cache.clear();
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export const hfService = new HFService();
|
js/main.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { hfService } from './api/hfService.js';
|
| 2 |
+
import { stateManager } from './state/stateManager.js';
|
| 3 |
+
import { UIRenderer } from './ui/uiRenderer.js';
|
| 4 |
+
import { getFileUrl, isImage, isPDF, isText } from './utils/formatters.js';
|
| 5 |
+
|
| 6 |
+
class App {
|
| 7 |
+
constructor() {
|
| 8 |
+
this.ui = new UIRenderer(stateManager, hfService);
|
| 9 |
+
this.state = stateManager;
|
| 10 |
+
this.hf = hfService;
|
| 11 |
+
this.pendingDelete = null;
|
| 12 |
+
this.cachedFolders = [];
|
| 13 |
+
this.init();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
async init() {
|
| 17 |
+
this.setupEventListeners();
|
| 18 |
+
this.setupNetworkHandling();
|
| 19 |
+
this.setupDragAndDrop();
|
| 20 |
+
this.state.subscribe(() => this.render());
|
| 21 |
+
this.fetchAndRender();
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
setupNetworkHandling() {
|
| 25 |
+
window.addEventListener('online', () => {
|
| 26 |
+
this.ui.showToast('Back online! Syncing...', 'success');
|
| 27 |
+
this.fetchAndRender();
|
| 28 |
+
});
|
| 29 |
+
window.addEventListener('offline', () => {
|
| 30 |
+
this.ui.showToast('You are offline. Some features may be limited.', 'warning');
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
setupDragAndDrop() {
|
| 35 |
+
const area = document.getElementById('contentArea');
|
| 36 |
+
if (!area) return;
|
| 37 |
+
|
| 38 |
+
['dragenter', 'dragover'].forEach(evt => {
|
| 39 |
+
area.addEventListener(evt, (e) => {
|
| 40 |
+
e.preventDefault();
|
| 41 |
+
e.stopPropagation();
|
| 42 |
+
area.classList.add('drag-over');
|
| 43 |
+
});
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
['dragleave', 'drop'].forEach(evt => {
|
| 47 |
+
area.addEventListener(evt, (e) => {
|
| 48 |
+
e.preventDefault();
|
| 49 |
+
e.stopPropagation();
|
| 50 |
+
area.classList.remove('drag-over');
|
| 51 |
+
});
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
area.addEventListener('drop', (e) => {
|
| 55 |
+
const files = e.dataTransfer.files;
|
| 56 |
+
if (files.length > 0) {
|
| 57 |
+
this.uploadFiles(files);
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
setupEventListeners() {
|
| 63 |
+
// Nav
|
| 64 |
+
document.getElementById('navMyFiles').onclick = (e) => {
|
| 65 |
+
e.preventDefault();
|
| 66 |
+
this.state.setBrowseMode('files');
|
| 67 |
+
this.state.setPath([]);
|
| 68 |
+
this.fetchAndRender();
|
| 69 |
+
};
|
| 70 |
+
document.getElementById('navRecent').onclick = (e) => {
|
| 71 |
+
e.preventDefault();
|
| 72 |
+
this.state.setBrowseMode('recent');
|
| 73 |
+
this.render();
|
| 74 |
+
};
|
| 75 |
+
document.getElementById('navStarred').onclick = (e) => {
|
| 76 |
+
e.preventDefault();
|
| 77 |
+
this.state.setBrowseMode('starred');
|
| 78 |
+
this.render();
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
// View Toggles
|
| 82 |
+
document.getElementById('viewGrid').onclick = () => this.state.setViewMode('grid');
|
| 83 |
+
document.getElementById('viewList').onclick = () => this.state.setViewMode('list');
|
| 84 |
+
|
| 85 |
+
// Search
|
| 86 |
+
let searchDebounce;
|
| 87 |
+
document.getElementById('searchInput').oninput = (e) => {
|
| 88 |
+
clearTimeout(searchDebounce);
|
| 89 |
+
searchDebounce = setTimeout(() => {
|
| 90 |
+
this.state.setSearchQuery(e.target.value.trim());
|
| 91 |
+
this.fetchAndRender();
|
| 92 |
+
}, 400);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// New actions
|
| 96 |
+
document.getElementById('newBtn').onclick = (e) => {
|
| 97 |
+
e.stopPropagation();
|
| 98 |
+
document.getElementById('newDropdown').classList.toggle('active');
|
| 99 |
+
};
|
| 100 |
+
document.getElementById('uploadFileBtn').onclick = () => {
|
| 101 |
+
document.getElementById('newDropdown').classList.remove('active');
|
| 102 |
+
document.getElementById('fileInput').click();
|
| 103 |
+
};
|
| 104 |
+
document.getElementById('createFolderBtn').onclick = () => {
|
| 105 |
+
document.getElementById('newDropdown').classList.remove('active');
|
| 106 |
+
document.getElementById('createFolderModal').classList.add('active');
|
| 107 |
+
document.getElementById('folderNameInput').value = '';
|
| 108 |
+
document.getElementById('folderNameInput').focus();
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// File Input
|
| 112 |
+
document.getElementById('fileInput').onchange = (e) => {
|
| 113 |
+
this.uploadFiles(e.target.files);
|
| 114 |
+
e.target.value = '';
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// Create Folder Modal
|
| 118 |
+
document.getElementById('confirmFolderBtn').onclick = () => this.createFolder();
|
| 119 |
+
document.getElementById('cancelFolderBtn').onclick = () => document.getElementById('createFolderModal').classList.remove('active');
|
| 120 |
+
|
| 121 |
+
// Enter on folder name input
|
| 122 |
+
document.getElementById('folderNameInput').addEventListener('keydown', (e) => {
|
| 123 |
+
if (e.key === 'Enter') this.createFolder();
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
// Delete Modal
|
| 127 |
+
document.getElementById('confirmDeleteBtn').onclick = () => this.confirmDelete();
|
| 128 |
+
document.getElementById('cancelDeleteBtn').onclick = () => document.getElementById('deleteModal').classList.remove('active');
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
// Click outside closes dropdown
|
| 133 |
+
document.addEventListener('click', () => {
|
| 134 |
+
document.getElementById('newDropdown').classList.remove('active');
|
| 135 |
+
// Close all dropdown menus
|
| 136 |
+
document.querySelectorAll('.dropdown-menu.open').forEach(m => m.classList.remove('open'));
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
// Modals Close via X button
|
| 140 |
+
document.querySelectorAll('.close-modal').forEach(btn => {
|
| 141 |
+
btn.onclick = () => {
|
| 142 |
+
btn.closest('.modal-overlay').classList.remove('active');
|
| 143 |
+
};
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
// Modals close on overlay click
|
| 147 |
+
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
| 148 |
+
overlay.addEventListener('click', (e) => {
|
| 149 |
+
if (e.target === overlay) {
|
| 150 |
+
overlay.classList.remove('active');
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
async fetchAndRender() {
|
| 157 |
+
if (this.state.isFetching) return;
|
| 158 |
+
this.state.isFetching = true;
|
| 159 |
+
this.ui.showSkeletons();
|
| 160 |
+
|
| 161 |
+
try {
|
| 162 |
+
const path = this.state.getFolderPath();
|
| 163 |
+
const { files, folders } = await this.hf.listFiles(path);
|
| 164 |
+
|
| 165 |
+
this.state.cachedFiles = files;
|
| 166 |
+
this.cachedFolders = folders;
|
| 167 |
+
this.render();
|
| 168 |
+
this.updateStorageStats();
|
| 169 |
+
} catch (err) {
|
| 170 |
+
console.error('Fetch error:', err);
|
| 171 |
+
this.ui.showToast(err.message || 'Failed to load files', 'error');
|
| 172 |
+
} finally {
|
| 173 |
+
this.state.isFetching = false;
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async updateStorageStats() {
|
| 178 |
+
try {
|
| 179 |
+
const { files } = await this.hf.listFiles('', true);
|
| 180 |
+
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
| 181 |
+
const count = files.length;
|
| 182 |
+
|
| 183 |
+
document.getElementById('storageUsageText').textContent = `${count} files • ${this.formatSize(totalSize)} used`;
|
| 184 |
+
const MAX_STORAGE = 10 * 1024 * 1024 * 1024; // 10GB
|
| 185 |
+
const pct = Math.min((totalSize / MAX_STORAGE) * 100, 100);
|
| 186 |
+
document.getElementById('storageProgress').style.width = pct + '%';
|
| 187 |
+
} catch (err) {
|
| 188 |
+
console.error('Storage stats error:', err);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
formatSize(bytes) {
|
| 193 |
+
if (!bytes || bytes === 0) return '0 B';
|
| 194 |
+
const k = 1024;
|
| 195 |
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
| 196 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 197 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
render() {
|
| 201 |
+
const browseMode = this.state.currentBrowse;
|
| 202 |
+
let displayFiles = [];
|
| 203 |
+
let displayFolders = [];
|
| 204 |
+
|
| 205 |
+
if (browseMode === 'files') {
|
| 206 |
+
displayFiles = this.state.cachedFiles;
|
| 207 |
+
displayFolders = this.cachedFolders;
|
| 208 |
+
this.ui.renderBreadcrumbs((path) => {
|
| 209 |
+
this.state.setPath(path);
|
| 210 |
+
this.fetchAndRender();
|
| 211 |
+
});
|
| 212 |
+
} else if (browseMode === 'recent') {
|
| 213 |
+
displayFiles = this.state.recent;
|
| 214 |
+
document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Recent</span>';
|
| 215 |
+
} else if (browseMode === 'starred') {
|
| 216 |
+
displayFiles = this.state.cachedFiles.filter(f => this.state.starred.includes(f.path));
|
| 217 |
+
document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Starred</span>';
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// Filter by search
|
| 221 |
+
if (this.state.searchQuery) {
|
| 222 |
+
const q = this.state.searchQuery.toLowerCase();
|
| 223 |
+
displayFiles = displayFiles.filter(f => f.name.toLowerCase().includes(q));
|
| 224 |
+
displayFolders = displayFolders.filter(f => f.name.toLowerCase().includes(q));
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
this.ui.renderFolders(displayFolders, (name) => {
|
| 228 |
+
this.state.setPath([...this.state.currentPath, name]);
|
| 229 |
+
this.fetchAndRender();
|
| 230 |
+
}, (path, name) => this.openDeleteModal(path, name));
|
| 231 |
+
|
| 232 |
+
this.ui.renderFiles(displayFiles, {
|
| 233 |
+
onPreview: (file) => this.openPreview(file),
|
| 234 |
+
onDownload: (url, name) => this.downloadFile(url, name),
|
| 235 |
+
onStar: (path) => this.state.toggleStar(path),
|
| 236 |
+
onDelete: (path, name) => this.openDeleteModal(path, name),
|
| 237 |
+
getUrl: (path) => getFileUrl(this.hf.username, this.hf.dataset, path)
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
this.updateActiveNavItem();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
updateActiveNavItem() {
|
| 244 |
+
const items = {
|
| 245 |
+
files: 'navMyFiles',
|
| 246 |
+
recent: 'navRecent',
|
| 247 |
+
starred: 'navStarred'
|
| 248 |
+
};
|
| 249 |
+
Object.values(items).forEach(id => document.getElementById(id).classList.remove('active'));
|
| 250 |
+
document.getElementById(items[this.state.currentBrowse]).classList.add('active');
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
async uploadFiles(fileList) {
|
| 254 |
+
const files = Array.from(fileList);
|
| 255 |
+
const MAX_SIZE = 10 * 1024 * 1024; // 10MB limit for simple API
|
| 256 |
+
|
| 257 |
+
for (const file of files) {
|
| 258 |
+
// 1. Validation
|
| 259 |
+
if (!this.isValidName(file.name)) {
|
| 260 |
+
this.ui.showToast(`Invalid file name: ${file.name}`, 'error');
|
| 261 |
+
continue;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
if (file.size > MAX_SIZE) {
|
| 265 |
+
this.ui.showToast(`File too large: ${file.name} (Max 10MB)`, 'warning');
|
| 266 |
+
continue;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const path = this.state.getFolderPath();
|
| 270 |
+
const destPath = path ? `${path}/${file.name}` : file.name;
|
| 271 |
+
|
| 272 |
+
// 2. Duplicate Check
|
| 273 |
+
if (this.state.cachedFiles.some(f => f.path === destPath)) {
|
| 274 |
+
this.ui.showToast(`File already exists: ${file.name}`, 'warning');
|
| 275 |
+
continue;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
this.ui.showProgress(`Uploading ${file.name}...`);
|
| 279 |
+
try {
|
| 280 |
+
await this.hf.uploadFile(file, destPath);
|
| 281 |
+
this.ui.showToast(`Uploaded ${file.name}`, 'success');
|
| 282 |
+
} catch (err) {
|
| 283 |
+
this.ui.showToast(err.message, 'error');
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
this.ui.hideProgress();
|
| 287 |
+
this.fetchAndRender();
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
async createFolder() {
|
| 291 |
+
const name = document.getElementById('folderNameInput').value.trim();
|
| 292 |
+
if (!name) return;
|
| 293 |
+
|
| 294 |
+
if (!this.isValidName(name)) {
|
| 295 |
+
this.ui.showToast('Invalid folder name', 'error');
|
| 296 |
+
return;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
const path = this.state.getFolderPath();
|
| 300 |
+
const destPath = path ? `${path}/${name}` : name;
|
| 301 |
+
|
| 302 |
+
// Check if folder name is already taken
|
| 303 |
+
if (this.cachedFolders.some(f => f.name === name)) {
|
| 304 |
+
this.ui.showToast(`Folder already exists: ${name}`, 'warning');
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
document.getElementById('createFolderModal').classList.remove('active');
|
| 309 |
+
this.ui.showProgress(`Creating folder ${name}...`);
|
| 310 |
+
try {
|
| 311 |
+
const keepPath = `${destPath}/.gitkeep`;
|
| 312 |
+
await this.hf.uploadFile(new File([''], '.gitkeep'), keepPath);
|
| 313 |
+
this.ui.showToast(`Folder "${name}" created`, 'success');
|
| 314 |
+
this.fetchAndRender();
|
| 315 |
+
} catch (err) {
|
| 316 |
+
this.ui.showToast(err.message, 'error');
|
| 317 |
+
} finally {
|
| 318 |
+
this.ui.hideProgress();
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
isValidName(name) {
|
| 323 |
+
const forbidden = /[<>:"\\|?*\x00-\x1F]/;
|
| 324 |
+
return name && name.length > 0 && !forbidden.test(name) && name.length < 255;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
openDeleteModal(path, name) {
|
| 328 |
+
this.pendingDelete = path;
|
| 329 |
+
const strong = document.querySelector('#deleteModal p strong');
|
| 330 |
+
if (strong) strong.textContent = name;
|
| 331 |
+
document.getElementById('deleteModal').classList.add('active');
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
async confirmDelete() {
|
| 335 |
+
if (!this.pendingDelete) return;
|
| 336 |
+
const path = this.pendingDelete;
|
| 337 |
+
this.pendingDelete = null;
|
| 338 |
+
document.getElementById('deleteModal').classList.remove('active');
|
| 339 |
+
this.ui.showProgress('Deleting...');
|
| 340 |
+
try {
|
| 341 |
+
const isFolder = this.cachedFolders.some(f => f.path === path);
|
| 342 |
+
if (isFolder) {
|
| 343 |
+
await this.hf.deleteFolder(path);
|
| 344 |
+
} else {
|
| 345 |
+
await this.hf.deleteFile(path);
|
| 346 |
+
}
|
| 347 |
+
this.ui.showToast('Deleted successfully', 'success');
|
| 348 |
+
this.fetchAndRender();
|
| 349 |
+
} catch (err) {
|
| 350 |
+
this.ui.showToast(err.message || 'Delete failed', 'error');
|
| 351 |
+
} finally {
|
| 352 |
+
this.ui.hideProgress();
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
openPreview(file) {
|
| 357 |
+
this.state.addToRecent(file);
|
| 358 |
+
const url = getFileUrl(this.hf.username, this.hf.dataset, file.path);
|
| 359 |
+
const modal = document.getElementById('previewModal');
|
| 360 |
+
const body = document.getElementById('previewBody');
|
| 361 |
+
const title = document.getElementById('previewFileName');
|
| 362 |
+
|
| 363 |
+
title.textContent = file.name;
|
| 364 |
+
body.innerHTML = '<div class="loading-state"><div class="spinner"></div></div>';
|
| 365 |
+
modal.classList.add('active');
|
| 366 |
+
|
| 367 |
+
// Download button
|
| 368 |
+
document.getElementById('downloadFromPreview').onclick = () => this.downloadFile(url, file.name);
|
| 369 |
+
|
| 370 |
+
if (isImage(file.name)) {
|
| 371 |
+
body.innerHTML = `<img src="${url}" class="preview-image" alt="${file.name}">`;
|
| 372 |
+
} else if (isPDF(file.name)) {
|
| 373 |
+
body.innerHTML = `<iframe src="${url}" class="preview-iframe"></iframe>`;
|
| 374 |
+
} else if (isText(file.name)) {
|
| 375 |
+
fetch(url).then(r => r.text()).then(text => {
|
| 376 |
+
body.innerHTML = `<pre class="preview-text">${this.escapeHtml(text)}</pre>`;
|
| 377 |
+
}).catch(() => {
|
| 378 |
+
body.innerHTML = '<div class="preview-fallback"><i class="ph-fill ph-file-x"></i><p>Could not load file preview</p></div>';
|
| 379 |
+
});
|
| 380 |
+
} else {
|
| 381 |
+
body.innerHTML = `<div class="preview-fallback"><i class="ph-fill ph-file"></i><p>No preview available</p><a href="${url}" download="${file.name}" class="btn-primary" style="padding: 10px 24px; text-decoration: none; border-radius: 8px; margin-top: 12px;">Download</a></div>`;
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
escapeHtml(text) {
|
| 386 |
+
const div = document.createElement('div');
|
| 387 |
+
div.textContent = text;
|
| 388 |
+
return div.innerHTML;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
downloadFile(url, name) {
|
| 392 |
+
const a = document.createElement('a');
|
| 393 |
+
a.href = url;
|
| 394 |
+
a.download = name;
|
| 395 |
+
a.target = '_blank';
|
| 396 |
+
a.click();
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
new App();
|
js/state/stateManager.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const STARRED_KEY = 'docvault_starred';
|
| 2 |
+
const RECENT_KEY = 'docvault_recent';
|
| 3 |
+
|
| 4 |
+
class StateManager {
|
| 5 |
+
constructor() {
|
| 6 |
+
this.currentPath = [];
|
| 7 |
+
this.searchQuery = '';
|
| 8 |
+
this.viewMode = localStorage.getItem('view_mode') || 'grid'; // 'grid' | 'list'
|
| 9 |
+
this.currentBrowse = 'files'; // 'files' | 'starred' | 'recent'
|
| 10 |
+
this.isFetching = false;
|
| 11 |
+
this.cachedFiles = [];
|
| 12 |
+
this.starred = this.load(STARRED_KEY);
|
| 13 |
+
this.recent = this.load(RECENT_KEY);
|
| 14 |
+
this.listeners = [];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
load(key) {
|
| 18 |
+
try {
|
| 19 |
+
return JSON.parse(localStorage.getItem(key)) || [];
|
| 20 |
+
} catch {
|
| 21 |
+
return [];
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
save(key, data) {
|
| 26 |
+
localStorage.setItem(key, JSON.stringify(data));
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
subscribe(listener) {
|
| 30 |
+
this.listeners.push(listener);
|
| 31 |
+
return () => {
|
| 32 |
+
this.listeners = this.listeners.filter(l => l !== listener);
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
notify(actionType = 'UPDATE', payload = null) {
|
| 37 |
+
console.log(`[StateManager] ${actionType}`, payload || '');
|
| 38 |
+
this.listeners.forEach(l => l(this));
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
setPath(path) {
|
| 42 |
+
if (!Array.isArray(path)) return;
|
| 43 |
+
this.currentPath = path;
|
| 44 |
+
this.notify('SET_PATH', path);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
setViewMode(mode) {
|
| 48 |
+
if (!['grid', 'list'].includes(mode)) return;
|
| 49 |
+
this.viewMode = mode;
|
| 50 |
+
localStorage.setItem('view_mode', mode);
|
| 51 |
+
this.notify('SET_VIEW_MODE', mode);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
setSearchQuery(query) {
|
| 55 |
+
this.searchQuery = typeof query === 'string' ? query : '';
|
| 56 |
+
this.notify('SET_SEARCH', query);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
setBrowseMode(mode) {
|
| 60 |
+
if (!['files', 'starred', 'recent'].includes(mode)) return;
|
| 61 |
+
this.currentBrowse = mode;
|
| 62 |
+
this.notify('SET_BROWSE', mode);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
toggleStar(path) {
|
| 66 |
+
if (typeof path !== 'string') return;
|
| 67 |
+
if (this.starred.includes(path)) {
|
| 68 |
+
this.starred = this.starred.filter(p => p !== path);
|
| 69 |
+
} else {
|
| 70 |
+
this.starred.push(path);
|
| 71 |
+
}
|
| 72 |
+
this.save(STARRED_KEY, this.starred);
|
| 73 |
+
this.notify('TOGGLE_STAR', path);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
addToRecent(file) {
|
| 77 |
+
if (!file || !file.path) return;
|
| 78 |
+
this.recent = [file, ...this.recent.filter(f => f.path !== file.path)].slice(0, 20);
|
| 79 |
+
this.save(RECENT_KEY, this.recent);
|
| 80 |
+
this.notify('ADD_RECENT', file.path);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
getFolderPath() {
|
| 84 |
+
return this.currentPath.join('/');
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export const stateManager = new StateManager();
|
js/ui/uiRenderer.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getFileIcon, getFileEmoji, getExt, isImage, formatSize, formatDate } from '../utils/formatters.js';
|
| 2 |
+
|
| 3 |
+
export class UIRenderer {
|
| 4 |
+
constructor(stateManager, hfService) {
|
| 5 |
+
this.state = stateManager;
|
| 6 |
+
this.hf = hfService;
|
| 7 |
+
this.containers = {
|
| 8 |
+
folders: document.getElementById('foldersContainer'),
|
| 9 |
+
files: document.getElementById('filesContainer'),
|
| 10 |
+
breadcrumbs: document.getElementById('breadcrumbs'),
|
| 11 |
+
toast: document.getElementById('toastContainer'),
|
| 12 |
+
progress: document.getElementById('uploadProgress'),
|
| 13 |
+
progressText: document.getElementById('progressText')
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
showToast(msg, type = 'info') {
|
| 18 |
+
const icons = {
|
| 19 |
+
success: 'ph-fill ph-check-circle',
|
| 20 |
+
error: 'ph-fill ph-warning-circle',
|
| 21 |
+
info: 'ph-fill ph-info',
|
| 22 |
+
warning: 'ph-fill ph-warning'
|
| 23 |
+
};
|
| 24 |
+
const t = document.createElement('div');
|
| 25 |
+
t.className = `toast toast-${type}`;
|
| 26 |
+
t.innerHTML = `<i class="${icons[type] || icons.info}"></i><span>${msg}</span>`;
|
| 27 |
+
this.containers.toast.appendChild(t);
|
| 28 |
+
requestAnimationFrame(() => t.classList.add('show'));
|
| 29 |
+
setTimeout(() => {
|
| 30 |
+
t.classList.remove('show');
|
| 31 |
+
setTimeout(() => t.remove(), 400);
|
| 32 |
+
}, 3500);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
showProgress(msg = 'Working...') {
|
| 36 |
+
this.containers.progressText.textContent = msg;
|
| 37 |
+
this.containers.progress.classList.add('active');
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
hideProgress() {
|
| 41 |
+
this.containers.progress.classList.remove('active');
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
renderBreadcrumbs(onCrumbClick) {
|
| 45 |
+
this.containers.breadcrumbs.innerHTML = '';
|
| 46 |
+
const root = document.createElement('span');
|
| 47 |
+
root.className = 'breadcrumb-item' + (this.state.currentPath.length === 0 ? ' active' : '');
|
| 48 |
+
root.textContent = 'My Files';
|
| 49 |
+
root.onclick = () => onCrumbClick([]);
|
| 50 |
+
this.containers.breadcrumbs.appendChild(root);
|
| 51 |
+
|
| 52 |
+
this.state.currentPath.forEach((seg, idx) => {
|
| 53 |
+
const sep = document.createElement('span');
|
| 54 |
+
sep.className = 'breadcrumb-separator';
|
| 55 |
+
sep.innerHTML = '<i class="ph-bold ph-caret-right" style="font-size: 14px; margin: 0 4px"></i>';
|
| 56 |
+
this.containers.breadcrumbs.appendChild(sep);
|
| 57 |
+
|
| 58 |
+
const crumb = document.createElement('span');
|
| 59 |
+
crumb.className = 'breadcrumb-item' + (idx === this.state.currentPath.length - 1 ? ' active' : '');
|
| 60 |
+
crumb.textContent = seg;
|
| 61 |
+
crumb.onclick = () => onCrumbClick(this.state.currentPath.slice(0, idx + 1));
|
| 62 |
+
this.containers.breadcrumbs.appendChild(crumb);
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
renderFolders(folders, onFolderClick, onDelete) {
|
| 67 |
+
this.containers.folders.innerHTML = '';
|
| 68 |
+
if (!folders.length) return;
|
| 69 |
+
|
| 70 |
+
folders.forEach(folder => {
|
| 71 |
+
const card = document.createElement('div');
|
| 72 |
+
card.className = 'folder-card';
|
| 73 |
+
card.innerHTML = `
|
| 74 |
+
<div class="card-top-row">
|
| 75 |
+
<div class="item-icon">
|
| 76 |
+
<svg class="folder-icon" width="48" height="48" viewBox="0 0 24 24" fill="none">
|
| 77 |
+
<path d="M2 6.75C2 5.784 2.784 5 3.75 5H9.5l1.5 2H20.25C21.216 7 22 7.784 22 8.75v9.5A1.75 1.75 0 0 1 20.25 20H3.75A1.75 1.75 0 0 1 2 18.25z" fill="var(--folder-color)" opacity="0.3"/>
|
| 78 |
+
<path d="M2 8.75C2 7.784 2.784 7 3.75 7H20.25C21.216 7 22 7.784 22 8.75v9.5A1.75 1.75 0 0 1 20.25 20H3.75A1.75 1.75 0 0 1 2 18.25z" fill="var(--folder-color)"/>
|
| 79 |
+
</svg>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="card-menu">
|
| 82 |
+
<button class="icon-btn card-menu-btn" title="More options"><i class="ph-bold ph-dots-three-vertical"></i></button>
|
| 83 |
+
<div class="dropdown-menu">
|
| 84 |
+
<button class="dropdown-item danger" data-action="delete">
|
| 85 |
+
<i class="ph-fill ph-trash"></i> Delete
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="item-name">${folder.name}</div>
|
| 91 |
+
<div class="item-meta">Folder</div>`;
|
| 92 |
+
|
| 93 |
+
card.onclick = (e) => {
|
| 94 |
+
if (e.target.closest('.card-menu')) return;
|
| 95 |
+
onFolderClick(folder.name);
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
card.querySelector('.dropdown-item').onclick = (e) => {
|
| 99 |
+
e.stopPropagation();
|
| 100 |
+
onDelete(folder.path, folder.name);
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const menuBtn = card.querySelector('.card-menu-btn');
|
| 104 |
+
const menu = card.querySelector('.dropdown-menu');
|
| 105 |
+
menuBtn.onclick = (e) => {
|
| 106 |
+
e.stopPropagation();
|
| 107 |
+
menu.classList.toggle('open');
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
this.containers.folders.appendChild(card);
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
renderFiles(files, actions) {
|
| 115 |
+
this.containers.files.innerHTML = '';
|
| 116 |
+
const mode = this.state.viewMode;
|
| 117 |
+
this.containers.files.className = mode === 'grid' ? 'grid-container' : 'list-container';
|
| 118 |
+
|
| 119 |
+
if (!files.length) {
|
| 120 |
+
this.showEmpty();
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
files.forEach(file => {
|
| 125 |
+
const card = document.createElement('div');
|
| 126 |
+
card.className = mode === 'grid' ? 'file-card' : 'file-list-item';
|
| 127 |
+
|
| 128 |
+
const { icon, color } = getFileIcon(file.name);
|
| 129 |
+
const ext = getExt(file.name).toUpperCase();
|
| 130 |
+
const size = formatSize(file.size);
|
| 131 |
+
const starred = this.state.starred.includes(file.path);
|
| 132 |
+
const url = actions.getUrl(file.path);
|
| 133 |
+
|
| 134 |
+
if (mode === 'grid') {
|
| 135 |
+
const isImg = isImage(file.name);
|
| 136 |
+
const previewHTML = isImg
|
| 137 |
+
? `<img src="${url}" alt="${file.name}" loading="lazy" onerror="this.parentElement.innerHTML='<span class=\\'file-icon\\'>📄</span>'">`
|
| 138 |
+
: `<span class="file-icon">${getFileEmoji(ext)}</span>`;
|
| 139 |
+
|
| 140 |
+
card.innerHTML = `
|
| 141 |
+
<div class="file-preview">${previewHTML}</div>
|
| 142 |
+
<div class="file-info">
|
| 143 |
+
<span class="file-type" style="background-color: ${color}20; color: ${color}">${ext}</span>
|
| 144 |
+
<h4 class="file-name" title="${file.name}">${file.name}</h4>
|
| 145 |
+
<p class="file-meta">${size} • ${file.lastModified ? formatDate(file.lastModified) : 'Recently'}</p>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="file-actions">⋮</div>
|
| 148 |
+
<div class="quick-actions">
|
| 149 |
+
<button class="quick-btn" data-action="preview" title="Preview"><i class="ph-fill ph-eye"></i></button>
|
| 150 |
+
<button class="quick-btn" data-action="download" title="Download"><i class="ph-fill ph-download-simple"></i></button>
|
| 151 |
+
</div>`;
|
| 152 |
+
} else {
|
| 153 |
+
card.innerHTML = `
|
| 154 |
+
<div class="list-icon" style="color: ${color}"><i class="${icon}"></i></div>
|
| 155 |
+
<div class="list-info">
|
| 156 |
+
<h4 class="list-name" title="${file.name}">${file.name}</h4>
|
| 157 |
+
<span class="list-meta">${size} • ${ext}</span>
|
| 158 |
+
</div>
|
| 159 |
+
<div class="list-actions">
|
| 160 |
+
<button class="icon-btn" data-action="star" title="${starred ? 'Unstar' : 'Star'}"><i class="ph-fill ph-star${starred ? '' : '-bold'}"></i></button>
|
| 161 |
+
<button class="icon-btn" data-action="download" title="Download"><i class="ph-fill ph-download-simple"></i></button>
|
| 162 |
+
<button class="icon-btn" data-action="delete" title="Delete"><i class="ph-fill ph-trash"></i></button>
|
| 163 |
+
</div>`;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
card.onclick = (e) => {
|
| 167 |
+
if (e.target.closest('.file-actions, .quick-actions, .list-actions')) return;
|
| 168 |
+
actions.onPreview(file);
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
// Action Handlers
|
| 172 |
+
card.addEventListener('click', (e) => {
|
| 173 |
+
const btn = e.target.closest('[data-action]');
|
| 174 |
+
if (!btn) return;
|
| 175 |
+
const action = btn.dataset.action;
|
| 176 |
+
if (action === 'preview') actions.onPreview(file);
|
| 177 |
+
else if (action === 'download') actions.onDownload(url, file.name);
|
| 178 |
+
else if (action === 'star') actions.onStar(file.path);
|
| 179 |
+
else if (action === 'delete') actions.onDelete(file.path, file.name);
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
this.containers.files.appendChild(card);
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
showEmpty() {
|
| 187 |
+
this.containers.files.innerHTML = `
|
| 188 |
+
<div class="empty-state">
|
| 189 |
+
<i class="ph-fill ph-folder-open"></i>
|
| 190 |
+
<h3>Nothing here yet</h3>
|
| 191 |
+
<p>Upload files or create folders to get started.</p>
|
| 192 |
+
</div>`;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
showSkeletons(foldersCount = 3, filesCount = 6) {
|
| 196 |
+
this.containers.folders.innerHTML = '';
|
| 197 |
+
this.containers.files.innerHTML = '';
|
| 198 |
+
for (let i = 0; i < foldersCount; i++) {
|
| 199 |
+
const el = document.createElement('div');
|
| 200 |
+
el.className = 'skeleton skeleton-card';
|
| 201 |
+
this.containers.folders.appendChild(el);
|
| 202 |
+
}
|
| 203 |
+
for (let i = 0; i < filesCount; i++) {
|
| 204 |
+
const el = document.createElement('div');
|
| 205 |
+
el.className = 'skeleton skeleton-card';
|
| 206 |
+
this.containers.files.appendChild(el);
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
js/utils/formatters.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function formatSize(bytes) {
|
| 2 |
+
if (!bytes || bytes === 0) return '—';
|
| 3 |
+
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
| 4 |
+
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
| 5 |
+
if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB';
|
| 6 |
+
return bytes + ' B';
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function getExt(name) { return (name.split('.').pop() || '').toLowerCase(); }
|
| 10 |
+
|
| 11 |
+
export function getFileIcon(name) {
|
| 12 |
+
const n = name.toLowerCase();
|
| 13 |
+
if (n.endsWith('.pdf')) return { icon: 'ph-fill ph-file-pdf', color: '#dc2626' };
|
| 14 |
+
if (n.match(/\.docx?$/)) return { icon: 'ph-fill ph-file-text', color: '#2563eb' };
|
| 15 |
+
if (n.match(/\.xlsx?$/)) return { icon: 'ph-fill ph-file-text', color: '#16a34a' };
|
| 16 |
+
if (n.match(/\.pptx?$/)) return { icon: 'ph-fill ph-presentation', color: '#dc2626' };
|
| 17 |
+
if (n.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)) return { icon: 'ph-fill ph-image', color: '#7c3aed' };
|
| 18 |
+
if (n.match(/\.(mp4|mov|avi|mkv|webm)$/)) return { icon: 'ph-fill ph-video', color: '#fc8181' };
|
| 19 |
+
if (n.match(/\.(mp3|wav|aac|flac|ogg)$/)) return { icon: 'ph-fill ph-music-notes', color: '#68d391' };
|
| 20 |
+
if (n.match(/\.(zip|rar|7z|tar|gz)$/)) return { icon: 'ph-fill ph-file-archive', color: '#7c2d12' };
|
| 21 |
+
if (n.match(/\.(js|py|ts|html|css|json|xml|sh|java|cpp|c)$/)) return { icon: 'ph-fill ph-file-code', color: '#ca8a04' };
|
| 22 |
+
if (n.match(/\.(txt|md|csv|log)$/)) return { icon: 'ph-fill ph-file-text', color: '#64748b' };
|
| 23 |
+
return { icon: 'ph-fill ph-file', color: '#79c0ff' };
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function getFileEmoji(ext) {
|
| 27 |
+
const extLower = ext.toLowerCase();
|
| 28 |
+
if (['pdf'].includes(extLower)) return '📄';
|
| 29 |
+
if (['doc', 'docx'].includes(extLower)) return '📝';
|
| 30 |
+
if (['xls', 'xlsx'].includes(extLower)) return '📊';
|
| 31 |
+
if (['ppt', 'pptx'].includes(extLower)) return '📽️';
|
| 32 |
+
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extLower)) return '🖼️';
|
| 33 |
+
if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(extLower)) return '🎥';
|
| 34 |
+
if (['mp3', 'wav', 'aac', 'flac', 'ogg'].includes(extLower)) return '🎵';
|
| 35 |
+
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extLower)) return '📦';
|
| 36 |
+
if (['js', 'ts', 'py', 'java', 'cpp', 'c', 'html', 'css'].includes(extLower)) return '💻';
|
| 37 |
+
if (['txt', 'md', 'csv', 'log', 'json', 'xml', 'yaml', 'yml'].includes(extLower)) return '📄';
|
| 38 |
+
return '📄';
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function isImage(name) { return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(name); }
|
| 42 |
+
export function isPDF(name) { return /\.pdf$/i.test(name); }
|
| 43 |
+
export function isText(name) { return /\.(txt|md|csv|log|json|xml|html|css|js|ts|py|sh|java|yaml|yml)$/i.test(name); }
|
| 44 |
+
|
| 45 |
+
export function formatDate(timestamp) {
|
| 46 |
+
if (!timestamp) return 'Recently';
|
| 47 |
+
const date = new Date(timestamp);
|
| 48 |
+
const now = new Date();
|
| 49 |
+
const diffMs = now - date;
|
| 50 |
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
| 51 |
+
|
| 52 |
+
if (diffDays === 0) return 'Today';
|
| 53 |
+
if (diffDays === 1) return 'Yesterday';
|
| 54 |
+
if (diffDays < 7) return `${diffDays} days ago`;
|
| 55 |
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
| 56 |
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
| 57 |
+
return `${Math.floor(diffDays / 365)} years ago`;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function getFileUrl(username, dataset, filePath) {
|
| 61 |
+
const encoded = filePath.split('/').map(encodeURIComponent).join('/');
|
| 62 |
+
return `https://huggingface.co/datasets/${encodeURIComponent(username)}/${encodeURIComponent(dataset)}/resolve/main/${encoded}`;
|
| 63 |
+
}
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""DocVault server package"""
|
server/app.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DocVault - Offline-First Document Storage System"""
|
| 2 |
+
|
| 3 |
+
from flask import Flask, jsonify, send_file, render_template_string
|
| 4 |
+
from flask_cors import CORS
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from .config import SECRET_KEY, DEBUG, MAX_CONTENT_LENGTH
|
| 9 |
+
from .routes.api import api_bp
|
| 10 |
+
from .utils.logger import setup_logger
|
| 11 |
+
except ImportError:
|
| 12 |
+
from config import SECRET_KEY, DEBUG, MAX_CONTENT_LENGTH
|
| 13 |
+
from routes.api import api_bp
|
| 14 |
+
from utils.logger import setup_logger
|
| 15 |
+
|
| 16 |
+
logger = setup_logger(__name__)
|
| 17 |
+
|
| 18 |
+
# Get the root directory (parent of server directory)
|
| 19 |
+
ROOT_DIR = os.path.dirname(os.path.dirname(__file__))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def create_app():
|
| 23 |
+
"""Create and configure Flask application"""
|
| 24 |
+
app = Flask(__name__, static_folder=None)
|
| 25 |
+
|
| 26 |
+
# Configuration
|
| 27 |
+
app.config['SECRET_KEY'] = SECRET_KEY
|
| 28 |
+
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
|
| 29 |
+
app.config['JSON_SORT_KEYS'] = False
|
| 30 |
+
|
| 31 |
+
# Enable CORS for local development
|
| 32 |
+
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
| 33 |
+
|
| 34 |
+
# Register blueprints
|
| 35 |
+
app.register_blueprint(api_bp)
|
| 36 |
+
|
| 37 |
+
# Cache busting headers for all responses
|
| 38 |
+
@app.after_request
|
| 39 |
+
def add_cache_headers(response):
|
| 40 |
+
if response.content_type and ('text/html' in response.content_type or
|
| 41 |
+
'text/javascript' in response.content_type or
|
| 42 |
+
'text/css' in response.content_type):
|
| 43 |
+
response.cache_control.max_age = 0
|
| 44 |
+
response.cache_control.no_cache = True
|
| 45 |
+
response.cache_control.no_store = True
|
| 46 |
+
response.headers['Pragma'] = 'no-cache'
|
| 47 |
+
response.headers['Expires'] = '0'
|
| 48 |
+
return response
|
| 49 |
+
|
| 50 |
+
# Global error handlers
|
| 51 |
+
@app.errorhandler(413)
|
| 52 |
+
def request_entity_too_large(error):
|
| 53 |
+
return jsonify({
|
| 54 |
+
"success": False,
|
| 55 |
+
"error": f"File too large. Maximum size: {MAX_CONTENT_LENGTH / (1024 * 1024):.0f}MB"
|
| 56 |
+
}), 413
|
| 57 |
+
|
| 58 |
+
# Serve static frontend files with no-cache headers
|
| 59 |
+
@app.route('/<path:filename>')
|
| 60 |
+
def serve_static(filename):
|
| 61 |
+
"""Serve static files from root directory"""
|
| 62 |
+
filepath = os.path.join(ROOT_DIR, filename)
|
| 63 |
+
if os.path.exists(filepath) and os.path.isfile(filepath):
|
| 64 |
+
return send_file(filepath)
|
| 65 |
+
return jsonify({"error": "Not found"}), 404
|
| 66 |
+
|
| 67 |
+
@app.route('/', methods=['GET'])
|
| 68 |
+
def index():
|
| 69 |
+
"""Serve index.html for root path"""
|
| 70 |
+
index_path = os.path.join(ROOT_DIR, 'index.html')
|
| 71 |
+
if os.path.exists(index_path):
|
| 72 |
+
with open(index_path, 'r', encoding='utf-8') as f:
|
| 73 |
+
return f.read()
|
| 74 |
+
|
| 75 |
+
# Fallback to API documentation if index.html doesn't exist
|
| 76 |
+
return jsonify({
|
| 77 |
+
"name": "DocVault",
|
| 78 |
+
"version": "1.0.0",
|
| 79 |
+
"description": "Offline-First Document Storage System",
|
| 80 |
+
"endpoints": {
|
| 81 |
+
"GET /api/health": "Health check",
|
| 82 |
+
"POST /api/create-folder": "Create folder",
|
| 83 |
+
"POST /api/delete-folder": "Delete folder",
|
| 84 |
+
"POST /api/delete-file": "Delete file",
|
| 85 |
+
"POST /api/upload-file": "Upload file",
|
| 86 |
+
"GET /api/list": "List contents",
|
| 87 |
+
"POST /api/rename": "Rename file/folder",
|
| 88 |
+
"GET /api/storage-stats": "Storage statistics",
|
| 89 |
+
"GET /api/download/<file_path>": "Download file"
|
| 90 |
+
},
|
| 91 |
+
"documentation": "See README.md for detailed API documentation"
|
| 92 |
+
}), 200
|
| 93 |
+
|
| 94 |
+
@app.route('/docs', methods=['GET'])
|
| 95 |
+
def docs():
|
| 96 |
+
"""API documentation in detail"""
|
| 97 |
+
docs_text = """
|
| 98 |
+
<html>
|
| 99 |
+
<head>
|
| 100 |
+
<title>DocVault API Documentation</title>
|
| 101 |
+
<style>
|
| 102 |
+
body { font-family: Arial, sans-serif; margin: 20px; }
|
| 103 |
+
h1 { color: #333; }
|
| 104 |
+
h2 { color: #666; margin-top: 30px; }
|
| 105 |
+
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
| 106 |
+
pre { background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }
|
| 107 |
+
</style>
|
| 108 |
+
</head>
|
| 109 |
+
<body>
|
| 110 |
+
<h1>DocVault API Documentation</h1>
|
| 111 |
+
<p>Offline-First Document Storage System</p>
|
| 112 |
+
|
| 113 |
+
<h2>Available Endpoints</h2>
|
| 114 |
+
|
| 115 |
+
<h3>POST /api/create-folder</h3>
|
| 116 |
+
<p>Create a new folder (including nested folders)</p>
|
| 117 |
+
<pre>
|
| 118 |
+
{
|
| 119 |
+
"folder_path": "Documents/MyProject"
|
| 120 |
+
}
|
| 121 |
+
</pre>
|
| 122 |
+
|
| 123 |
+
<h3>POST /api/delete-folder</h3>
|
| 124 |
+
<p>Delete a folder</p>
|
| 125 |
+
<pre>
|
| 126 |
+
{
|
| 127 |
+
"folder_path": "Documents/MyProject",
|
| 128 |
+
"force": true
|
| 129 |
+
}
|
| 130 |
+
</pre>
|
| 131 |
+
|
| 132 |
+
<h3>POST /api/delete-file</h3>
|
| 133 |
+
<p>Delete a file</p>
|
| 134 |
+
<pre>
|
| 135 |
+
{
|
| 136 |
+
"file_path": "Documents/report.pdf"
|
| 137 |
+
}
|
| 138 |
+
</pre>
|
| 139 |
+
|
| 140 |
+
<h3>POST /api/upload-file</h3>
|
| 141 |
+
<p>Upload a file to a folder</p>
|
| 142 |
+
<p>Form data: file (binary), folder_path (string)</p>
|
| 143 |
+
|
| 144 |
+
<h3>GET /api/list</h3>
|
| 145 |
+
<p>List files and folders</p>
|
| 146 |
+
<p>Query: folder_path (optional)</p>
|
| 147 |
+
|
| 148 |
+
<h3>POST /api/rename</h3>
|
| 149 |
+
<p>Rename a file or folder</p>
|
| 150 |
+
<pre>
|
| 151 |
+
{
|
| 152 |
+
"item_path": "Documents/OldName",
|
| 153 |
+
"new_name": "NewName"
|
| 154 |
+
}
|
| 155 |
+
</pre>
|
| 156 |
+
|
| 157 |
+
<h3>GET /api/storage-stats</h3>
|
| 158 |
+
<p>Get storage statistics</p>
|
| 159 |
+
|
| 160 |
+
<h3>GET /api/download/<file_path></h3>
|
| 161 |
+
<p>Download a file</p>
|
| 162 |
+
|
| 163 |
+
<h2>Headers</h2>
|
| 164 |
+
<p>Optional: X-User-ID (defaults to 'default_user')</p>
|
| 165 |
+
|
| 166 |
+
<h2>Error Responses</h2>
|
| 167 |
+
<pre>
|
| 168 |
+
{
|
| 169 |
+
"success": false,
|
| 170 |
+
"error": "Error message"
|
| 171 |
+
}
|
| 172 |
+
</pre>
|
| 173 |
+
</body>
|
| 174 |
+
</html>
|
| 175 |
+
"""
|
| 176 |
+
return docs_text, 200, {'Content-Type': 'text/html'}
|
| 177 |
+
|
| 178 |
+
logger.info("DocVault application initialized")
|
| 179 |
+
return app
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == '__main__':
|
| 183 |
+
app = create_app()
|
| 184 |
+
logger.info(f"Starting DocVault on http://localhost:5000 (DEBUG: {DEBUG})")
|
| 185 |
+
app.run(debug=DEBUG, host='0.0.0.0', port=5000)
|
server/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration settings for DocVault"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from datetime import timedelta
|
| 5 |
+
|
| 6 |
+
# Base configuration
|
| 7 |
+
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
| 8 |
+
PROJECT_ROOT = os.path.dirname(BASE_DIR)
|
| 9 |
+
|
| 10 |
+
# Storage configuration
|
| 11 |
+
DATA_DIR = os.path.join(PROJECT_ROOT, "data")
|
| 12 |
+
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
|
| 13 |
+
|
| 14 |
+
# Ensure directories exist
|
| 15 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 16 |
+
os.makedirs(LOG_DIR, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
# Flask configuration
|
| 19 |
+
DEBUG = True
|
| 20 |
+
SECRET_KEY = os.getenv('SECRET_KEY', 'default-insecure-key-change-in-production')
|
| 21 |
+
JSONIFY_PRETTYPRINT_REGULAR = True
|
| 22 |
+
|
| 23 |
+
# API configuration
|
| 24 |
+
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB max file size
|
| 25 |
+
ALLOWED_EXTENSIONS = {
|
| 26 |
+
'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'doc', 'docx',
|
| 27 |
+
'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', 'json', 'xml',
|
| 28 |
+
'csv', 'md', 'py', 'js', 'html', 'css', 'yml', 'yaml'
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# Security
|
| 32 |
+
# - Only alphanumeric, hyphens, underscores for folder/file names
|
| 33 |
+
VALID_FILENAME_CHARS = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.')
|
| 34 |
+
|
| 35 |
+
# Default user ID for offline use
|
| 36 |
+
DEFAULT_USER_ID = "default_user"
|
| 37 |
+
|
| 38 |
+
# Marker file for identifying folders in HF integration
|
| 39 |
+
FOLDER_MARKER = ".gitkeep"
|
| 40 |
+
|
| 41 |
+
# Logging configuration
|
| 42 |
+
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 43 |
+
LOG_LEVEL = "INFO"
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==2.3.3
|
| 2 |
+
Flask-CORS==4.0.0
|
| 3 |
+
Werkzeug==2.3.7
|
| 4 |
+
python-dotenv==1.0.0
|
| 5 |
+
pytest==8.4.1
|
server/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""DocVault server routes package"""
|
server/routes/api.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API routes for DocVault"""
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, request, jsonify
|
| 4 |
+
from werkzeug.utils import secure_filename
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from ..storage.manager import StorageManager
|
| 8 |
+
from ..utils.validators import PathValidator
|
| 9 |
+
from ..utils.logger import setup_logger
|
| 10 |
+
from ..config import DEFAULT_USER_ID, ALLOWED_EXTENSIONS
|
| 11 |
+
except ImportError:
|
| 12 |
+
from storage.manager import StorageManager
|
| 13 |
+
from utils.validators import PathValidator
|
| 14 |
+
from utils.logger import setup_logger
|
| 15 |
+
from config import DEFAULT_USER_ID, ALLOWED_EXTENSIONS
|
| 16 |
+
|
| 17 |
+
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
| 18 |
+
logger = setup_logger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_user_id_from_request():
|
| 22 |
+
"""Extract user_id from request headers or use default"""
|
| 23 |
+
return request.headers.get('X-User-ID', DEFAULT_USER_ID)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def allowed_file(filename):
|
| 27 |
+
"""Check if file extension is allowed"""
|
| 28 |
+
if '.' not in filename:
|
| 29 |
+
return False
|
| 30 |
+
ext = filename.rsplit('.', 1)[1].lower()
|
| 31 |
+
return ext in ALLOWED_EXTENSIONS
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@api_bp.route('/health', methods=['GET'])
|
| 35 |
+
def health_check():
|
| 36 |
+
"""Health check endpoint"""
|
| 37 |
+
return jsonify({"status": "healthy", "service": "DocVault"}), 200
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@api_bp.route('/create-folder', methods=['POST'])
|
| 41 |
+
def create_folder():
|
| 42 |
+
"""Create a new folder"""
|
| 43 |
+
try:
|
| 44 |
+
user_id = get_user_id_from_request()
|
| 45 |
+
data = request.get_json() or {}
|
| 46 |
+
|
| 47 |
+
folder_path = data.get('folder_path', '').strip()
|
| 48 |
+
|
| 49 |
+
if not folder_path:
|
| 50 |
+
return jsonify({"success": False, "error": "folder_path is required"}), 400
|
| 51 |
+
|
| 52 |
+
result = StorageManager.create_folder(user_id, folder_path)
|
| 53 |
+
status_code = 201 if result['success'] else 400
|
| 54 |
+
|
| 55 |
+
return jsonify(result), status_code
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Error in create_folder: {str(e)}")
|
| 59 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@api_bp.route('/delete-folder', methods=['POST'])
|
| 63 |
+
def delete_folder():
|
| 64 |
+
"""Delete a folder"""
|
| 65 |
+
try:
|
| 66 |
+
user_id = get_user_id_from_request()
|
| 67 |
+
data = request.get_json() or {}
|
| 68 |
+
|
| 69 |
+
folder_path = data.get('folder_path', '').strip()
|
| 70 |
+
force = data.get('force', False)
|
| 71 |
+
|
| 72 |
+
if not folder_path:
|
| 73 |
+
return jsonify({"success": False, "error": "folder_path is required"}), 400
|
| 74 |
+
|
| 75 |
+
result = StorageManager.delete_folder(user_id, folder_path, force=force)
|
| 76 |
+
status_code = 200 if result['success'] else 400
|
| 77 |
+
|
| 78 |
+
return jsonify(result), status_code
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error in delete_folder: {str(e)}")
|
| 82 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@api_bp.route('/delete-file', methods=['POST'])
|
| 86 |
+
def delete_file():
|
| 87 |
+
"""Delete a file"""
|
| 88 |
+
try:
|
| 89 |
+
user_id = get_user_id_from_request()
|
| 90 |
+
data = request.get_json() or {}
|
| 91 |
+
|
| 92 |
+
file_path = data.get('file_path', '').strip()
|
| 93 |
+
|
| 94 |
+
if not file_path:
|
| 95 |
+
return jsonify({"success": False, "error": "file_path is required"}), 400
|
| 96 |
+
|
| 97 |
+
result = StorageManager.delete_file(user_id, file_path)
|
| 98 |
+
status_code = 200 if result['success'] else 400
|
| 99 |
+
|
| 100 |
+
return jsonify(result), status_code
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Error in delete_file: {str(e)}")
|
| 104 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@api_bp.route('/upload-file', methods=['POST'])
|
| 108 |
+
def upload_file():
|
| 109 |
+
"""Upload a file to a folder"""
|
| 110 |
+
try:
|
| 111 |
+
user_id = get_user_id_from_request()
|
| 112 |
+
folder_path = request.form.get('folder_path', '').strip()
|
| 113 |
+
|
| 114 |
+
if not folder_path:
|
| 115 |
+
return jsonify({"success": False, "error": "folder_path is required"}), 400
|
| 116 |
+
|
| 117 |
+
if 'file' not in request.files:
|
| 118 |
+
return jsonify({"success": False, "error": "No file provided"}), 400
|
| 119 |
+
|
| 120 |
+
file = request.files['file']
|
| 121 |
+
|
| 122 |
+
if file.filename == '':
|
| 123 |
+
return jsonify({"success": False, "error": "No file selected"}), 400
|
| 124 |
+
|
| 125 |
+
if not allowed_file(file.filename):
|
| 126 |
+
return jsonify({
|
| 127 |
+
"success": False,
|
| 128 |
+
"error": f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
|
| 129 |
+
}), 400
|
| 130 |
+
|
| 131 |
+
filename = secure_filename(file.filename)
|
| 132 |
+
result = StorageManager.upload_file(user_id, folder_path, filename, file)
|
| 133 |
+
status_code = 201 if result['success'] else 400
|
| 134 |
+
|
| 135 |
+
return jsonify(result), status_code
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"Error in upload_file: {str(e)}")
|
| 139 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@api_bp.route('/list', methods=['GET'])
|
| 143 |
+
def list_contents():
|
| 144 |
+
"""List files and folders in a directory"""
|
| 145 |
+
try:
|
| 146 |
+
user_id = get_user_id_from_request()
|
| 147 |
+
# Accept both 'folder_path' (new) and 'path' (old) parameters for compatibility
|
| 148 |
+
folder_path = request.args.get('folder_path', request.args.get('path', '')).strip()
|
| 149 |
+
|
| 150 |
+
result = StorageManager.list_contents(user_id, folder_path)
|
| 151 |
+
status_code = 200 if result['success'] else 400
|
| 152 |
+
|
| 153 |
+
return jsonify(result), status_code
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"Error in list_contents: {str(e)}")
|
| 157 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@api_bp.route('/rename', methods=['POST'])
|
| 161 |
+
def rename_item():
|
| 162 |
+
"""Rename a file or folder"""
|
| 163 |
+
try:
|
| 164 |
+
user_id = get_user_id_from_request()
|
| 165 |
+
data = request.get_json() or {}
|
| 166 |
+
|
| 167 |
+
item_path = data.get('item_path', '').strip()
|
| 168 |
+
new_name = data.get('new_name', '').strip()
|
| 169 |
+
|
| 170 |
+
if not item_path or not new_name:
|
| 171 |
+
return jsonify({"success": False, "error": "item_path and new_name are required"}), 400
|
| 172 |
+
|
| 173 |
+
result = StorageManager.rename_item(user_id, item_path, new_name)
|
| 174 |
+
status_code = 200 if result['success'] else 400
|
| 175 |
+
|
| 176 |
+
return jsonify(result), status_code
|
| 177 |
+
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error in rename_item: {str(e)}")
|
| 180 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@api_bp.route('/storage-stats', methods=['GET'])
|
| 184 |
+
def storage_stats():
|
| 185 |
+
"""Get storage statistics"""
|
| 186 |
+
try:
|
| 187 |
+
user_id = get_user_id_from_request()
|
| 188 |
+
result = StorageManager.get_storage_stats(user_id)
|
| 189 |
+
|
| 190 |
+
return jsonify(result), 200
|
| 191 |
+
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.error(f"Error in storage_stats: {str(e)}")
|
| 194 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@api_bp.route('/download/<path:file_path>', methods=['GET'])
|
| 198 |
+
def download_file(file_path):
|
| 199 |
+
"""Download a file"""
|
| 200 |
+
try:
|
| 201 |
+
from flask import send_file
|
| 202 |
+
import os
|
| 203 |
+
|
| 204 |
+
user_id = get_user_id_from_request()
|
| 205 |
+
|
| 206 |
+
if not PathValidator.is_valid_path(user_id, file_path):
|
| 207 |
+
return jsonify({"success": False, "error": "Invalid file path"}), 400
|
| 208 |
+
|
| 209 |
+
safe_path = PathValidator.get_safe_path(user_id, file_path)
|
| 210 |
+
|
| 211 |
+
if not os.path.exists(safe_path) or not os.path.isfile(safe_path):
|
| 212 |
+
return jsonify({"success": False, "error": "File not found"}), 404
|
| 213 |
+
|
| 214 |
+
return send_file(safe_path, as_attachment=True)
|
| 215 |
+
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Error in download_file: {str(e)}")
|
| 218 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@api_bp.errorhandler(404)
|
| 222 |
+
def not_found(error):
|
| 223 |
+
"""Handle 404 errors"""
|
| 224 |
+
return jsonify({"success": False, "error": "Endpoint not found"}), 404
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@api_bp.errorhandler(500)
|
| 228 |
+
def internal_error(error):
|
| 229 |
+
"""Handle 500 errors"""
|
| 230 |
+
logger.error(f"Internal server error: {str(error)}")
|
| 231 |
+
return jsonify({"success": False, "error": "Internal server error"}), 500
|
server/storage/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""DocVault server storage package"""
|
server/storage/manager.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Storage management module for DocVault"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from ..utils.validators import PathValidator, sanitize_filename, get_file_size, format_file_size
|
| 9 |
+
from ..utils.logger import setup_logger
|
| 10 |
+
from .. import config
|
| 11 |
+
except ImportError:
|
| 12 |
+
from utils.validators import PathValidator, sanitize_filename, get_file_size, format_file_size
|
| 13 |
+
from utils.logger import setup_logger
|
| 14 |
+
import config
|
| 15 |
+
|
| 16 |
+
logger = setup_logger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class StorageManager:
|
| 20 |
+
"""Manages file and folder operations in local storage"""
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def delete_file(user_id: str, file_path: str) -> dict:
|
| 24 |
+
"""Delete a file from local storage."""
|
| 25 |
+
try:
|
| 26 |
+
file_path = PathValidator._normalize_relative_path(file_path)
|
| 27 |
+
if not PathValidator.is_valid_path(user_id, file_path):
|
| 28 |
+
return {"success": False, "error": "Invalid file path"}
|
| 29 |
+
|
| 30 |
+
safe_path = PathValidator.get_safe_path(user_id, file_path)
|
| 31 |
+
|
| 32 |
+
if not os.path.exists(safe_path):
|
| 33 |
+
return {
|
| 34 |
+
"success": False,
|
| 35 |
+
"error": f"File not found: {file_path}",
|
| 36 |
+
"code": "FILE_NOT_FOUND"
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if not os.path.isfile(safe_path):
|
| 40 |
+
return {"success": False, "error": "Path is not a file"}
|
| 41 |
+
|
| 42 |
+
os.remove(safe_path)
|
| 43 |
+
logger.info(f"Deleted file: {user_id}/{file_path}")
|
| 44 |
+
return {
|
| 45 |
+
"success": True,
|
| 46 |
+
"message": f"File deleted: {file_path}"
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"Error deleting file {file_path}: {str(e)}")
|
| 51 |
+
return {"success": False, "error": str(e)}
|
| 52 |
+
|
| 53 |
+
@staticmethod
|
| 54 |
+
def create_folder(user_id: str, folder_path: str) -> dict:
|
| 55 |
+
"""
|
| 56 |
+
Create a folder (including nested folders).
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
user_id: User identifier
|
| 60 |
+
folder_path: Folder path (can be nested like "Documents/Projects")
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
dict with status and folder info
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
folder_path = PathValidator._normalize_relative_path(folder_path)
|
| 67 |
+
# Validate path
|
| 68 |
+
if not PathValidator.is_valid_path(user_id, folder_path):
|
| 69 |
+
return {"success": False, "error": "Invalid folder path"}
|
| 70 |
+
|
| 71 |
+
# Validate each part of the path
|
| 72 |
+
parts = [p for p in folder_path.split('/') if p]
|
| 73 |
+
if not PathValidator.validate_folder_structure(parts):
|
| 74 |
+
return {"success": False, "error": "Invalid folder name characters"}
|
| 75 |
+
|
| 76 |
+
# Get safe absolute path
|
| 77 |
+
safe_path = PathValidator.get_safe_path(user_id, folder_path)
|
| 78 |
+
|
| 79 |
+
# Check if already exists
|
| 80 |
+
if os.path.exists(safe_path):
|
| 81 |
+
return {
|
| 82 |
+
"success": False,
|
| 83 |
+
"error": f"Folder already exists: {folder_path}",
|
| 84 |
+
"code": "FOLDER_EXISTS"
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
# Create folder
|
| 88 |
+
os.makedirs(safe_path, exist_ok=True)
|
| 89 |
+
|
| 90 |
+
# Create marker file for HF integration compatibility
|
| 91 |
+
marker_path = os.path.join(safe_path, config.FOLDER_MARKER)
|
| 92 |
+
if not os.path.exists(marker_path):
|
| 93 |
+
with open(marker_path, 'a', encoding='utf-8'):
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
logger.info(f"Created folder: {user_id}/{folder_path}")
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"success": True,
|
| 100 |
+
"message": f"Folder created: {folder_path}",
|
| 101 |
+
"folder": {
|
| 102 |
+
"name": os.path.basename(safe_path),
|
| 103 |
+
"path": folder_path,
|
| 104 |
+
"created_at": datetime.now().isoformat(),
|
| 105 |
+
"type": "folder"
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error(f"Error creating folder {folder_path}: {str(e)}")
|
| 111 |
+
return {"success": False, "error": str(e)}
|
| 112 |
+
|
| 113 |
+
@staticmethod
|
| 114 |
+
def delete_folder(user_id: str, folder_path: str, force: bool = False) -> dict:
|
| 115 |
+
"""
|
| 116 |
+
Delete a folder (including non-empty folders if force=True).
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
user_id: User identifier
|
| 120 |
+
folder_path: Folder path to delete
|
| 121 |
+
force: If True, delete non-empty folders
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
dict with status
|
| 125 |
+
"""
|
| 126 |
+
try:
|
| 127 |
+
folder_path = PathValidator._normalize_relative_path(folder_path)
|
| 128 |
+
# Validate path
|
| 129 |
+
if not PathValidator.is_valid_path(user_id, folder_path):
|
| 130 |
+
return {"success": False, "error": "Invalid folder path"}
|
| 131 |
+
|
| 132 |
+
safe_path = PathValidator.get_safe_path(user_id, folder_path)
|
| 133 |
+
|
| 134 |
+
# Check if exists
|
| 135 |
+
if not os.path.exists(safe_path):
|
| 136 |
+
return {
|
| 137 |
+
"success": False,
|
| 138 |
+
"error": f"Folder not found: {folder_path}",
|
| 139 |
+
"code": "FOLDER_NOT_FOUND"
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# Check if it's a folder
|
| 143 |
+
if not os.path.isdir(safe_path):
|
| 144 |
+
return {"success": False, "error": "Path is not a folder"}
|
| 145 |
+
|
| 146 |
+
# Check if empty
|
| 147 |
+
contents = os.listdir(safe_path)
|
| 148 |
+
# Filter out marker file
|
| 149 |
+
contents = [f for f in contents if f != config.FOLDER_MARKER]
|
| 150 |
+
|
| 151 |
+
if contents and not force:
|
| 152 |
+
return {
|
| 153 |
+
"success": False,
|
| 154 |
+
"error": "Folder is not empty. Use force=true to delete",
|
| 155 |
+
"code": "FOLDER_NOT_EMPTY",
|
| 156 |
+
"item_count": len(contents)
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
# Delete folder
|
| 160 |
+
if force or not contents:
|
| 161 |
+
shutil.rmtree(safe_path)
|
| 162 |
+
logger.info(f"Deleted folder: {user_id}/{folder_path}")
|
| 163 |
+
return {
|
| 164 |
+
"success": True,
|
| 165 |
+
"message": f"Folder deleted: {folder_path}"
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Error deleting folder {folder_path}: {str(e)}")
|
| 170 |
+
return {"success": False, "error": str(e)}
|
| 171 |
+
|
| 172 |
+
@staticmethod
|
| 173 |
+
def upload_file(user_id: str, folder_path: str, filename: str, file_obj) -> dict:
|
| 174 |
+
"""
|
| 175 |
+
Upload and save file to specific folder.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
user_id: User identifier
|
| 179 |
+
folder_path: Destination folder path
|
| 180 |
+
filename: Original filename
|
| 181 |
+
file_obj: File object to save
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
dict with status and file info
|
| 185 |
+
"""
|
| 186 |
+
try:
|
| 187 |
+
folder_path = PathValidator._normalize_relative_path(folder_path)
|
| 188 |
+
# Sanitize filename
|
| 189 |
+
safe_filename = sanitize_filename(filename)
|
| 190 |
+
if not PathValidator.is_valid_filename(safe_filename):
|
| 191 |
+
return {"success": False, "error": "Invalid filename"}
|
| 192 |
+
|
| 193 |
+
# Validate folder path
|
| 194 |
+
if not PathValidator.is_valid_path(user_id, folder_path):
|
| 195 |
+
return {"success": False, "error": "Invalid folder path"}
|
| 196 |
+
|
| 197 |
+
safe_folder_path = PathValidator.get_safe_path(user_id, folder_path)
|
| 198 |
+
|
| 199 |
+
# Create folder if it doesn't exist
|
| 200 |
+
if not os.path.exists(safe_folder_path):
|
| 201 |
+
os.makedirs(safe_folder_path, exist_ok=True)
|
| 202 |
+
# Create marker file
|
| 203 |
+
marker_path = os.path.join(safe_folder_path, config.FOLDER_MARKER)
|
| 204 |
+
with open(marker_path, 'a', encoding='utf-8'):
|
| 205 |
+
pass
|
| 206 |
+
|
| 207 |
+
# Full file path
|
| 208 |
+
file_path = os.path.join(safe_folder_path, safe_filename)
|
| 209 |
+
|
| 210 |
+
# Handle duplicate filenames
|
| 211 |
+
if os.path.exists(file_path):
|
| 212 |
+
name, ext = os.path.splitext(safe_filename)
|
| 213 |
+
counter = 1
|
| 214 |
+
while os.path.exists(file_path):
|
| 215 |
+
safe_filename = f"{name}_{counter}{ext}"
|
| 216 |
+
file_path = os.path.join(safe_folder_path, safe_filename)
|
| 217 |
+
counter += 1
|
| 218 |
+
|
| 219 |
+
# Save file
|
| 220 |
+
file_obj.seek(0)
|
| 221 |
+
file_obj.save(file_path)
|
| 222 |
+
|
| 223 |
+
file_size = get_file_size(file_path)
|
| 224 |
+
logger.info(f"Uploaded file: {user_id}/{folder_path}/{safe_filename} ({format_file_size(file_size)})")
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"success": True,
|
| 228 |
+
"message": f"File uploaded: {safe_filename}",
|
| 229 |
+
"file": {
|
| 230 |
+
"name": safe_filename,
|
| 231 |
+
"path": f"{folder_path}/{safe_filename}",
|
| 232 |
+
"size": file_size,
|
| 233 |
+
"size_formatted": format_file_size(file_size),
|
| 234 |
+
"uploaded_at": datetime.now().isoformat(),
|
| 235 |
+
"type": "file"
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Error uploading file to {folder_path}: {str(e)}")
|
| 241 |
+
return {"success": False, "error": str(e)}
|
| 242 |
+
|
| 243 |
+
@staticmethod
|
| 244 |
+
def list_contents(user_id: str, folder_path: str = "") -> dict:
|
| 245 |
+
"""
|
| 246 |
+
List all files and folders in a directory.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
user_id: User identifier
|
| 250 |
+
folder_path: Folder to list (empty for root)
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
dict with files and folders
|
| 254 |
+
"""
|
| 255 |
+
try:
|
| 256 |
+
folder_path = PathValidator._normalize_relative_path(folder_path)
|
| 257 |
+
# Validate path
|
| 258 |
+
if not PathValidator.is_valid_path(user_id, folder_path):
|
| 259 |
+
return {"success": False, "error": "Invalid folder path"}
|
| 260 |
+
|
| 261 |
+
safe_path = PathValidator.get_safe_path(user_id, folder_path)
|
| 262 |
+
|
| 263 |
+
# Check if exists
|
| 264 |
+
if not os.path.exists(safe_path):
|
| 265 |
+
logger.warning(f"Attempted to list non-existent path: {user_id}/{folder_path}")
|
| 266 |
+
return {
|
| 267 |
+
"success": True,
|
| 268 |
+
"path": folder_path or "/",
|
| 269 |
+
"folders": [],
|
| 270 |
+
"files": [],
|
| 271 |
+
"total_folders": 0,
|
| 272 |
+
"total_files": 0,
|
| 273 |
+
"summary": {
|
| 274 |
+
"total_folders": 0,
|
| 275 |
+
"total_files": 0
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
folders = []
|
| 280 |
+
files = []
|
| 281 |
+
|
| 282 |
+
# List contents
|
| 283 |
+
for item in os.listdir(safe_path):
|
| 284 |
+
# Skip marker files
|
| 285 |
+
if item == config.FOLDER_MARKER:
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
item_path = os.path.join(safe_path, item)
|
| 289 |
+
item_stat = os.stat(item_path)
|
| 290 |
+
|
| 291 |
+
if os.path.isdir(item_path):
|
| 292 |
+
folders.append({
|
| 293 |
+
"name": item,
|
| 294 |
+
"type": "folder",
|
| 295 |
+
"path": f"{folder_path}/{item}" if folder_path else item,
|
| 296 |
+
"created_at": datetime.fromtimestamp(item_stat.st_ctime).isoformat(),
|
| 297 |
+
"modified_at": datetime.fromtimestamp(item_stat.st_mtime).isoformat()
|
| 298 |
+
})
|
| 299 |
+
else:
|
| 300 |
+
files.append({
|
| 301 |
+
"name": item,
|
| 302 |
+
"type": "file",
|
| 303 |
+
"path": f"{folder_path}/{item}" if folder_path else item,
|
| 304 |
+
"size": item_stat.st_size,
|
| 305 |
+
"size_formatted": format_file_size(item_stat.st_size),
|
| 306 |
+
"created_at": datetime.fromtimestamp(item_stat.st_ctime).isoformat(),
|
| 307 |
+
"modified_at": datetime.fromtimestamp(item_stat.st_mtime).isoformat()
|
| 308 |
+
})
|
| 309 |
+
|
| 310 |
+
# Sort by name
|
| 311 |
+
folders.sort(key=lambda x: x['name'].lower())
|
| 312 |
+
files.sort(key=lambda x: x['name'].lower())
|
| 313 |
+
|
| 314 |
+
logger.info(f"Listed contents: {user_id}/{folder_path} ({len(folders)} folders, {len(files)} files)")
|
| 315 |
+
|
| 316 |
+
return {
|
| 317 |
+
"success": True,
|
| 318 |
+
"path": folder_path or "/",
|
| 319 |
+
"folders": folders,
|
| 320 |
+
"files": files,
|
| 321 |
+
"total_folders": len(folders),
|
| 322 |
+
"total_files": len(files),
|
| 323 |
+
"summary": {
|
| 324 |
+
"total_folders": len(folders),
|
| 325 |
+
"total_files": len(files)
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
except Exception as e:
|
| 330 |
+
logger.error(f"Error listing contents of {folder_path}: {str(e)}")
|
| 331 |
+
return {"success": False, "error": str(e)}
|
| 332 |
+
|
| 333 |
+
@staticmethod
|
| 334 |
+
def rename_item(user_id: str, item_path: str, new_name: str) -> dict:
|
| 335 |
+
"""
|
| 336 |
+
Rename a file or folder.
|
| 337 |
+
|
| 338 |
+
Args:
|
| 339 |
+
user_id: User identifier
|
| 340 |
+
item_path: Current path to file/folder
|
| 341 |
+
new_name: New name
|
| 342 |
+
|
| 343 |
+
Returns:
|
| 344 |
+
dict with status
|
| 345 |
+
"""
|
| 346 |
+
try:
|
| 347 |
+
item_path = PathValidator._normalize_relative_path(item_path)
|
| 348 |
+
# Validate paths
|
| 349 |
+
if not PathValidator.is_valid_path(user_id, item_path):
|
| 350 |
+
return {"success": False, "error": "Invalid item path"}
|
| 351 |
+
|
| 352 |
+
if not PathValidator.is_valid_filename(new_name):
|
| 353 |
+
return {"success": False, "error": "Invalid new name"}
|
| 354 |
+
|
| 355 |
+
# Get parent directory
|
| 356 |
+
parent_path = os.path.dirname(item_path) if item_path else ""
|
| 357 |
+
old_full_path = PathValidator.get_safe_path(user_id, item_path)
|
| 358 |
+
new_full_path = os.path.join(
|
| 359 |
+
PathValidator.get_safe_path(user_id, parent_path),
|
| 360 |
+
new_name
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
# Check if exists
|
| 364 |
+
if not os.path.exists(old_full_path):
|
| 365 |
+
return {"success": False, "error": "Item not found"}
|
| 366 |
+
|
| 367 |
+
# Check if new name already exists
|
| 368 |
+
if os.path.exists(new_full_path):
|
| 369 |
+
return {"success": False, "error": "Name already exists"}
|
| 370 |
+
|
| 371 |
+
# Rename
|
| 372 |
+
os.rename(old_full_path, new_full_path)
|
| 373 |
+
|
| 374 |
+
item_type = "folder" if os.path.isdir(new_full_path) else "file"
|
| 375 |
+
logger.info(f"Renamed {item_type}: {user_id}/{item_path} -> {new_name}")
|
| 376 |
+
|
| 377 |
+
return {
|
| 378 |
+
"success": True,
|
| 379 |
+
"message": f"{item_type.capitalize()} renamed to: {new_name}",
|
| 380 |
+
"item": {
|
| 381 |
+
"name": new_name,
|
| 382 |
+
"type": item_type,
|
| 383 |
+
"path": f"{parent_path}/{new_name}" if parent_path else new_name
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
except Exception as e:
|
| 388 |
+
logger.error(f"Error renaming item {item_path}: {str(e)}")
|
| 389 |
+
return {"success": False, "error": str(e)}
|
| 390 |
+
|
| 391 |
+
@staticmethod
|
| 392 |
+
def get_storage_stats(user_id: str) -> dict:
|
| 393 |
+
"""Get storage statistics for a user"""
|
| 394 |
+
try:
|
| 395 |
+
user_dir = PathValidator.get_safe_path(user_id, "")
|
| 396 |
+
|
| 397 |
+
if not os.path.exists(user_dir):
|
| 398 |
+
return {
|
| 399 |
+
"success": True,
|
| 400 |
+
"total_size": 0,
|
| 401 |
+
"total_files": 0,
|
| 402 |
+
"total_folders": 0
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
total_size = 0
|
| 406 |
+
total_files = 0
|
| 407 |
+
total_folders = 0
|
| 408 |
+
|
| 409 |
+
for dirpath, dirnames, filenames in os.walk(user_dir):
|
| 410 |
+
total_folders += len(dirnames)
|
| 411 |
+
for filename in filenames:
|
| 412 |
+
if filename != config.FOLDER_MARKER:
|
| 413 |
+
file_path = os.path.join(dirpath, filename)
|
| 414 |
+
total_size += get_file_size(file_path)
|
| 415 |
+
total_files += 1
|
| 416 |
+
|
| 417 |
+
return {
|
| 418 |
+
"success": True,
|
| 419 |
+
"total_size": total_size,
|
| 420 |
+
"total_size_formatted": format_file_size(total_size),
|
| 421 |
+
"total_files": total_files,
|
| 422 |
+
"total_folders": total_folders
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
except Exception as e:
|
| 426 |
+
logger.error(f"Error getting storage stats: {str(e)}")
|
| 427 |
+
return {"success": False, "error": str(e)}
|
server/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""DocVault server utilities package"""
|
server/utils/logger.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logger setup for DocVault"""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import logging.handlers
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from ..config import LOG_DIR, LOG_FORMAT, LOG_LEVEL
|
| 9 |
+
except ImportError:
|
| 10 |
+
from config import LOG_DIR, LOG_FORMAT, LOG_LEVEL
|
| 11 |
+
|
| 12 |
+
def setup_logger(name: str) -> logging.Logger:
|
| 13 |
+
"""
|
| 14 |
+
Setup logger with file and console handlers.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
name: Logger name (usually __name__)
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
Configured logger instance
|
| 21 |
+
"""
|
| 22 |
+
logger = logging.getLogger(name)
|
| 23 |
+
logger.setLevel(getattr(logging, LOG_LEVEL))
|
| 24 |
+
|
| 25 |
+
# Only add handlers if not already present
|
| 26 |
+
if logger.hasHandlers():
|
| 27 |
+
return logger
|
| 28 |
+
|
| 29 |
+
# Create formatter
|
| 30 |
+
formatter = logging.Formatter(LOG_FORMAT)
|
| 31 |
+
|
| 32 |
+
# Console handler
|
| 33 |
+
console_handler = logging.StreamHandler()
|
| 34 |
+
console_handler.setLevel(logging.DEBUG)
|
| 35 |
+
console_handler.setFormatter(formatter)
|
| 36 |
+
logger.addHandler(console_handler)
|
| 37 |
+
|
| 38 |
+
# File handler with rotation
|
| 39 |
+
log_file = os.path.join(LOG_DIR, f"{name}.log")
|
| 40 |
+
file_handler = logging.handlers.RotatingFileHandler(
|
| 41 |
+
log_file,
|
| 42 |
+
maxBytes=10 * 1024 * 1024, # 10MB
|
| 43 |
+
backupCount=5
|
| 44 |
+
)
|
| 45 |
+
file_handler.setLevel(logging.DEBUG)
|
| 46 |
+
file_handler.setFormatter(formatter)
|
| 47 |
+
logger.addHandler(file_handler)
|
| 48 |
+
|
| 49 |
+
return logger
|
server/utils/validators.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions for path validation and security"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from .. import config
|
| 8 |
+
except ImportError:
|
| 9 |
+
import config
|
| 10 |
+
|
| 11 |
+
class PathValidator:
|
| 12 |
+
"""Validates and sanitizes file paths to prevent vulnerabilities"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def _normalize_relative_path(path: str) -> str:
|
| 16 |
+
"""Normalize relative path separators and remove duplicate separators"""
|
| 17 |
+
if not isinstance(path, str):
|
| 18 |
+
return ''
|
| 19 |
+
return re.sub(r'[\\/]+', '/', path).strip('/ ')
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def is_valid_filename(filename: str) -> bool:
|
| 23 |
+
"""
|
| 24 |
+
Check if filename contains only allowed characters.
|
| 25 |
+
Prevents path traversal attacks.
|
| 26 |
+
"""
|
| 27 |
+
if not filename or len(filename) > 255:
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
# Check for path traversal attempts
|
| 31 |
+
if '..' in filename or filename.startswith('/') or filename.startswith('\\'):
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
# Check for reserved names (Windows)
|
| 35 |
+
reserved = {'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'LPT1', 'LPT2'}
|
| 36 |
+
if filename.upper() in reserved:
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
# Check characters
|
| 40 |
+
return all(c in config.VALID_FILENAME_CHARS for c in filename)
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def is_valid_path(user_id: str, path: str) -> bool:
|
| 44 |
+
"""
|
| 45 |
+
Validate that path is safe and within user directory.
|
| 46 |
+
Prevents accessing files outside user's data directory.
|
| 47 |
+
"""
|
| 48 |
+
if not user_id or not isinstance(user_id, str):
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
normalized_path = PathValidator._normalize_relative_path(path or '')
|
| 52 |
+
|
| 53 |
+
user_dir = os.path.normpath(os.path.join(config.DATA_DIR, user_id))
|
| 54 |
+
requested_path = os.path.normpath(os.path.join(user_dir, normalized_path))
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
return os.path.commonpath([requested_path, user_dir]) == user_dir
|
| 58 |
+
except ValueError:
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
@staticmethod
|
| 62 |
+
def get_safe_path(user_id: str, relative_path: str = "") -> str:
|
| 63 |
+
"""Get a validated absolute path within user's directory"""
|
| 64 |
+
normalized_path = PathValidator._normalize_relative_path(relative_path)
|
| 65 |
+
if not PathValidator.is_valid_path(user_id, normalized_path):
|
| 66 |
+
raise ValueError(f"Invalid path: {relative_path}")
|
| 67 |
+
|
| 68 |
+
user_dir = os.path.join(config.DATA_DIR, user_id)
|
| 69 |
+
if normalized_path:
|
| 70 |
+
return os.path.normpath(os.path.join(user_dir, normalized_path))
|
| 71 |
+
return os.path.normpath(user_dir)
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def validate_folder_structure(path_parts: list) -> bool:
|
| 75 |
+
"""Validate each part of a folder path"""
|
| 76 |
+
return all(PathValidator.is_valid_filename(part) for part in path_parts if part)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def sanitize_filename(filename: str) -> str:
|
| 80 |
+
"""Sanitize filename by removing or replacing invalid characters"""
|
| 81 |
+
# Remove invalid characters
|
| 82 |
+
sanitized = ''.join(c if c in config.VALID_FILENAME_CHARS else '_' for c in filename)
|
| 83 |
+
# Remove leading/trailing dots and spaces
|
| 84 |
+
sanitized = sanitized.strip('. ')
|
| 85 |
+
return sanitized if sanitized else "file"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_file_size(filepath: str) -> int:
|
| 89 |
+
"""Get file size in bytes"""
|
| 90 |
+
try:
|
| 91 |
+
return os.path.getsize(filepath)
|
| 92 |
+
except (OSError, IOError):
|
| 93 |
+
return 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def format_file_size(size_bytes: int) -> str:
|
| 97 |
+
"""Format bytes to human-readable size"""
|
| 98 |
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
| 99 |
+
if size_bytes < 1024.0:
|
| 100 |
+
return f"{size_bytes:.2f} {unit}"
|
| 101 |
+
size_bytes /= 1024.0
|
| 102 |
+
return f"{size_bytes:.2f} TB"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_file_extension(filename: str) -> str:
|
| 106 |
+
"""Get file extension"""
|
| 107 |
+
return os.path.splitext(filename)[1].lstrip('.').lower()
|
styles.css
ADDED
|
@@ -0,0 +1,1447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--font-family: 'Outfit', sans-serif;
|
| 3 |
+
--bg: #F8FAFC;
|
| 4 |
+
--bg-color: var(--bg);
|
| 5 |
+
--card: #FFFFFF;
|
| 6 |
+
--panel-bg: var(--card);
|
| 7 |
+
--border-color: #E2E8F0;
|
| 8 |
+
--text: #0F172A;
|
| 9 |
+
--text-main: var(--text);
|
| 10 |
+
--muted: #64748B;
|
| 11 |
+
--text-muted: var(--muted);
|
| 12 |
+
--primary: #10B981;
|
| 13 |
+
--primary-color: var(--primary);
|
| 14 |
+
--primary-light: #D1FAE5;
|
| 15 |
+
--primary-hover: #059669;
|
| 16 |
+
--primary-gradient: linear-gradient(90deg, var(--primary-color), #34d399);
|
| 17 |
+
--danger-color: #EF4444;
|
| 18 |
+
--folder-color: #f59e0b;
|
| 19 |
+
--file-color: #F59E0B;
|
| 20 |
+
--hover-bg: #F1F5F9;
|
| 21 |
+
--accent-color: #F59E0B;
|
| 22 |
+
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.03);
|
| 23 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
|
| 24 |
+
--shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
|
| 25 |
+
--shadow-xl: 0 20px 40px -10px rgba(0, 0, 0, 0.15), 0 10px 20px -5px rgba(0, 0, 0, 0.08);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* ── Reset ── */
|
| 29 |
+
* {
|
| 30 |
+
margin: 0;
|
| 31 |
+
padding: 0;
|
| 32 |
+
box-sizing: border-box;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
body {
|
| 36 |
+
font-family: var(--font-family);
|
| 37 |
+
background-color: var(--bg-color);
|
| 38 |
+
color: var(--text-main);
|
| 39 |
+
background-image:
|
| 40 |
+
radial-gradient(circle at 15% 50%, rgba(16, 185, 129, 0.04), transparent 30%),
|
| 41 |
+
radial-gradient(circle at 85% 30%, rgba(59, 130, 246, 0.03), transparent 30%);
|
| 42 |
+
min-height: 100vh;
|
| 43 |
+
overflow: hidden;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.glass-panel {
|
| 47 |
+
background: var(--panel-bg);
|
| 48 |
+
border: 1px solid var(--border-color);
|
| 49 |
+
border-radius: 16px;
|
| 50 |
+
box-shadow: var(--shadow-md);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* ── LAYOUT ── */
|
| 54 |
+
.app-container {
|
| 55 |
+
display: flex;
|
| 56 |
+
height: 100vh;
|
| 57 |
+
width: 100%;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* ── SIDEBAR ── */
|
| 61 |
+
.sidebar {
|
| 62 |
+
width: 270px;
|
| 63 |
+
background: #ffffff;
|
| 64 |
+
border-right: 1px solid var(--border-color);
|
| 65 |
+
display: flex;
|
| 66 |
+
flex-direction: column;
|
| 67 |
+
padding: 24px;
|
| 68 |
+
z-index: 10;
|
| 69 |
+
box-shadow: var(--shadow-sm);
|
| 70 |
+
flex-shrink: 0;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.logo-container {
|
| 74 |
+
display: flex;
|
| 75 |
+
align-items: center;
|
| 76 |
+
gap: 12px;
|
| 77 |
+
margin-bottom: 36px;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.logo-icon {
|
| 81 |
+
font-size: 32px;
|
| 82 |
+
color: var(--primary-color);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.logo-text {
|
| 86 |
+
font-size: 24px;
|
| 87 |
+
font-weight: 700;
|
| 88 |
+
letter-spacing: -0.5px;
|
| 89 |
+
color: var(--text-main);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* ── "+ New" Button Dropdown ── */
|
| 93 |
+
.new-btn-wrapper {
|
| 94 |
+
position: relative;
|
| 95 |
+
margin-bottom: 28px;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.new-btn {
|
| 99 |
+
width: 100%;
|
| 100 |
+
padding: 13px;
|
| 101 |
+
background: var(--primary-color);
|
| 102 |
+
color: white;
|
| 103 |
+
border: none;
|
| 104 |
+
border-radius: 12px;
|
| 105 |
+
font-size: 15px;
|
| 106 |
+
font-weight: 600;
|
| 107 |
+
font-family: inherit;
|
| 108 |
+
display: flex;
|
| 109 |
+
align-items: center;
|
| 110 |
+
justify-content: center;
|
| 111 |
+
gap: 8px;
|
| 112 |
+
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);
|
| 113 |
+
cursor: pointer;
|
| 114 |
+
transition: all 0.2s ease;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.new-btn:hover {
|
| 118 |
+
background: var(--primary-hover);
|
| 119 |
+
transform: translateY(-1px);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.new-dropdown {
|
| 123 |
+
position: absolute;
|
| 124 |
+
top: calc(100% + 6px);
|
| 125 |
+
left: 0;
|
| 126 |
+
right: 0;
|
| 127 |
+
background: #ffffff;
|
| 128 |
+
border: 1px solid var(--border-color);
|
| 129 |
+
border-radius: 12px;
|
| 130 |
+
z-index: 200;
|
| 131 |
+
box-shadow: var(--shadow-lg);
|
| 132 |
+
overflow: hidden;
|
| 133 |
+
opacity: 0;
|
| 134 |
+
pointer-events: none;
|
| 135 |
+
transform: translateY(-8px);
|
| 136 |
+
transition: all 0.2s ease;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.new-dropdown.active {
|
| 140 |
+
opacity: 1;
|
| 141 |
+
pointer-events: auto;
|
| 142 |
+
transform: translateY(0);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.new-dropdown-item {
|
| 146 |
+
width: 100%;
|
| 147 |
+
padding: 13px 16px;
|
| 148 |
+
background: none;
|
| 149 |
+
border: none;
|
| 150 |
+
color: var(--text-main);
|
| 151 |
+
font-family: inherit;
|
| 152 |
+
font-size: 14px;
|
| 153 |
+
display: flex;
|
| 154 |
+
align-items: center;
|
| 155 |
+
gap: 12px;
|
| 156 |
+
cursor: pointer;
|
| 157 |
+
text-align: left;
|
| 158 |
+
transition: background-color 0.15s ease;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.new-dropdown-item:hover {
|
| 162 |
+
background: var(--hover-bg);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.new-dropdown-item i {
|
| 166 |
+
font-size: 18px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.new-dropdown-item .ph-folder-plus {
|
| 170 |
+
color: var(--primary-color);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.new-dropdown-item .ph-upload-simple {
|
| 174 |
+
color: var(--file-color);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* ── SIDEBAR NAV ── */
|
| 178 |
+
.sidebar-nav {
|
| 179 |
+
display: flex;
|
| 180 |
+
flex-direction: column;
|
| 181 |
+
gap: 4px;
|
| 182 |
+
flex-grow: 1;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.nav-item {
|
| 186 |
+
display: flex;
|
| 187 |
+
align-items: center;
|
| 188 |
+
gap: 12px;
|
| 189 |
+
padding: 11px 14px;
|
| 190 |
+
color: var(--text-muted);
|
| 191 |
+
text-decoration: none;
|
| 192 |
+
border-radius: 10px;
|
| 193 |
+
font-weight: 500;
|
| 194 |
+
position: relative;
|
| 195 |
+
font-size: 14px;
|
| 196 |
+
transition: all 0.15s ease;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.nav-item i {
|
| 200 |
+
font-size: 20px;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.nav-item:hover {
|
| 204 |
+
background: var(--hover-bg);
|
| 205 |
+
color: var(--text-main);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.nav-item.active {
|
| 209 |
+
background: var(--primary-light);
|
| 210 |
+
color: var(--primary-color);
|
| 211 |
+
font-weight: 600;
|
| 212 |
+
border-left: 4px solid var(--primary-color);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* ── STORAGE ── */
|
| 216 |
+
.sidebar-bottom {
|
| 217 |
+
margin-top: auto;
|
| 218 |
+
padding-top: 20px;
|
| 219 |
+
border-top: 1px solid var(--border-color);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.storage-dashboard {
|
| 223 |
+
background: #ffffff;
|
| 224 |
+
padding: 16px;
|
| 225 |
+
border-radius: 16px;
|
| 226 |
+
border: 1px solid var(--border-color);
|
| 227 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.storage-header {
|
| 231 |
+
display: flex;
|
| 232 |
+
align-items: center;
|
| 233 |
+
gap: 8px;
|
| 234 |
+
font-weight: 700;
|
| 235 |
+
font-size: 14px;
|
| 236 |
+
margin-bottom: 12px;
|
| 237 |
+
color: var(--text-main);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.storage-progress {
|
| 241 |
+
height: 8px;
|
| 242 |
+
background: #f1f5f9;
|
| 243 |
+
border-radius: 10px;
|
| 244 |
+
overflow: hidden;
|
| 245 |
+
margin-bottom: 10px;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.progress-fill {
|
| 249 |
+
height: 100%;
|
| 250 |
+
background: var(--primary-gradient);
|
| 251 |
+
border-radius: 10px;
|
| 252 |
+
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.storage-text {
|
| 256 |
+
font-size: 12px;
|
| 257 |
+
color: var(--text-muted);
|
| 258 |
+
font-weight: 500;
|
| 259 |
+
display: block;
|
| 260 |
+
text-align: center;
|
| 261 |
+
margin-top: 2px;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.hf-badge {
|
| 265 |
+
display: flex;
|
| 266 |
+
align-items: center;
|
| 267 |
+
justify-content: center;
|
| 268 |
+
gap: 8px;
|
| 269 |
+
margin-top: 14px;
|
| 270 |
+
padding: 9px 14px;
|
| 271 |
+
background: var(--hover-bg);
|
| 272 |
+
border: 1px solid var(--border-color);
|
| 273 |
+
border-radius: 10px;
|
| 274 |
+
font-size: 12px;
|
| 275 |
+
color: var(--text-muted);
|
| 276 |
+
font-weight: 500;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.hf-emoji {
|
| 280 |
+
font-size: 15px;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/* ── TOP HEADER ── */
|
| 284 |
+
.main-content {
|
| 285 |
+
flex-grow: 1;
|
| 286 |
+
display: flex;
|
| 287 |
+
flex-direction: column;
|
| 288 |
+
overflow: hidden;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.top-header {
|
| 292 |
+
height: 72px;
|
| 293 |
+
display: flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
justify-content: space-between;
|
| 296 |
+
padding: 0 40px;
|
| 297 |
+
border-bottom: 1px solid var(--border-color);
|
| 298 |
+
background: #ffffff;
|
| 299 |
+
flex-shrink: 0;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/* ── SEARCH BAR ── */
|
| 303 |
+
.search-bar {
|
| 304 |
+
display: flex;
|
| 305 |
+
align-items: center;
|
| 306 |
+
background: #f5f7fa;
|
| 307 |
+
border: none;
|
| 308 |
+
border-radius: 30px;
|
| 309 |
+
padding: 12px 20px;
|
| 310 |
+
width: 520px;
|
| 311 |
+
transition: all 0.2s ease;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.search-bar:focus-within {
|
| 315 |
+
background: #ffffff;
|
| 316 |
+
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.search-bar i {
|
| 320 |
+
color: var(--text-muted);
|
| 321 |
+
font-size: 18px;
|
| 322 |
+
margin-right: 12px;
|
| 323 |
+
flex-shrink: 0;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.search-bar input {
|
| 327 |
+
background: none;
|
| 328 |
+
border: none;
|
| 329 |
+
color: var(--text-main);
|
| 330 |
+
outline: none;
|
| 331 |
+
width: 100%;
|
| 332 |
+
font-family: inherit;
|
| 333 |
+
font-size: 14px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.search-bar input::placeholder {
|
| 337 |
+
color: var(--text-muted);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/* ── USER ACTIONS ── */
|
| 341 |
+
.user-actions {
|
| 342 |
+
display: flex;
|
| 343 |
+
align-items: center;
|
| 344 |
+
gap: 12px;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.icon-btn {
|
| 348 |
+
background: none;
|
| 349 |
+
border: none;
|
| 350 |
+
color: var(--text-muted);
|
| 351 |
+
font-size: 22px;
|
| 352 |
+
cursor: pointer;
|
| 353 |
+
padding: 8px;
|
| 354 |
+
border-radius: 50%;
|
| 355 |
+
display: flex;
|
| 356 |
+
align-items: center;
|
| 357 |
+
justify-content: center;
|
| 358 |
+
transition: all 0.15s ease;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.icon-btn:hover {
|
| 362 |
+
background: var(--hover-bg);
|
| 363 |
+
color: var(--text-main);
|
| 364 |
+
transform: scale(1.05);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.icon-btn.active {
|
| 368 |
+
color: var(--primary-color);
|
| 369 |
+
background: var(--primary-light);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.avatar {
|
| 373 |
+
width: 38px;
|
| 374 |
+
height: 38px;
|
| 375 |
+
border-radius: 50%;
|
| 376 |
+
background: linear-gradient(135deg, var(--primary-color), #2dd4bf);
|
| 377 |
+
display: flex;
|
| 378 |
+
align-items: center;
|
| 379 |
+
justify-content: center;
|
| 380 |
+
font-weight: 700;
|
| 381 |
+
font-size: 14px;
|
| 382 |
+
cursor: pointer;
|
| 383 |
+
color: white;
|
| 384 |
+
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* ── BREADCRUMBS ── */
|
| 388 |
+
.breadcrumbs {
|
| 389 |
+
padding: 28px 40px 8px;
|
| 390 |
+
display: flex;
|
| 391 |
+
align-items: center;
|
| 392 |
+
gap: 6px;
|
| 393 |
+
font-size: 20px;
|
| 394 |
+
font-weight: 700;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.breadcrumb-item {
|
| 398 |
+
color: var(--text-muted);
|
| 399 |
+
cursor: pointer;
|
| 400 |
+
padding: 4px 8px;
|
| 401 |
+
border-radius: 6px;
|
| 402 |
+
font-size: 18px;
|
| 403 |
+
transition: all 0.15s ease;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.breadcrumb-item:hover {
|
| 407 |
+
background: var(--hover-bg);
|
| 408 |
+
color: var(--primary-color);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.breadcrumb-item.active {
|
| 412 |
+
color: var(--text-main);
|
| 413 |
+
pointer-events: none;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.breadcrumb-separator {
|
| 417 |
+
color: var(--text-muted);
|
| 418 |
+
font-size: 14px;
|
| 419 |
+
opacity: 0.5;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/* ── CONTENT ── */
|
| 423 |
+
.content-area {
|
| 424 |
+
flex-grow: 1;
|
| 425 |
+
padding: 0 40px 40px;
|
| 426 |
+
overflow-y: auto;
|
| 427 |
+
position: relative;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.content-area.drag-over::after {
|
| 431 |
+
content: 'Drop files here to upload';
|
| 432 |
+
position: fixed;
|
| 433 |
+
inset: 0;
|
| 434 |
+
border: 4px dashed var(--primary-color);
|
| 435 |
+
pointer-events: none;
|
| 436 |
+
background: rgba(16, 185, 129, 0.06);
|
| 437 |
+
z-index: 500;
|
| 438 |
+
border-radius: 12px;
|
| 439 |
+
display: flex;
|
| 440 |
+
align-items: center;
|
| 441 |
+
justify-content: center;
|
| 442 |
+
font-size: 22px;
|
| 443 |
+
font-weight: 700;
|
| 444 |
+
color: var(--primary-color);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
/* ── SECTION HEADERS ── */
|
| 448 |
+
.section-header {
|
| 449 |
+
display: flex;
|
| 450 |
+
justify-content: space-between;
|
| 451 |
+
align-items: center;
|
| 452 |
+
margin-bottom: 20px;
|
| 453 |
+
padding-top: 12px;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.section-header h2 {
|
| 457 |
+
font-size: 12px;
|
| 458 |
+
font-weight: 700;
|
| 459 |
+
color: var(--text-muted);
|
| 460 |
+
text-transform: uppercase;
|
| 461 |
+
letter-spacing: 1px;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.view-toggles {
|
| 465 |
+
display: flex;
|
| 466 |
+
gap: 4px;
|
| 467 |
+
background: var(--hover-bg);
|
| 468 |
+
padding: 4px;
|
| 469 |
+
border-radius: 10px;
|
| 470 |
+
border: 1px solid var(--border-color);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.view-toggles .icon-btn {
|
| 474 |
+
border-radius: 7px;
|
| 475 |
+
padding: 5px 9px;
|
| 476 |
+
font-size: 18px;
|
| 477 |
+
color: var(--text-muted);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.view-toggles .icon-btn.active {
|
| 481 |
+
background: #ffffff;
|
| 482 |
+
color: var(--primary-color);
|
| 483 |
+
box-shadow: var(--shadow-sm);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.mt-8 {
|
| 487 |
+
margin-top: 44px;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.grid-container,
|
| 491 |
+
.files-grid {
|
| 492 |
+
display: grid;
|
| 493 |
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
| 494 |
+
gap: 20px;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.list-container {
|
| 498 |
+
display: flex;
|
| 499 |
+
flex-direction: column;
|
| 500 |
+
gap: 8px;
|
| 501 |
+
padding: 8px 0;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/* ── FOLDER CARDS ── */
|
| 505 |
+
.folder-card {
|
| 506 |
+
background: var(--card);
|
| 507 |
+
border: 1px solid var(--border-color);
|
| 508 |
+
border-radius: 16px;
|
| 509 |
+
padding: 24px 20px 20px 20px;
|
| 510 |
+
position: relative;
|
| 511 |
+
cursor: pointer;
|
| 512 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 513 |
+
box-shadow: var(--shadow-sm);
|
| 514 |
+
display: flex;
|
| 515 |
+
flex-direction: column;
|
| 516 |
+
align-items: center;
|
| 517 |
+
gap: 16px;
|
| 518 |
+
min-height: 180px;
|
| 519 |
+
animation: cardFadeIn 0.4s ease-out forwards;
|
| 520 |
+
opacity: 0;
|
| 521 |
+
transform: translateY(20px);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
@keyframes cardFadeIn {
|
| 525 |
+
to {
|
| 526 |
+
opacity: 1;
|
| 527 |
+
transform: translateY(0);
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.folder-card:hover {
|
| 532 |
+
transform: translateY(-4px) scale(1.02);
|
| 533 |
+
box-shadow: var(--shadow-lg);
|
| 534 |
+
border-color: var(--primary-color);
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.folder-card:active {
|
| 538 |
+
transform: translateY(-2px) scale(0.98);
|
| 539 |
+
transition-duration: 0.1s;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.card-top-row {
|
| 543 |
+
width: 100%;
|
| 544 |
+
display: flex;
|
| 545 |
+
justify-content: space-between;
|
| 546 |
+
align-items: flex-start;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.card-menu {
|
| 550 |
+
position: relative;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.folder-icon-container {
|
| 554 |
+
display: flex;
|
| 555 |
+
align-items: center;
|
| 556 |
+
justify-content: center;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.folder-info {
|
| 560 |
+
display: flex;
|
| 561 |
+
flex-direction: column;
|
| 562 |
+
align-items: center;
|
| 563 |
+
gap: 6px;
|
| 564 |
+
width: 100%;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.folder-name {
|
| 568 |
+
font-size: 14px;
|
| 569 |
+
font-weight: 600;
|
| 570 |
+
color: var(--text-main);
|
| 571 |
+
text-align: center;
|
| 572 |
+
word-break: break-word;
|
| 573 |
+
line-height: 1.4;
|
| 574 |
+
transition: color 0.2s ease;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.folder-card:hover .folder-name {
|
| 578 |
+
color: var(--primary-color);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.folder-meta {
|
| 582 |
+
font-size: 12px;
|
| 583 |
+
color: var(--text-muted);
|
| 584 |
+
text-align: center;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.folder-actions {
|
| 588 |
+
position: absolute;
|
| 589 |
+
top: 16px;
|
| 590 |
+
right: 16px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.action-btn {
|
| 594 |
+
width: 36px;
|
| 595 |
+
height: 36px;
|
| 596 |
+
border: 1px solid var(--border-color);
|
| 597 |
+
border-radius: 10px;
|
| 598 |
+
background: rgba(255, 255, 255, 0.92);
|
| 599 |
+
color: var(--text-muted);
|
| 600 |
+
display: inline-flex;
|
| 601 |
+
align-items: center;
|
| 602 |
+
justify-content: center;
|
| 603 |
+
cursor: pointer;
|
| 604 |
+
transition: all 0.15s ease;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
.action-btn:hover {
|
| 608 |
+
color: var(--text-main);
|
| 609 |
+
background: var(--hover-bg);
|
| 610 |
+
box-shadow: var(--shadow-sm);
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
.item-icon {
|
| 614 |
+
display: flex;
|
| 615 |
+
align-items: center;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.item-name {
|
| 619 |
+
font-size: 14px;
|
| 620 |
+
font-weight: 600;
|
| 621 |
+
color: var(--text-main);
|
| 622 |
+
text-align: center;
|
| 623 |
+
word-break: break-word;
|
| 624 |
+
line-height: 1.4;
|
| 625 |
+
transition: color 0.2s ease;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.folder-card:hover .item-name {
|
| 629 |
+
color: var(--primary-color);
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.item-meta {
|
| 633 |
+
font-size: 12px;
|
| 634 |
+
color: var(--text-muted);
|
| 635 |
+
text-align: center;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* ── FILE CARDS ── */
|
| 639 |
+
.file-card {
|
| 640 |
+
background: var(--card);
|
| 641 |
+
border: 1px solid var(--border-color);
|
| 642 |
+
border-radius: 16px;
|
| 643 |
+
overflow: hidden;
|
| 644 |
+
cursor: pointer;
|
| 645 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 646 |
+
box-shadow: var(--shadow-sm);
|
| 647 |
+
position: relative;
|
| 648 |
+
animation: cardFadeIn 0.4s ease-out forwards;
|
| 649 |
+
opacity: 0;
|
| 650 |
+
transform: translateY(20px);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.file-card:hover {
|
| 654 |
+
transform: translateY(-4px);
|
| 655 |
+
box-shadow: var(--shadow-lg);
|
| 656 |
+
border-color: var(--primary-color);
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.file-card:active {
|
| 660 |
+
transform: translateY(-2px) scale(0.98);
|
| 661 |
+
transition-duration: 0.1s;
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
.file-preview {
|
| 665 |
+
height: 140px;
|
| 666 |
+
background: linear-gradient(135deg, #f8fafc, #eef2ff);
|
| 667 |
+
display: flex;
|
| 668 |
+
align-items: center;
|
| 669 |
+
justify-content: center;
|
| 670 |
+
overflow: hidden;
|
| 671 |
+
border-bottom: 1px solid var(--border-color);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.file-preview img {
|
| 675 |
+
width: 100%;
|
| 676 |
+
height: 100%;
|
| 677 |
+
object-fit: cover;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
.file-icon {
|
| 681 |
+
font-size: 48px;
|
| 682 |
+
opacity: 0.8;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.file-info {
|
| 686 |
+
padding: 16px;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.file-type {
|
| 690 |
+
display: inline-block;
|
| 691 |
+
font-size: 10px;
|
| 692 |
+
font-weight: 700;
|
| 693 |
+
letter-spacing: 0.5px;
|
| 694 |
+
padding: 3px 8px;
|
| 695 |
+
border-radius: 6px;
|
| 696 |
+
margin-bottom: 8px;
|
| 697 |
+
text-transform: uppercase;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
.file-name {
|
| 701 |
+
font-size: 14px;
|
| 702 |
+
font-weight: 600;
|
| 703 |
+
color: var(--text-main);
|
| 704 |
+
overflow: hidden;
|
| 705 |
+
text-overflow: ellipsis;
|
| 706 |
+
white-space: nowrap;
|
| 707 |
+
margin-bottom: 4px;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.file-meta {
|
| 711 |
+
font-size: 12px;
|
| 712 |
+
color: var(--text-muted);
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
.file-actions {
|
| 716 |
+
position: absolute;
|
| 717 |
+
top: 12px;
|
| 718 |
+
right: 12px;
|
| 719 |
+
width: 32px;
|
| 720 |
+
height: 32px;
|
| 721 |
+
border-radius: 50%;
|
| 722 |
+
background: rgba(255, 255, 255, 0.9);
|
| 723 |
+
backdrop-filter: blur(4px);
|
| 724 |
+
display: flex;
|
| 725 |
+
align-items: center;
|
| 726 |
+
justify-content: center;
|
| 727 |
+
font-size: 16px;
|
| 728 |
+
color: var(--text-muted);
|
| 729 |
+
opacity: 0;
|
| 730 |
+
transition: opacity 0.2s ease;
|
| 731 |
+
cursor: pointer;
|
| 732 |
+
border: 1px solid var(--border-color);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.file-card:hover .file-actions {
|
| 736 |
+
opacity: 1;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.quick-actions {
|
| 740 |
+
position: absolute;
|
| 741 |
+
bottom: 0;
|
| 742 |
+
left: 0;
|
| 743 |
+
right: 0;
|
| 744 |
+
display: flex;
|
| 745 |
+
justify-content: center;
|
| 746 |
+
gap: 8px;
|
| 747 |
+
padding: 10px;
|
| 748 |
+
background: linear-gradient(transparent, rgba(255, 255, 255, 0.95));
|
| 749 |
+
opacity: 0;
|
| 750 |
+
transform: translateY(8px);
|
| 751 |
+
transition: all 0.2s ease;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.file-card:hover .quick-actions {
|
| 755 |
+
opacity: 1;
|
| 756 |
+
transform: translateY(0);
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
.quick-btn {
|
| 760 |
+
width: 36px;
|
| 761 |
+
height: 36px;
|
| 762 |
+
border-radius: 50%;
|
| 763 |
+
border: 1px solid var(--border-color);
|
| 764 |
+
background: #ffffff;
|
| 765 |
+
color: var(--text-muted);
|
| 766 |
+
display: flex;
|
| 767 |
+
align-items: center;
|
| 768 |
+
justify-content: center;
|
| 769 |
+
cursor: pointer;
|
| 770 |
+
font-size: 16px;
|
| 771 |
+
transition: all 0.15s ease;
|
| 772 |
+
box-shadow: var(--shadow-sm);
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.quick-btn:hover {
|
| 776 |
+
background: var(--primary-color);
|
| 777 |
+
color: white;
|
| 778 |
+
border-color: var(--primary-color);
|
| 779 |
+
transform: scale(1.1);
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
/* ── LIST VIEW ITEMS ── */
|
| 783 |
+
.file-list-item {
|
| 784 |
+
display: flex;
|
| 785 |
+
align-items: center;
|
| 786 |
+
padding: 12px 16px;
|
| 787 |
+
background: #ffffff;
|
| 788 |
+
border: 1px solid var(--border-color);
|
| 789 |
+
border-radius: 12px;
|
| 790 |
+
gap: 16px;
|
| 791 |
+
transition: all 0.2s ease;
|
| 792 |
+
cursor: pointer;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.file-list-item:hover {
|
| 796 |
+
background: var(--hover-bg);
|
| 797 |
+
transform: translateX(4px);
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.list-icon {
|
| 801 |
+
font-size: 24px;
|
| 802 |
+
display: flex;
|
| 803 |
+
align-items: center;
|
| 804 |
+
justify-content: center;
|
| 805 |
+
width: 40px;
|
| 806 |
+
height: 40px;
|
| 807 |
+
background: var(--hover-bg);
|
| 808 |
+
border-radius: 10px;
|
| 809 |
+
flex-shrink: 0;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.list-info {
|
| 813 |
+
flex-grow: 1;
|
| 814 |
+
min-width: 0;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.list-name {
|
| 818 |
+
font-size: 14px;
|
| 819 |
+
font-weight: 600;
|
| 820 |
+
color: var(--text-main);
|
| 821 |
+
white-space: nowrap;
|
| 822 |
+
overflow: hidden;
|
| 823 |
+
text-overflow: ellipsis;
|
| 824 |
+
margin-bottom: 2px;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
.list-meta {
|
| 828 |
+
font-size: 12px;
|
| 829 |
+
color: var(--text-muted);
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.list-actions {
|
| 833 |
+
display: flex;
|
| 834 |
+
gap: 4px;
|
| 835 |
+
opacity: 0;
|
| 836 |
+
transition: opacity 0.2s ease;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.file-list-item:hover .list-actions {
|
| 840 |
+
opacity: 1;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
.list-actions .icon-btn {
|
| 844 |
+
width: 32px;
|
| 845 |
+
height: 32px;
|
| 846 |
+
font-size: 18px;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
.list-actions .icon-btn:hover {
|
| 850 |
+
background: #ffffff;
|
| 851 |
+
box-shadow: var(--shadow-sm);
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
/* ── MODALS ── */
|
| 855 |
+
.modal-overlay {
|
| 856 |
+
position: fixed;
|
| 857 |
+
inset: 0;
|
| 858 |
+
background: rgba(0, 0, 0, 0.4);
|
| 859 |
+
backdrop-filter: blur(4px);
|
| 860 |
+
display: flex;
|
| 861 |
+
align-items: center;
|
| 862 |
+
justify-content: center;
|
| 863 |
+
z-index: 1000;
|
| 864 |
+
opacity: 0;
|
| 865 |
+
pointer-events: none;
|
| 866 |
+
transition: opacity 0.25s ease;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
.modal-overlay.active {
|
| 870 |
+
opacity: 1;
|
| 871 |
+
pointer-events: auto;
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
.modal {
|
| 875 |
+
background: var(--card);
|
| 876 |
+
border-radius: 20px;
|
| 877 |
+
padding: 32px;
|
| 878 |
+
width: 90%;
|
| 879 |
+
max-width: 440px;
|
| 880 |
+
box-shadow: var(--shadow-xl);
|
| 881 |
+
position: relative;
|
| 882 |
+
transform: scale(0.95) translateY(10px);
|
| 883 |
+
transition: transform 0.25s ease;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.modal-overlay.active .modal {
|
| 887 |
+
transform: scale(1) translateY(0);
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.modal h3 {
|
| 891 |
+
font-size: 18px;
|
| 892 |
+
font-weight: 700;
|
| 893 |
+
color: var(--text-main);
|
| 894 |
+
margin-bottom: 24px;
|
| 895 |
+
display: flex;
|
| 896 |
+
align-items: center;
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.close-modal {
|
| 900 |
+
position: absolute;
|
| 901 |
+
top: 16px;
|
| 902 |
+
right: 16px;
|
| 903 |
+
background: none;
|
| 904 |
+
border: none;
|
| 905 |
+
color: var(--text-muted);
|
| 906 |
+
font-size: 20px;
|
| 907 |
+
cursor: pointer;
|
| 908 |
+
padding: 4px;
|
| 909 |
+
border-radius: 8px;
|
| 910 |
+
display: flex;
|
| 911 |
+
align-items: center;
|
| 912 |
+
justify-content: center;
|
| 913 |
+
transition: all 0.15s ease;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
.close-modal:hover {
|
| 917 |
+
background: var(--hover-bg);
|
| 918 |
+
color: var(--text-main);
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.input-group {
|
| 922 |
+
margin-bottom: 24px;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.input-label {
|
| 926 |
+
display: block;
|
| 927 |
+
font-size: 13px;
|
| 928 |
+
font-weight: 600;
|
| 929 |
+
color: var(--text-muted);
|
| 930 |
+
margin-bottom: 8px;
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
.input-group input {
|
| 934 |
+
width: 100%;
|
| 935 |
+
padding: 12px 16px;
|
| 936 |
+
border: 2px solid var(--border-color);
|
| 937 |
+
border-radius: 12px;
|
| 938 |
+
font-family: inherit;
|
| 939 |
+
font-size: 14px;
|
| 940 |
+
color: var(--text-main);
|
| 941 |
+
background: var(--bg-color);
|
| 942 |
+
outline: none;
|
| 943 |
+
transition: all 0.2s ease;
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
.input-group input:focus {
|
| 947 |
+
border-color: var(--primary-color);
|
| 948 |
+
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
.modal-footer {
|
| 952 |
+
display: flex;
|
| 953 |
+
justify-content: flex-end;
|
| 954 |
+
gap: 12px;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
/* ── BUTTONS ── */
|
| 958 |
+
.btn-primary {
|
| 959 |
+
padding: 10px 20px;
|
| 960 |
+
background: var(--primary-color);
|
| 961 |
+
color: white;
|
| 962 |
+
border: none;
|
| 963 |
+
border-radius: 10px;
|
| 964 |
+
font-family: inherit;
|
| 965 |
+
font-size: 14px;
|
| 966 |
+
font-weight: 600;
|
| 967 |
+
cursor: pointer;
|
| 968 |
+
display: flex;
|
| 969 |
+
align-items: center;
|
| 970 |
+
gap: 6px;
|
| 971 |
+
transition: all 0.15s ease;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
.btn-primary:hover {
|
| 975 |
+
background: var(--primary-hover);
|
| 976 |
+
transform: translateY(-1px);
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.btn-secondary {
|
| 980 |
+
padding: 10px 20px;
|
| 981 |
+
background: transparent;
|
| 982 |
+
color: var(--text-muted);
|
| 983 |
+
border: 1px solid var(--border-color);
|
| 984 |
+
border-radius: 10px;
|
| 985 |
+
font-family: inherit;
|
| 986 |
+
font-size: 14px;
|
| 987 |
+
font-weight: 600;
|
| 988 |
+
cursor: pointer;
|
| 989 |
+
transition: all 0.15s ease;
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
.btn-secondary:hover {
|
| 993 |
+
background: var(--hover-bg);
|
| 994 |
+
color: var(--text-main);
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.btn-danger {
|
| 998 |
+
padding: 10px 20px;
|
| 999 |
+
background: var(--danger-color);
|
| 1000 |
+
color: white;
|
| 1001 |
+
border: none;
|
| 1002 |
+
border-radius: 10px;
|
| 1003 |
+
font-family: inherit;
|
| 1004 |
+
font-size: 14px;
|
| 1005 |
+
font-weight: 600;
|
| 1006 |
+
cursor: pointer;
|
| 1007 |
+
display: flex;
|
| 1008 |
+
align-items: center;
|
| 1009 |
+
gap: 6px;
|
| 1010 |
+
transition: all 0.15s ease;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.btn-danger:hover {
|
| 1014 |
+
background: #dc2626;
|
| 1015 |
+
transform: translateY(-1px);
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
/* ── SPINNER ── */
|
| 1019 |
+
.spinner {
|
| 1020 |
+
width: 40px;
|
| 1021 |
+
height: 40px;
|
| 1022 |
+
border: 4px solid var(--border-color);
|
| 1023 |
+
border-top-color: var(--primary-color);
|
| 1024 |
+
border-radius: 50%;
|
| 1025 |
+
animation: spin 0.8s linear infinite;
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
.spinner-sm {
|
| 1029 |
+
width: 20px;
|
| 1030 |
+
height: 20px;
|
| 1031 |
+
border: 3px solid var(--border-color);
|
| 1032 |
+
border-top-color: var(--primary-color);
|
| 1033 |
+
border-radius: 50%;
|
| 1034 |
+
animation: spin 0.8s linear infinite;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
@keyframes spin {
|
| 1038 |
+
to { transform: rotate(360deg); }
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.loading-state {
|
| 1042 |
+
display: flex;
|
| 1043 |
+
flex-direction: column;
|
| 1044 |
+
align-items: center;
|
| 1045 |
+
justify-content: center;
|
| 1046 |
+
gap: 16px;
|
| 1047 |
+
padding: 40px;
|
| 1048 |
+
color: var(--text-muted);
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
/* ── DELETE MODAL ── */
|
| 1052 |
+
.delete-icon-wrap {
|
| 1053 |
+
text-align: center;
|
| 1054 |
+
font-size: 56px;
|
| 1055 |
+
color: var(--danger-color);
|
| 1056 |
+
margin-bottom: 20px;
|
| 1057 |
+
animation: pulse-danger 1.5s infinite;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
@keyframes pulse-danger {
|
| 1061 |
+
0%, 100% { transform: scale(1); }
|
| 1062 |
+
50% { transform: scale(1.06); }
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
/* ── UPLOAD PROGRESS ── */
|
| 1066 |
+
.upload-progress {
|
| 1067 |
+
position: fixed;
|
| 1068 |
+
bottom: -100px;
|
| 1069 |
+
left: 50%;
|
| 1070 |
+
transform: translateX(-50%);
|
| 1071 |
+
background: #ffffff;
|
| 1072 |
+
border: 1px solid var(--border-color);
|
| 1073 |
+
border-radius: 14px;
|
| 1074 |
+
padding: 16px 24px;
|
| 1075 |
+
z-index: 1100;
|
| 1076 |
+
box-shadow: var(--shadow-lg);
|
| 1077 |
+
min-width: 300px;
|
| 1078 |
+
transition: bottom 0.3s ease;
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
.upload-progress.active {
|
| 1082 |
+
bottom: 28px;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
.upload-progress-inner {
|
| 1086 |
+
display: flex;
|
| 1087 |
+
align-items: center;
|
| 1088 |
+
gap: 14px;
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
.upload-progress-inner span {
|
| 1092 |
+
font-size: 14px;
|
| 1093 |
+
font-weight: 600;
|
| 1094 |
+
color: var(--text-main);
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
/* ── TOAST ── */
|
| 1098 |
+
.toast-container {
|
| 1099 |
+
position: fixed;
|
| 1100 |
+
top: 28px;
|
| 1101 |
+
right: 28px;
|
| 1102 |
+
display: flex;
|
| 1103 |
+
flex-direction: column;
|
| 1104 |
+
gap: 10px;
|
| 1105 |
+
z-index: 1200;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
.toast {
|
| 1109 |
+
display: flex;
|
| 1110 |
+
align-items: center;
|
| 1111 |
+
gap: 12px;
|
| 1112 |
+
background: #ffffff;
|
| 1113 |
+
border: 1px solid var(--border-color);
|
| 1114 |
+
border-radius: 12px;
|
| 1115 |
+
padding: 14px 18px;
|
| 1116 |
+
box-shadow: var(--shadow-lg);
|
| 1117 |
+
font-size: 14px;
|
| 1118 |
+
font-weight: 600;
|
| 1119 |
+
color: var(--text-main);
|
| 1120 |
+
transform: translateX(110%);
|
| 1121 |
+
opacity: 0;
|
| 1122 |
+
max-width: 360px;
|
| 1123 |
+
transition: all 0.3s ease;
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
.toast.show {
|
| 1127 |
+
transform: translateX(0);
|
| 1128 |
+
opacity: 1;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
.toast i {
|
| 1132 |
+
font-size: 20px;
|
| 1133 |
+
flex-shrink: 0;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
.toast-success { border-left: 4px solid var(--primary-color); }
|
| 1137 |
+
.toast-success i { color: var(--primary-color); }
|
| 1138 |
+
|
| 1139 |
+
.toast-error { border-left: 4px solid var(--danger-color); }
|
| 1140 |
+
.toast-error i { color: var(--danger-color); }
|
| 1141 |
+
|
| 1142 |
+
.toast-warning { border-left: 4px solid #f59e0b; }
|
| 1143 |
+
.toast-warning i { color: #f59e0b; }
|
| 1144 |
+
|
| 1145 |
+
.toast-info { border-left: 4px solid var(--file-color); }
|
| 1146 |
+
.toast-info i { color: var(--file-color); }
|
| 1147 |
+
|
| 1148 |
+
/* ── SCROLLBAR ── */
|
| 1149 |
+
::-webkit-scrollbar { width: 8px; }
|
| 1150 |
+
::-webkit-scrollbar-track { background: var(--bg-color); }
|
| 1151 |
+
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 10px; }
|
| 1152 |
+
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
|
| 1153 |
+
|
| 1154 |
+
/* ── DROPDOWN MENU (3-dot) ── */
|
| 1155 |
+
.dropdown-menu {
|
| 1156 |
+
position: absolute;
|
| 1157 |
+
top: 100%;
|
| 1158 |
+
right: 0;
|
| 1159 |
+
background: var(--card);
|
| 1160 |
+
border: 1px solid var(--border-color);
|
| 1161 |
+
border-radius: 12px;
|
| 1162 |
+
box-shadow: var(--shadow-lg);
|
| 1163 |
+
min-width: 140px;
|
| 1164 |
+
opacity: 0;
|
| 1165 |
+
visibility: hidden;
|
| 1166 |
+
transform: translateY(-8px);
|
| 1167 |
+
transition: all 0.2s ease;
|
| 1168 |
+
z-index: 100;
|
| 1169 |
+
overflow: hidden;
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
.dropdown-menu.open {
|
| 1173 |
+
opacity: 1;
|
| 1174 |
+
visibility: visible;
|
| 1175 |
+
transform: translateY(0);
|
| 1176 |
+
}
|
| 1177 |
+
|
| 1178 |
+
.dropdown-item {
|
| 1179 |
+
width: 100%;
|
| 1180 |
+
padding: 10px 14px;
|
| 1181 |
+
background: none;
|
| 1182 |
+
border: none;
|
| 1183 |
+
color: var(--text-main);
|
| 1184 |
+
font-family: inherit;
|
| 1185 |
+
font-size: 13px;
|
| 1186 |
+
font-weight: 500;
|
| 1187 |
+
display: flex;
|
| 1188 |
+
align-items: center;
|
| 1189 |
+
gap: 8px;
|
| 1190 |
+
cursor: pointer;
|
| 1191 |
+
text-align: left;
|
| 1192 |
+
transition: background-color 0.15s ease;
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
.dropdown-item:hover {
|
| 1196 |
+
background: var(--hover-bg);
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
.dropdown-item i {
|
| 1200 |
+
font-size: 16px;
|
| 1201 |
+
width: 16px;
|
| 1202 |
+
flex-shrink: 0;
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
+
.dropdown-item.danger {
|
| 1206 |
+
color: var(--danger-color);
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
.dropdown-item.danger:hover {
|
| 1210 |
+
background: rgba(239, 68, 68, 0.1);
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
/* File actions menu (appended to body) */
|
| 1214 |
+
.file-menu-dropdown {
|
| 1215 |
+
position: fixed;
|
| 1216 |
+
background: var(--card);
|
| 1217 |
+
border: 1px solid var(--border-color);
|
| 1218 |
+
border-radius: 12px;
|
| 1219 |
+
box-shadow: var(--shadow-lg);
|
| 1220 |
+
min-width: 140px;
|
| 1221 |
+
z-index: 1000;
|
| 1222 |
+
overflow: hidden;
|
| 1223 |
+
animation: fadeInScale 0.15s ease;
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
.file-menu-dropdown .menu-item {
|
| 1227 |
+
width: 100%;
|
| 1228 |
+
padding: 10px 14px;
|
| 1229 |
+
background: none;
|
| 1230 |
+
border: none;
|
| 1231 |
+
color: var(--text-main);
|
| 1232 |
+
font-family: inherit;
|
| 1233 |
+
font-size: 13px;
|
| 1234 |
+
font-weight: 500;
|
| 1235 |
+
display: flex;
|
| 1236 |
+
align-items: center;
|
| 1237 |
+
gap: 8px;
|
| 1238 |
+
cursor: pointer;
|
| 1239 |
+
text-align: left;
|
| 1240 |
+
transition: background-color 0.15s ease;
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
.file-menu-dropdown .menu-item:hover {
|
| 1244 |
+
background: var(--hover-bg);
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.file-menu-dropdown .menu-item i {
|
| 1248 |
+
font-size: 16px;
|
| 1249 |
+
width: 16px;
|
| 1250 |
+
flex-shrink: 0;
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
.file-menu-dropdown .menu-item.danger {
|
| 1254 |
+
color: var(--danger-color);
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
.file-menu-dropdown .menu-item.danger:hover {
|
| 1258 |
+
background: rgba(239, 68, 68, 0.1);
|
| 1259 |
+
}
|
| 1260 |
+
|
| 1261 |
+
.menu-divider {
|
| 1262 |
+
height: 1px;
|
| 1263 |
+
background: var(--border-color);
|
| 1264 |
+
margin: 4px 0;
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
@keyframes fadeInScale {
|
| 1268 |
+
from {
|
| 1269 |
+
opacity: 0;
|
| 1270 |
+
transform: scale(0.95);
|
| 1271 |
+
}
|
| 1272 |
+
to {
|
| 1273 |
+
opacity: 1;
|
| 1274 |
+
transform: scale(1);
|
| 1275 |
+
}
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
/* ── PREVIEW MODAL ── */
|
| 1279 |
+
.preview-header {
|
| 1280 |
+
display: flex;
|
| 1281 |
+
align-items: center;
|
| 1282 |
+
justify-content: space-between;
|
| 1283 |
+
padding: 16px 20px;
|
| 1284 |
+
border-bottom: 1px solid var(--border-color);
|
| 1285 |
+
background: #ffffff;
|
| 1286 |
+
border-radius: 16px 16px 0 0;
|
| 1287 |
+
flex-shrink: 0;
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
.preview-title {
|
| 1291 |
+
display: flex;
|
| 1292 |
+
align-items: center;
|
| 1293 |
+
gap: 10px;
|
| 1294 |
+
font-weight: 600;
|
| 1295 |
+
font-size: 15px;
|
| 1296 |
+
color: var(--text-main);
|
| 1297 |
+
overflow: hidden;
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
.preview-title span {
|
| 1301 |
+
white-space: nowrap;
|
| 1302 |
+
overflow: hidden;
|
| 1303 |
+
text-overflow: ellipsis;
|
| 1304 |
+
max-width: 600px;
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
.preview-icon {
|
| 1308 |
+
font-size: 22px;
|
| 1309 |
+
flex-shrink: 0;
|
| 1310 |
+
color: var(--primary-color);
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
.preview-body {
|
| 1314 |
+
flex: 1;
|
| 1315 |
+
overflow: auto;
|
| 1316 |
+
background: #f8fafc;
|
| 1317 |
+
display: flex;
|
| 1318 |
+
align-items: center;
|
| 1319 |
+
justify-content: center;
|
| 1320 |
+
position: relative;
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
.preview-iframe {
|
| 1324 |
+
width: 100%;
|
| 1325 |
+
height: 100%;
|
| 1326 |
+
border: none;
|
| 1327 |
+
display: block;
|
| 1328 |
+
}
|
| 1329 |
+
|
| 1330 |
+
.preview-image {
|
| 1331 |
+
max-width: 100%;
|
| 1332 |
+
max-height: 100%;
|
| 1333 |
+
object-fit: contain;
|
| 1334 |
+
border-radius: 4px;
|
| 1335 |
+
display: block;
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
.preview-text {
|
| 1339 |
+
width: 100%;
|
| 1340 |
+
height: 100%;
|
| 1341 |
+
padding: 24px 28px;
|
| 1342 |
+
font-family: 'Fira Code', 'Consolas', monospace;
|
| 1343 |
+
font-size: 13px;
|
| 1344 |
+
line-height: 1.7;
|
| 1345 |
+
color: var(--text-main);
|
| 1346 |
+
white-space: pre-wrap;
|
| 1347 |
+
word-break: break-word;
|
| 1348 |
+
background: #f8fafc;
|
| 1349 |
+
overflow: auto;
|
| 1350 |
+
margin: 0;
|
| 1351 |
+
align-self: flex-start;
|
| 1352 |
+
min-height: 100%;
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
.preview-fallback {
|
| 1356 |
+
display: flex;
|
| 1357 |
+
flex-direction: column;
|
| 1358 |
+
align-items: center;
|
| 1359 |
+
justify-content: center;
|
| 1360 |
+
gap: 12px;
|
| 1361 |
+
padding: 60px 32px;
|
| 1362 |
+
text-align: center;
|
| 1363 |
+
color: var(--text-muted);
|
| 1364 |
+
}
|
| 1365 |
+
|
| 1366 |
+
.preview-fallback i {
|
| 1367 |
+
font-size: 72px;
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
.preview-fallback p {
|
| 1371 |
+
font-size: 15px;
|
| 1372 |
+
font-weight: 500;
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
/* ── SKELETON LOADING ── */
|
| 1376 |
+
.skeleton {
|
| 1377 |
+
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
| 1378 |
+
background-size: 200% 100%;
|
| 1379 |
+
animation: skeletonLoading 1.5s infinite;
|
| 1380 |
+
border-radius: 16px;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
@keyframes skeletonLoading {
|
| 1384 |
+
0% { background-position: 200% 0; }
|
| 1385 |
+
100% { background-position: -200% 0; }
|
| 1386 |
+
}
|
| 1387 |
+
|
| 1388 |
+
.skeleton-card {
|
| 1389 |
+
height: 140px;
|
| 1390 |
+
border-radius: 16px;
|
| 1391 |
+
border: 1px solid var(--border-color);
|
| 1392 |
+
}
|
| 1393 |
+
|
| 1394 |
+
/* ── EMPTY STATE ── */
|
| 1395 |
+
.empty-state {
|
| 1396 |
+
display: flex;
|
| 1397 |
+
flex-direction: column;
|
| 1398 |
+
align-items: center;
|
| 1399 |
+
justify-content: center;
|
| 1400 |
+
padding: 60px 20px;
|
| 1401 |
+
text-align: center;
|
| 1402 |
+
color: var(--text-muted);
|
| 1403 |
+
animation: fadeInUp 0.6s ease-out;
|
| 1404 |
+
grid-column: 1 / -1;
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
@keyframes fadeInUp {
|
| 1408 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 1409 |
+
to { opacity: 1; transform: translateY(0); }
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
.empty-state i {
|
| 1413 |
+
font-size: 48px;
|
| 1414 |
+
margin-bottom: 16px;
|
| 1415 |
+
opacity: 0.6;
|
| 1416 |
+
animation: bounceIn 0.8s ease-out;
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
@keyframes bounceIn {
|
| 1420 |
+
0% { opacity: 0; transform: scale(0.3); }
|
| 1421 |
+
50% { opacity: 1; transform: scale(1.05); }
|
| 1422 |
+
70% { transform: scale(0.9); }
|
| 1423 |
+
100% { opacity: 1; transform: scale(1); }
|
| 1424 |
+
}
|
| 1425 |
+
|
| 1426 |
+
.empty-state h3 {
|
| 1427 |
+
font-size: 18px;
|
| 1428 |
+
font-weight: 600;
|
| 1429 |
+
margin-bottom: 8px;
|
| 1430 |
+
color: var(--text-main);
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
.empty-state p {
|
| 1434 |
+
font-size: 14px;
|
| 1435 |
+
line-height: 1.5;
|
| 1436 |
+
max-width: 280px;
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
/* ── RESPONSIVE ── */
|
| 1440 |
+
@media (max-width: 768px) {
|
| 1441 |
+
.sidebar { width: 220px; padding: 16px; }
|
| 1442 |
+
.top-header { padding: 0 20px; }
|
| 1443 |
+
.search-bar { width: 100%; }
|
| 1444 |
+
.content-area { padding: 0 20px 20px; }
|
| 1445 |
+
.breadcrumbs { padding: 20px 20px 8px; }
|
| 1446 |
+
.folder-card { padding: 16px; min-height: 120px; }
|
| 1447 |
+
}
|
tests/DocVault.postman_collection.json
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"info": {
|
| 3 |
+
"name": "DocVault API Collection",
|
| 4 |
+
"description": "Complete API endpoints for DocVault offline-first document storage system",
|
| 5 |
+
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
| 6 |
+
},
|
| 7 |
+
"item": [
|
| 8 |
+
{
|
| 9 |
+
"name": "Health Check",
|
| 10 |
+
"request": {
|
| 11 |
+
"method": "GET",
|
| 12 |
+
"url": "http://localhost:5000/api/health",
|
| 13 |
+
"header": []
|
| 14 |
+
},
|
| 15 |
+
"response": []
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"name": "Create Folder",
|
| 19 |
+
"request": {
|
| 20 |
+
"method": "POST",
|
| 21 |
+
"header": [
|
| 22 |
+
{
|
| 23 |
+
"key": "Content-Type",
|
| 24 |
+
"value": "application/json"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"key": "X-User-ID",
|
| 28 |
+
"value": "user123"
|
| 29 |
+
}
|
| 30 |
+
],
|
| 31 |
+
"body": {
|
| 32 |
+
"mode": "raw",
|
| 33 |
+
"raw": "{\"folder_path\": \"Documents/Projects/MyProject\"}"
|
| 34 |
+
},
|
| 35 |
+
"url": "http://localhost:5000/api/create-folder"
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"name": "Delete Folder",
|
| 40 |
+
"request": {
|
| 41 |
+
"method": "POST",
|
| 42 |
+
"header": [
|
| 43 |
+
{
|
| 44 |
+
"key": "Content-Type",
|
| 45 |
+
"value": "application/json"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"key": "X-User-ID",
|
| 49 |
+
"value": "user123"
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
"body": {
|
| 53 |
+
"mode": "raw",
|
| 54 |
+
"raw": "{\"folder_path\": \"Documents/Projects/MyProject\", \"force\": true}"
|
| 55 |
+
},
|
| 56 |
+
"url": "http://localhost:5000/api/delete-folder"
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"name": "Upload File",
|
| 61 |
+
"request": {
|
| 62 |
+
"method": "POST",
|
| 63 |
+
"header": [
|
| 64 |
+
{
|
| 65 |
+
"key": "X-User-ID",
|
| 66 |
+
"value": "user123"
|
| 67 |
+
}
|
| 68 |
+
],
|
| 69 |
+
"body": {
|
| 70 |
+
"mode": "formdata",
|
| 71 |
+
"formdata": [
|
| 72 |
+
{
|
| 73 |
+
"key": "folder_path",
|
| 74 |
+
"value": "Documents",
|
| 75 |
+
"type": "text"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"key": "file",
|
| 79 |
+
"type": "file"
|
| 80 |
+
}
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
"url": "http://localhost:5000/api/upload-file"
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"name": "List Contents",
|
| 88 |
+
"request": {
|
| 89 |
+
"method": "GET",
|
| 90 |
+
"header": [
|
| 91 |
+
{
|
| 92 |
+
"key": "X-User-ID",
|
| 93 |
+
"value": "user123"
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"url": {
|
| 97 |
+
"raw": "http://localhost:5000/api/list?folder_path=Documents",
|
| 98 |
+
"protocol": "http",
|
| 99 |
+
"host": ["localhost"],
|
| 100 |
+
"port": "5000",
|
| 101 |
+
"path": ["api", "list"],
|
| 102 |
+
"query": [
|
| 103 |
+
{
|
| 104 |
+
"key": "folder_path",
|
| 105 |
+
"value": "Documents"
|
| 106 |
+
}
|
| 107 |
+
]
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"name": "Rename Item",
|
| 113 |
+
"request": {
|
| 114 |
+
"method": "POST",
|
| 115 |
+
"header": [
|
| 116 |
+
{
|
| 117 |
+
"key": "Content-Type",
|
| 118 |
+
"value": "application/json"
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"key": "X-User-ID",
|
| 122 |
+
"value": "user123"
|
| 123 |
+
}
|
| 124 |
+
],
|
| 125 |
+
"body": {
|
| 126 |
+
"mode": "raw",
|
| 127 |
+
"raw": "{\"item_path\": \"Documents/OldName\", \"new_name\": \"NewName\"}"
|
| 128 |
+
},
|
| 129 |
+
"url": "http://localhost:5000/api/rename"
|
| 130 |
+
}
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"name": "Storage Statistics",
|
| 134 |
+
"request": {
|
| 135 |
+
"method": "GET",
|
| 136 |
+
"header": [
|
| 137 |
+
{
|
| 138 |
+
"key": "X-User-ID",
|
| 139 |
+
"value": "user123"
|
| 140 |
+
}
|
| 141 |
+
],
|
| 142 |
+
"url": "http://localhost:5000/api/storage-stats"
|
| 143 |
+
}
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"name": "Download File",
|
| 147 |
+
"request": {
|
| 148 |
+
"method": "GET",
|
| 149 |
+
"header": [
|
| 150 |
+
{
|
| 151 |
+
"key": "X-User-ID",
|
| 152 |
+
"value": "user123"
|
| 153 |
+
}
|
| 154 |
+
],
|
| 155 |
+
"url": "http://localhost:5000/api/download/Documents/report.pdf"
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
]
|
| 159 |
+
}
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""DocVault tests package"""
|
tests/test_api.sh
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# DocVault API Test Script
|
| 4 |
+
# Uses curl to test all API endpoints
|
| 5 |
+
|
| 6 |
+
BASE_URL="http://localhost:5000/api"
|
| 7 |
+
USER_ID="test_user_$(date +%s)"
|
| 8 |
+
|
| 9 |
+
echo "========================================="
|
| 10 |
+
echo "DocVault API Test Script"
|
| 11 |
+
echo "========================================="
|
| 12 |
+
echo "Base URL: $BASE_URL"
|
| 13 |
+
echo "User ID: $USER_ID"
|
| 14 |
+
echo ""
|
| 15 |
+
|
| 16 |
+
# Health Check
|
| 17 |
+
echo "1. Testing Health Check..."
|
| 18 |
+
curl -X GET "$BASE_URL/health" \
|
| 19 |
+
-H "Content-Type: application/json"
|
| 20 |
+
echo ""
|
| 21 |
+
echo ""
|
| 22 |
+
|
| 23 |
+
# Create Folders
|
| 24 |
+
echo "2. Creating Folders..."
|
| 25 |
+
curl -X POST "$BASE_URL/create-folder" \
|
| 26 |
+
-H "Content-Type: application/json" \
|
| 27 |
+
-H "X-User-ID: $USER_ID" \
|
| 28 |
+
-d '{"folder_path": "Documents"}'
|
| 29 |
+
echo ""
|
| 30 |
+
|
| 31 |
+
echo "3. Creating Nested Folders..."
|
| 32 |
+
curl -X POST "$BASE_URL/create-folder" \
|
| 33 |
+
-H "Content-Type: application/json" \
|
| 34 |
+
-H "X-User-ID: $USER_ID" \
|
| 35 |
+
-d '{"folder_path": "Documents/Projects/MyProject"}'
|
| 36 |
+
echo ""
|
| 37 |
+
|
| 38 |
+
echo "4. Creating More Folders..."
|
| 39 |
+
curl -X POST "$BASE_URL/create-folder" \
|
| 40 |
+
-H "Content-Type: application/json" \
|
| 41 |
+
-H "X-User-ID: $USER_ID" \
|
| 42 |
+
-d '{"folder_path": "Images"}'
|
| 43 |
+
echo ""
|
| 44 |
+
echo ""
|
| 45 |
+
|
| 46 |
+
# List Contents
|
| 47 |
+
echo "5. Listing Root Contents..."
|
| 48 |
+
curl -X GET "$BASE_URL/list" \
|
| 49 |
+
-H "X-User-ID: $USER_ID"
|
| 50 |
+
echo ""
|
| 51 |
+
echo ""
|
| 52 |
+
|
| 53 |
+
# Upload Files
|
| 54 |
+
echo "6. Uploading Test File to Documents..."
|
| 55 |
+
curl -X POST "$BASE_URL/upload-file" \
|
| 56 |
+
-H "X-User-ID: $USER_ID" \
|
| 57 |
+
-F "folder_path=Documents" \
|
| 58 |
+
-F "file=@test_file.txt"
|
| 59 |
+
echo ""
|
| 60 |
+
|
| 61 |
+
echo "7. Uploading Another File to Documents/Projects..."
|
| 62 |
+
curl -X POST "$BASE_URL/upload-file" \
|
| 63 |
+
-H "X-User-ID: $USER_ID" \
|
| 64 |
+
-F "folder_path=Documents/Projects" \
|
| 65 |
+
-F "file=@test_document.md"
|
| 66 |
+
echo ""
|
| 67 |
+
echo ""
|
| 68 |
+
|
| 69 |
+
# List Folder Contents
|
| 70 |
+
echo "8. Listing Documents Folder..."
|
| 71 |
+
curl -X GET "$BASE_URL/list?folder_path=Documents" \
|
| 72 |
+
-H "X-User-ID: $USER_ID"
|
| 73 |
+
echo ""
|
| 74 |
+
echo ""
|
| 75 |
+
|
| 76 |
+
echo "9. Listing Documents/Projects Folder..."
|
| 77 |
+
curl -X GET "$BASE_URL/list?folder_path=Documents/Projects" \
|
| 78 |
+
-H "X-User-ID: $USER_ID"
|
| 79 |
+
echo ""
|
| 80 |
+
echo ""
|
| 81 |
+
|
| 82 |
+
# Rename
|
| 83 |
+
echo "10. Renaming Folder..."
|
| 84 |
+
curl -X POST "$BASE_URL/rename" \
|
| 85 |
+
-H "Content-Type: application/json" \
|
| 86 |
+
-H "X-User-ID: $USER_ID" \
|
| 87 |
+
-d '{"item_path": "Images", "new_name": "Pictures"}'
|
| 88 |
+
echo ""
|
| 89 |
+
echo ""
|
| 90 |
+
|
| 91 |
+
# Storage Stats
|
| 92 |
+
echo "11. Getting Storage Statistics..."
|
| 93 |
+
curl -X GET "$BASE_URL/storage-stats" \
|
| 94 |
+
-H "X-User-ID: $USER_ID"
|
| 95 |
+
echo ""
|
| 96 |
+
echo ""
|
| 97 |
+
|
| 98 |
+
# Delete File (by renaming and deleting the test)
|
| 99 |
+
echo "12. Listing all contents before deletion..."
|
| 100 |
+
curl -X GET "$BASE_URL/list?folder_path=Documents/Projects/MyProject" \
|
| 101 |
+
-H "X-User-ID: $USER_ID"
|
| 102 |
+
echo ""
|
| 103 |
+
echo ""
|
| 104 |
+
|
| 105 |
+
# Delete Folder (empty)
|
| 106 |
+
echo "13. Deleting Empty Folder..."
|
| 107 |
+
curl -X POST "$BASE_URL/delete-folder" \
|
| 108 |
+
-H "Content-Type: application/json" \
|
| 109 |
+
-H "X-User-ID: $USER_ID" \
|
| 110 |
+
-d '{"folder_path": "Documents/Projects/MyProject"}'
|
| 111 |
+
echo ""
|
| 112 |
+
echo ""
|
| 113 |
+
|
| 114 |
+
# Delete Folder (non-empty, with force)
|
| 115 |
+
echo "14. Deleting Non-Empty Folder with Force..."
|
| 116 |
+
curl -X POST "$BASE_URL/delete-folder" \
|
| 117 |
+
-H "Content-Type: application/json" \
|
| 118 |
+
-H "X-User-ID: $USER_ID" \
|
| 119 |
+
-d '{"folder_path": "Documents/Projects", "force": true}'
|
| 120 |
+
echo ""
|
| 121 |
+
echo ""
|
| 122 |
+
|
| 123 |
+
# Final List
|
| 124 |
+
echo "15. Final Directory Listing..."
|
| 125 |
+
curl -X GET "$BASE_URL/list" \
|
| 126 |
+
-H "X-User-ID: $USER_ID"
|
| 127 |
+
echo ""
|
| 128 |
+
echo ""
|
| 129 |
+
|
| 130 |
+
echo "========================================="
|
| 131 |
+
echo "Test Complete!"
|
| 132 |
+
echo "========================================="
|
tests/test_document.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sample Markdown Document
|
| 2 |
+
|
| 3 |
+
This is a sample markdown document for testing DocVault file upload functionality.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
- File upload
|
| 7 |
+
- Folder creation
|
| 8 |
+
- File management
|
| 9 |
+
- Storage tracking
|
| 10 |
+
|
| 11 |
+
## Code Example
|
| 12 |
+
|
| 13 |
+
```python
|
| 14 |
+
# DocVault API Usage
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
response = requests.post(
|
| 18 |
+
'http://localhost:5000/api/create-folder',
|
| 19 |
+
json={'folder_path': 'Documents/Projects'},
|
| 20 |
+
headers={'X-User-ID': 'user123'}
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
print(response.json())
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
**Created for DocVault Testing**
|
tests/test_docvault.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test cases for DocVault API"""
|
| 2 |
+
|
| 3 |
+
import unittest
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
import shutil
|
| 8 |
+
import io
|
| 9 |
+
|
| 10 |
+
# Add server to path
|
| 11 |
+
import sys
|
| 12 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 13 |
+
|
| 14 |
+
from server.app import create_app
|
| 15 |
+
import server.config as config
|
| 16 |
+
import server.utils.validators as validators
|
| 17 |
+
import server.storage.manager as storage_manager
|
| 18 |
+
from server.config import FOLDER_MARKER
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class DocVaultTestCase(unittest.TestCase):
|
| 22 |
+
"""Base test case for DocVault"""
|
| 23 |
+
|
| 24 |
+
def setUp(self):
|
| 25 |
+
"""Set up test client and temporary data directory"""
|
| 26 |
+
self.app = create_app()
|
| 27 |
+
self.client = self.app.test_client()
|
| 28 |
+
self.user_id = "test_user"
|
| 29 |
+
self.temp_data_dir = tempfile.mkdtemp()
|
| 30 |
+
self.user_data_path = os.path.join(self.temp_data_dir, self.user_id)
|
| 31 |
+
os.makedirs(self.user_data_path, exist_ok=True)
|
| 32 |
+
|
| 33 |
+
# Override storage directory for tests
|
| 34 |
+
config.DATA_DIR = self.temp_data_dir
|
| 35 |
+
validators.DATA_DIR = self.temp_data_dir
|
| 36 |
+
storage_manager.DATA_DIR = self.temp_data_dir
|
| 37 |
+
|
| 38 |
+
def tearDown(self):
|
| 39 |
+
"""Clean up temporary data"""
|
| 40 |
+
if os.path.exists(self.temp_data_dir):
|
| 41 |
+
shutil.rmtree(self.temp_data_dir)
|
| 42 |
+
|
| 43 |
+
def get_headers(self, user_id=None):
|
| 44 |
+
"""Get request headers with user ID"""
|
| 45 |
+
return {'X-User-ID': user_id or self.user_id}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class HealthCheckTest(DocVaultTestCase):
|
| 49 |
+
"""Test health check endpoint"""
|
| 50 |
+
|
| 51 |
+
def test_health_check(self):
|
| 52 |
+
"""Test health check endpoint"""
|
| 53 |
+
response = self.client.get('/api/health')
|
| 54 |
+
self.assertEqual(response.status_code, 200)
|
| 55 |
+
data = json.loads(response.data)
|
| 56 |
+
self.assertEqual(data['status'], 'healthy')
|
| 57 |
+
self.assertEqual(data['service'], 'DocVault')
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class FolderOperationsTest(DocVaultTestCase):
|
| 61 |
+
"""Test folder operations"""
|
| 62 |
+
|
| 63 |
+
def test_create_folder_success(self):
|
| 64 |
+
"""Test successful folder creation"""
|
| 65 |
+
response = self.client.post(
|
| 66 |
+
'/api/create-folder',
|
| 67 |
+
json={'folder_path': 'Documents'},
|
| 68 |
+
headers=self.get_headers()
|
| 69 |
+
)
|
| 70 |
+
self.assertEqual(response.status_code, 201)
|
| 71 |
+
data = json.loads(response.data)
|
| 72 |
+
self.assertTrue(data['success'])
|
| 73 |
+
self.assertEqual(data['folder']['name'], 'Documents')
|
| 74 |
+
|
| 75 |
+
def test_create_nested_folder_success(self):
|
| 76 |
+
"""Test nested folder creation"""
|
| 77 |
+
response = self.client.post(
|
| 78 |
+
'/api/create-folder',
|
| 79 |
+
json={'folder_path': 'Documents/Projects/MyProject'},
|
| 80 |
+
headers=self.get_headers()
|
| 81 |
+
)
|
| 82 |
+
self.assertEqual(response.status_code, 201)
|
| 83 |
+
data = json.loads(response.data)
|
| 84 |
+
self.assertTrue(data['success'])
|
| 85 |
+
|
| 86 |
+
def test_create_folder_duplicate(self):
|
| 87 |
+
"""Test creating duplicate folder"""
|
| 88 |
+
self.client.post(
|
| 89 |
+
'/api/create-folder',
|
| 90 |
+
json={'folder_path': 'Documents'},
|
| 91 |
+
headers=self.get_headers()
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
response = self.client.post(
|
| 95 |
+
'/api/create-folder',
|
| 96 |
+
json={'folder_path': 'Documents'},
|
| 97 |
+
headers=self.get_headers()
|
| 98 |
+
)
|
| 99 |
+
self.assertEqual(response.status_code, 400)
|
| 100 |
+
data = json.loads(response.data)
|
| 101 |
+
self.assertFalse(data['success'])
|
| 102 |
+
self.assertEqual(data.get('code'), 'FOLDER_EXISTS')
|
| 103 |
+
|
| 104 |
+
def test_create_folder_invalid_name(self):
|
| 105 |
+
"""Test creating folder with invalid characters"""
|
| 106 |
+
response = self.client.post(
|
| 107 |
+
'/api/create-folder',
|
| 108 |
+
json={'folder_path': 'Invalid/../Folder'},
|
| 109 |
+
headers=self.get_headers()
|
| 110 |
+
)
|
| 111 |
+
self.assertEqual(response.status_code, 400)
|
| 112 |
+
data = json.loads(response.data)
|
| 113 |
+
self.assertFalse(data['success'])
|
| 114 |
+
|
| 115 |
+
def test_delete_folder_success(self):
|
| 116 |
+
"""Test successful folder deletion"""
|
| 117 |
+
# Create folder first
|
| 118 |
+
self.client.post(
|
| 119 |
+
'/api/create-folder',
|
| 120 |
+
json={'folder_path': 'ToDelete'},
|
| 121 |
+
headers=self.get_headers()
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Delete it
|
| 125 |
+
response = self.client.post(
|
| 126 |
+
'/api/delete-folder',
|
| 127 |
+
json={'folder_path': 'ToDelete'},
|
| 128 |
+
headers=self.get_headers()
|
| 129 |
+
)
|
| 130 |
+
self.assertEqual(response.status_code, 200)
|
| 131 |
+
data = json.loads(response.data)
|
| 132 |
+
self.assertTrue(data['success'])
|
| 133 |
+
|
| 134 |
+
def test_delete_nonexistent_folder(self):
|
| 135 |
+
"""Test deleting non-existent folder"""
|
| 136 |
+
response = self.client.post(
|
| 137 |
+
'/api/delete-folder',
|
| 138 |
+
json={'folder_path': 'NonExistent'},
|
| 139 |
+
headers=self.get_headers()
|
| 140 |
+
)
|
| 141 |
+
self.assertEqual(response.status_code, 400)
|
| 142 |
+
data = json.loads(response.data)
|
| 143 |
+
self.assertFalse(data['success'])
|
| 144 |
+
self.assertEqual(data.get('code'), 'FOLDER_NOT_FOUND')
|
| 145 |
+
|
| 146 |
+
def test_delete_non_empty_folder_without_force(self):
|
| 147 |
+
"""Test deleting non-empty folder without force"""
|
| 148 |
+
# Create folder with file
|
| 149 |
+
self.client.post(
|
| 150 |
+
'/api/create-folder',
|
| 151 |
+
json={'folder_path': 'WithFiles'},
|
| 152 |
+
headers=self.get_headers()
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Try to delete without force
|
| 156 |
+
response = self.client.post(
|
| 157 |
+
'/api/delete-folder',
|
| 158 |
+
json={'folder_path': 'WithFiles', 'force': False},
|
| 159 |
+
headers=self.get_headers()
|
| 160 |
+
)
|
| 161 |
+
# Should fail if folder has marker file
|
| 162 |
+
data = json.loads(response.data)
|
| 163 |
+
if not data['success']:
|
| 164 |
+
self.assertEqual(data.get('code'), 'FOLDER_NOT_EMPTY')
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class FileOperationsTest(DocVaultTestCase):
|
| 168 |
+
"""Test file operations"""
|
| 169 |
+
|
| 170 |
+
def test_upload_file_success(self):
|
| 171 |
+
"""Test successful file upload"""
|
| 172 |
+
# Create folder first
|
| 173 |
+
self.client.post(
|
| 174 |
+
'/api/create-folder',
|
| 175 |
+
json={'folder_path': 'Documents'},
|
| 176 |
+
headers=self.get_headers()
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# Upload file
|
| 180 |
+
data = {'folder_path': 'Documents', 'file': (io.BytesIO(b'Hello World'), 'test.txt')}
|
| 181 |
+
response = self.client.post(
|
| 182 |
+
'/api/upload-file',
|
| 183 |
+
data=data,
|
| 184 |
+
headers=self.get_headers()
|
| 185 |
+
)
|
| 186 |
+
self.assertEqual(response.status_code, 201)
|
| 187 |
+
response_data = json.loads(response.data)
|
| 188 |
+
self.assertTrue(response_data['success'])
|
| 189 |
+
self.assertEqual(response_data['file']['name'], 'test.txt')
|
| 190 |
+
|
| 191 |
+
def test_upload_file_to_nonexistent_folder(self):
|
| 192 |
+
"""Test uploading file to non-existent folder (should create)"""
|
| 193 |
+
data = {'folder_path': 'NewFolder', 'file': (io.BytesIO(b'Hello World'), 'test.txt')}
|
| 194 |
+
response = self.client.post(
|
| 195 |
+
'/api/upload-file',
|
| 196 |
+
data=data,
|
| 197 |
+
headers=self.get_headers()
|
| 198 |
+
)
|
| 199 |
+
self.assertEqual(response.status_code, 201)
|
| 200 |
+
response_data = json.loads(response.data)
|
| 201 |
+
self.assertTrue(response_data['success'])
|
| 202 |
+
|
| 203 |
+
def test_upload_file_no_file_provided(self):
|
| 204 |
+
"""Test upload with no file"""
|
| 205 |
+
response = self.client.post(
|
| 206 |
+
'/api/upload-file',
|
| 207 |
+
data={'folder_path': 'Documents'},
|
| 208 |
+
headers=self.get_headers()
|
| 209 |
+
)
|
| 210 |
+
self.assertEqual(response.status_code, 400)
|
| 211 |
+
data = json.loads(response.data)
|
| 212 |
+
self.assertFalse(data['success'])
|
| 213 |
+
|
| 214 |
+
def test_upload_file_restricted_extension(self):
|
| 215 |
+
"""Test uploading file with restricted extension"""
|
| 216 |
+
data = {'folder_path': 'Documents', 'file': (io.BytesIO(b'malware'), 'virus.exe')}
|
| 217 |
+
response = self.client.post(
|
| 218 |
+
'/api/upload-file',
|
| 219 |
+
data=data,
|
| 220 |
+
headers=self.get_headers()
|
| 221 |
+
)
|
| 222 |
+
self.assertEqual(response.status_code, 400)
|
| 223 |
+
response_data = json.loads(response.data)
|
| 224 |
+
self.assertFalse(response_data['success'])
|
| 225 |
+
|
| 226 |
+
def test_delete_file_success(self):
|
| 227 |
+
"""Test successful file deletion"""
|
| 228 |
+
self.client.post(
|
| 229 |
+
'/api/create-folder',
|
| 230 |
+
json={'folder_path': 'Documents'},
|
| 231 |
+
headers=self.get_headers()
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
self.client.post(
|
| 235 |
+
'/api/upload-file',
|
| 236 |
+
data={'folder_path': 'Documents', 'file': (io.BytesIO(b'Hello World'), 'test.txt')},
|
| 237 |
+
headers=self.get_headers()
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
response = self.client.post(
|
| 241 |
+
'/api/delete-file',
|
| 242 |
+
json={'file_path': 'Documents/test.txt'},
|
| 243 |
+
headers=self.get_headers()
|
| 244 |
+
)
|
| 245 |
+
self.assertEqual(response.status_code, 200)
|
| 246 |
+
response_data = json.loads(response.data)
|
| 247 |
+
self.assertTrue(response_data['success'])
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
class ListOperationsTest(DocVaultTestCase):
|
| 251 |
+
"""Test listing operations"""
|
| 252 |
+
|
| 253 |
+
def test_list_empty_root(self):
|
| 254 |
+
"""Test listing empty root"""
|
| 255 |
+
response = self.client.get(
|
| 256 |
+
'/api/list',
|
| 257 |
+
headers=self.get_headers()
|
| 258 |
+
)
|
| 259 |
+
self.assertEqual(response.status_code, 200)
|
| 260 |
+
data = json.loads(response.data)
|
| 261 |
+
self.assertTrue(data['success'])
|
| 262 |
+
self.assertEqual(data['total_folders'], 0)
|
| 263 |
+
self.assertEqual(data['total_files'], 0)
|
| 264 |
+
|
| 265 |
+
def test_list_with_folders(self):
|
| 266 |
+
"""Test listing with folders"""
|
| 267 |
+
# Create folders
|
| 268 |
+
self.client.post(
|
| 269 |
+
'/api/create-folder',
|
| 270 |
+
json={'folder_path': 'Docs'},
|
| 271 |
+
headers=self.get_headers()
|
| 272 |
+
)
|
| 273 |
+
self.client.post(
|
| 274 |
+
'/api/create-folder',
|
| 275 |
+
json={'folder_path': 'Images'},
|
| 276 |
+
headers=self.get_headers()
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# List
|
| 280 |
+
response = self.client.get(
|
| 281 |
+
'/api/list',
|
| 282 |
+
headers=self.get_headers()
|
| 283 |
+
)
|
| 284 |
+
self.assertEqual(response.status_code, 200)
|
| 285 |
+
data = json.loads(response.data)
|
| 286 |
+
self.assertTrue(data['success'])
|
| 287 |
+
self.assertGreater(data['total_folders'], 0)
|
| 288 |
+
|
| 289 |
+
def test_list_with_files(self):
|
| 290 |
+
"""Test listing with files"""
|
| 291 |
+
# Create folder and upload file
|
| 292 |
+
self.client.post(
|
| 293 |
+
'/api/create-folder',
|
| 294 |
+
json={'folder_path': 'Documents'},
|
| 295 |
+
headers=self.get_headers()
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
self.client.post(
|
| 299 |
+
'/api/upload-file',
|
| 300 |
+
data={'folder_path': 'Documents', 'file': (io.BytesIO(b'# Read Me'), 'readme.md')},
|
| 301 |
+
headers=self.get_headers()
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# List subfolder
|
| 305 |
+
response = self.client.get(
|
| 306 |
+
'/api/list?folder_path=Documents',
|
| 307 |
+
headers=self.get_headers()
|
| 308 |
+
)
|
| 309 |
+
self.assertEqual(response.status_code, 200)
|
| 310 |
+
data = json.loads(response.data)
|
| 311 |
+
self.assertTrue(data['success'])
|
| 312 |
+
self.assertGreater(data['total_files'], 0)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
class RenameOperationsTest(DocVaultTestCase):
|
| 316 |
+
"""Test rename operations"""
|
| 317 |
+
|
| 318 |
+
def test_rename_folder_success(self):
|
| 319 |
+
"""Test successful folder rename"""
|
| 320 |
+
# Create folder
|
| 321 |
+
self.client.post(
|
| 322 |
+
'/api/create-folder',
|
| 323 |
+
json={'folder_path': 'OldName'},
|
| 324 |
+
headers=self.get_headers()
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Rename it
|
| 328 |
+
response = self.client.post(
|
| 329 |
+
'/api/rename',
|
| 330 |
+
json={'item_path': 'OldName', 'new_name': 'NewName'},
|
| 331 |
+
headers=self.get_headers()
|
| 332 |
+
)
|
| 333 |
+
self.assertEqual(response.status_code, 200)
|
| 334 |
+
data = json.loads(response.data)
|
| 335 |
+
self.assertTrue(data['success'])
|
| 336 |
+
self.assertEqual(data['item']['name'], 'NewName')
|
| 337 |
+
|
| 338 |
+
def test_rename_file_success(self):
|
| 339 |
+
"""Test successful file rename"""
|
| 340 |
+
# Create folder and upload file
|
| 341 |
+
self.client.post(
|
| 342 |
+
'/api/create-folder',
|
| 343 |
+
json={'folder_path': 'Documents'},
|
| 344 |
+
headers=self.get_headers()
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
self.client.post(
|
| 348 |
+
'/api/upload-file',
|
| 349 |
+
data={'folder_path': 'Documents', 'file': (io.BytesIO(b'content'), 'oldname.txt')},
|
| 350 |
+
headers=self.get_headers()
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
# Rename
|
| 354 |
+
response = self.client.post(
|
| 355 |
+
'/api/rename',
|
| 356 |
+
json={'item_path': 'Documents/oldname.txt', 'new_name': 'newname.txt'},
|
| 357 |
+
headers=self.get_headers()
|
| 358 |
+
)
|
| 359 |
+
self.assertEqual(response.status_code, 200)
|
| 360 |
+
data = json.loads(response.data)
|
| 361 |
+
self.assertTrue(data['success'])
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class StorageStatsTest(DocVaultTestCase):
|
| 365 |
+
"""Test storage statistics"""
|
| 366 |
+
|
| 367 |
+
def test_storage_stats(self):
|
| 368 |
+
"""Test storage statistics endpoint"""
|
| 369 |
+
response = self.client.get(
|
| 370 |
+
'/api/storage-stats',
|
| 371 |
+
headers=self.get_headers()
|
| 372 |
+
)
|
| 373 |
+
self.assertEqual(response.status_code, 200)
|
| 374 |
+
data = json.loads(response.data)
|
| 375 |
+
self.assertTrue(data['success'])
|
| 376 |
+
self.assertIn('total_size', data)
|
| 377 |
+
self.assertIn('total_files', data)
|
| 378 |
+
self.assertIn('total_folders', data)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
class SecurityTest(DocVaultTestCase):
|
| 382 |
+
"""Test security features"""
|
| 383 |
+
|
| 384 |
+
def test_path_traversal_prevention(self):
|
| 385 |
+
"""Test path traversal attack prevention"""
|
| 386 |
+
response = self.client.post(
|
| 387 |
+
'/api/create-folder',
|
| 388 |
+
json={'folder_path': '../../etc/passwd'},
|
| 389 |
+
headers=self.get_headers()
|
| 390 |
+
)
|
| 391 |
+
self.assertEqual(response.status_code, 400)
|
| 392 |
+
data = json.loads(response.data)
|
| 393 |
+
self.assertFalse(data['success'])
|
| 394 |
+
|
| 395 |
+
def test_invalid_folder_name(self):
|
| 396 |
+
"""Test invalid folder name"""
|
| 397 |
+
response = self.client.post(
|
| 398 |
+
'/api/create-folder',
|
| 399 |
+
json={'folder_path': 'Folder With Spaces'},
|
| 400 |
+
headers=self.get_headers()
|
| 401 |
+
)
|
| 402 |
+
# Should fail or sanitize (depends on implementation)
|
| 403 |
+
data = json.loads(response.data)
|
| 404 |
+
# Either it fails or creates with sanitized name
|
| 405 |
+
self.assertIsNotNone(data)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def run_tests():
|
| 409 |
+
"""Run all tests"""
|
| 410 |
+
loader = unittest.TestLoader()
|
| 411 |
+
suite = unittest.TestSuite()
|
| 412 |
+
|
| 413 |
+
# Add test classes
|
| 414 |
+
suite.addTests(loader.loadTestsFromTestCase(HealthCheckTest))
|
| 415 |
+
suite.addTests(loader.loadTestsFromTestCase(FolderOperationsTest))
|
| 416 |
+
suite.addTests(loader.loadTestsFromTestCase(FileOperationsTest))
|
| 417 |
+
suite.addTests(loader.loadTestsFromTestCase(ListOperationsTest))
|
| 418 |
+
suite.addTests(loader.loadTestsFromTestCase(RenameOperationsTest))
|
| 419 |
+
suite.addTests(loader.loadTestsFromTestCase(StorageStatsTest))
|
| 420 |
+
suite.addTests(loader.loadTestsFromTestCase(SecurityTest))
|
| 421 |
+
|
| 422 |
+
runner = unittest.TextTestRunner(verbosity=2)
|
| 423 |
+
result = runner.run(suite)
|
| 424 |
+
|
| 425 |
+
return result.wasSuccessful()
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if __name__ == '__main__':
|
| 429 |
+
success = run_tests()
|
| 430 |
+
sys.exit(0 if success else 1)
|
tests/test_file.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Test file content for DocVault testing purposes.
|
| 2 |
+
This is a sample text file used to test file upload functionality.
|