File Sync and repoint to WebDAV folder
This commit is contained in:
@@ -9,13 +9,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
|
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
|
||||||
FILE_STORAGE_PATH: /opt/lifeos/files/prod
|
FILE_STORAGE_PATH: /opt/lifeos/webdav
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
|
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8002:8002"
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/lifeos/prod/files:/opt/lifeos/files/prod
|
- /opt/lifeos/webdav:/opt/lifeos/webdav
|
||||||
networks:
|
networks:
|
||||||
- lifeos_network
|
- lifeos_network
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -29,13 +29,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
|
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
|
||||||
FILE_STORAGE_PATH: /opt/lifeos/files/dev
|
FILE_STORAGE_PATH: /opt/lifeos/webdav
|
||||||
ENVIRONMENT: development
|
ENVIRONMENT: development
|
||||||
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
|
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
|
||||||
ports:
|
ports:
|
||||||
- "8003:8003"
|
- "8003:8003"
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/lifeos/dev/files:/opt/lifeos/files/dev
|
- /opt/lifeos/webdav:/opt/lifeos/webdav
|
||||||
- .:/app # hot reload in dev
|
- .:/app # hot reload in dev
|
||||||
networks:
|
networks:
|
||||||
- lifeos_network
|
- lifeos_network
|
||||||
|
|||||||
225
routers/files.py
225
routers/files.py
@@ -1,12 +1,11 @@
|
|||||||
"""Files: upload, download, list, preview, and polymorphic entity attachment."""
|
"""Files: upload, download, list, preview, folder-aware storage, and WebDAV sync."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import mimetypes
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
|
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import RedirectResponse, FileResponse
|
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -18,7 +17,7 @@ from core.sidebar import get_sidebar_data
|
|||||||
router = APIRouter(prefix="/files", tags=["files"])
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev")
|
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/webdav")
|
||||||
|
|
||||||
# Ensure storage dir exists
|
# Ensure storage dir exists
|
||||||
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
|
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -29,16 +28,120 @@ PREVIEWABLE = {
|
|||||||
"application/pdf", "text/plain", "text/html", "text/csv",
|
"application/pdf", "text/plain", "text/html", "text/csv",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Files to skip during sync
|
||||||
|
SKIP_FILES = {".DS_Store", "Thumbs.db", ".gitkeep", "desktop.ini"}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(item):
|
||||||
|
"""Resolve a DB record's relative storage_path to an absolute path."""
|
||||||
|
return os.path.join(FILE_STORAGE_PATH, item["storage_path"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_folders():
|
||||||
|
"""Walk FILE_STORAGE_PATH and return sorted list of relative folder paths."""
|
||||||
|
folders = []
|
||||||
|
for dirpath, dirnames, _filenames in os.walk(FILE_STORAGE_PATH):
|
||||||
|
# Skip hidden directories
|
||||||
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
|
rel = os.path.relpath(dirpath, FILE_STORAGE_PATH)
|
||||||
|
if rel != ".":
|
||||||
|
folders.append(rel)
|
||||||
|
return sorted(folders)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_collision(folder_abs, filename):
|
||||||
|
"""If filename exists in folder_abs, return name (2).ext, name (3).ext, etc."""
|
||||||
|
target = os.path.join(folder_abs, filename)
|
||||||
|
if not os.path.exists(target):
|
||||||
|
return filename
|
||||||
|
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
counter = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{name} ({counter}){ext}"
|
||||||
|
if not os.path.exists(os.path.join(folder_abs, candidate)):
|
||||||
|
return candidate
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_files(db: AsyncSession):
|
||||||
|
"""Sync filesystem state with the database.
|
||||||
|
|
||||||
|
- Files on disk not in DB → create record
|
||||||
|
- Active DB records with missing files → soft-delete
|
||||||
|
Returns dict with added/removed counts.
|
||||||
|
"""
|
||||||
|
added = 0
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
# Build set of all relative file paths on disk
|
||||||
|
disk_files = set()
|
||||||
|
for dirpath, dirnames, filenames in os.walk(FILE_STORAGE_PATH):
|
||||||
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
|
for fname in filenames:
|
||||||
|
if fname in SKIP_FILES or fname.startswith("."):
|
||||||
|
continue
|
||||||
|
abs_path = os.path.join(dirpath, fname)
|
||||||
|
rel_path = os.path.relpath(abs_path, FILE_STORAGE_PATH)
|
||||||
|
disk_files.add(rel_path)
|
||||||
|
|
||||||
|
# Get ALL DB records (including soft-deleted) to avoid re-creating deleted files
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT id, storage_path, is_deleted FROM files"
|
||||||
|
))
|
||||||
|
db_records = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Build lookup sets
|
||||||
|
all_db_paths = {r["storage_path"] for r in db_records}
|
||||||
|
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
|
||||||
|
|
||||||
|
# New on disk, not in DB at all → create record
|
||||||
|
new_files = disk_files - all_db_paths
|
||||||
|
for rel_path in new_files:
|
||||||
|
abs_path = os.path.join(FILE_STORAGE_PATH, rel_path)
|
||||||
|
filename = os.path.basename(rel_path)
|
||||||
|
mime_type = mimetypes.guess_type(filename)[0]
|
||||||
|
try:
|
||||||
|
size_bytes = os.path.getsize(abs_path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
repo = BaseRepository("files", db)
|
||||||
|
await repo.create({
|
||||||
|
"filename": filename,
|
||||||
|
"original_filename": filename,
|
||||||
|
"storage_path": rel_path,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# Active in DB but missing from disk → soft-delete
|
||||||
|
missing_files = active_db_paths - disk_files
|
||||||
|
for record in db_records:
|
||||||
|
if record["storage_path"] in missing_files and not record["is_deleted"]:
|
||||||
|
repo = BaseRepository("files", db)
|
||||||
|
await repo.soft_delete(record["id"])
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
return {"added": added, "removed": removed}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_files(
|
async def list_files(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
folder: Optional[str] = None,
|
||||||
context_type: Optional[str] = None,
|
context_type: Optional[str] = None,
|
||||||
context_id: Optional[str] = None,
|
context_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
# Auto-sync on page load
|
||||||
|
sync_result = await sync_files(db)
|
||||||
|
|
||||||
|
folders = get_folders()
|
||||||
|
|
||||||
if context_type and context_id:
|
if context_type and context_id:
|
||||||
# Files attached to a specific entity
|
# Files attached to a specific entity
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
@@ -48,19 +151,40 @@ async def list_files(
|
|||||||
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
|
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
"""), {"ct": context_type, "cid": context_id})
|
"""), {"ct": context_type, "cid": context_id})
|
||||||
|
elif folder is not None:
|
||||||
|
if folder == "":
|
||||||
|
# Root folder: files with no directory separator in storage_path
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""))
|
||||||
|
else:
|
||||||
|
# Specific folder: storage_path starts with folder/
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE is_deleted = false AND storage_path LIKE :prefix
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""), {"prefix": folder + "/%"})
|
||||||
else:
|
else:
|
||||||
# All files
|
# All files
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT f.* FROM files f
|
SELECT * FROM files
|
||||||
WHERE f.is_deleted = false
|
WHERE is_deleted = false
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 100
|
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Add derived folder field for display
|
||||||
|
for item in items:
|
||||||
|
dirname = os.path.dirname(item["storage_path"])
|
||||||
|
item["folder"] = dirname if dirname else "/"
|
||||||
|
|
||||||
return templates.TemplateResponse("files.html", {
|
return templates.TemplateResponse("files.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
|
"folders": folders, "current_folder": folder,
|
||||||
|
"sync_result": sync_result,
|
||||||
"context_type": context_type or "",
|
"context_type": context_type or "",
|
||||||
"context_id": context_id or "",
|
"context_id": context_id or "",
|
||||||
"page_title": "Files", "active_nav": "files",
|
"page_title": "Files", "active_nav": "files",
|
||||||
@@ -70,13 +194,16 @@ async def list_files(
|
|||||||
@router.get("/upload")
|
@router.get("/upload")
|
||||||
async def upload_form(
|
async def upload_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
folder: Optional[str] = None,
|
||||||
context_type: Optional[str] = None,
|
context_type: Optional[str] = None,
|
||||||
context_id: Optional[str] = None,
|
context_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
|
folders = get_folders()
|
||||||
return templates.TemplateResponse("file_upload.html", {
|
return templates.TemplateResponse("file_upload.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
|
"folders": folders, "prefill_folder": folder or "",
|
||||||
"context_type": context_type or "",
|
"context_type": context_type or "",
|
||||||
"context_id": context_id or "",
|
"context_id": context_id or "",
|
||||||
"page_title": "Upload File", "active_nav": "files",
|
"page_title": "Upload File", "active_nav": "files",
|
||||||
@@ -89,19 +216,41 @@ async def upload_file(
|
|||||||
file: UploadFile = FastAPIFile(...),
|
file: UploadFile = FastAPIFile(...),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
|
folder: Optional[str] = Form(None),
|
||||||
|
new_folder: Optional[str] = Form(None),
|
||||||
context_type: Optional[str] = Form(None),
|
context_type: Optional[str] = Form(None),
|
||||||
context_id: Optional[str] = Form(None),
|
context_id: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Generate storage filename
|
# Determine target folder
|
||||||
file_uuid = str(uuid.uuid4())
|
target_folder = ""
|
||||||
|
if new_folder and new_folder.strip():
|
||||||
|
target_folder = new_folder.strip().strip("/")
|
||||||
|
elif folder and folder.strip():
|
||||||
|
target_folder = folder.strip()
|
||||||
|
|
||||||
|
# Build absolute folder path and ensure it exists
|
||||||
|
if target_folder:
|
||||||
|
folder_abs = os.path.join(FILE_STORAGE_PATH, target_folder)
|
||||||
|
else:
|
||||||
|
folder_abs = FILE_STORAGE_PATH
|
||||||
|
os.makedirs(folder_abs, exist_ok=True)
|
||||||
|
|
||||||
|
# Use original filename, handle collisions
|
||||||
original = file.filename or "unknown"
|
original = file.filename or "unknown"
|
||||||
safe_name = original.replace("/", "_").replace("\\", "_")
|
safe_name = original.replace("/", "_").replace("\\", "_")
|
||||||
storage_name = f"{file_uuid}_{safe_name}"
|
final_name = resolve_collision(folder_abs, safe_name)
|
||||||
storage_path = os.path.join(FILE_STORAGE_PATH, storage_name)
|
|
||||||
|
# Build relative storage path
|
||||||
|
if target_folder:
|
||||||
|
storage_path = os.path.join(target_folder, final_name)
|
||||||
|
else:
|
||||||
|
storage_path = final_name
|
||||||
|
|
||||||
|
abs_path = os.path.join(FILE_STORAGE_PATH, storage_path)
|
||||||
|
|
||||||
# Save to disk
|
# Save to disk
|
||||||
with open(storage_path, "wb") as f:
|
with open(abs_path, "wb") as f:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
@@ -110,7 +259,7 @@ async def upload_file(
|
|||||||
# Insert file record
|
# Insert file record
|
||||||
repo = BaseRepository("files", db)
|
repo = BaseRepository("files", db)
|
||||||
data = {
|
data = {
|
||||||
"filename": storage_name,
|
"filename": final_name,
|
||||||
"original_filename": original,
|
"original_filename": original,
|
||||||
"storage_path": storage_path,
|
"storage_path": storage_path,
|
||||||
"mime_type": file.content_type,
|
"mime_type": file.content_type,
|
||||||
@@ -139,15 +288,26 @@ async def upload_file(
|
|||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync")
|
||||||
|
async def manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Manual sync trigger."""
|
||||||
|
await sync_files(db)
|
||||||
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{file_id}/download")
|
@router.get("/{file_id}/download")
|
||||||
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
|
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("files", db)
|
repo = BaseRepository("files", db)
|
||||||
item = await repo.get(file_id)
|
item = await repo.get(file_id)
|
||||||
if not item or not os.path.exists(item["storage_path"]):
|
if not item:
|
||||||
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
abs_path = _resolve_path(item)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=item["storage_path"],
|
path=abs_path,
|
||||||
filename=item["original_filename"],
|
filename=item["original_filename"],
|
||||||
media_type=item.get("mime_type") or "application/octet-stream",
|
media_type=item.get("mime_type") or "application/octet-stream",
|
||||||
)
|
)
|
||||||
@@ -163,10 +323,12 @@ async def preview_file(file_id: str, request: Request, db: AsyncSession = Depend
|
|||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
can_preview = item.get("mime_type", "") in PREVIEWABLE
|
can_preview = item.get("mime_type", "") in PREVIEWABLE
|
||||||
|
folder = os.path.dirname(item["storage_path"])
|
||||||
|
|
||||||
return templates.TemplateResponse("file_preview.html", {
|
return templates.TemplateResponse("file_preview.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"can_preview": can_preview,
|
"can_preview": can_preview,
|
||||||
|
"folder": folder if folder else "/",
|
||||||
"page_title": item["original_filename"], "active_nav": "files",
|
"page_title": item["original_filename"], "active_nav": "files",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -176,13 +338,34 @@ async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
"""Serve file inline (for img src, iframe, etc)."""
|
"""Serve file inline (for img src, iframe, etc)."""
|
||||||
repo = BaseRepository("files", db)
|
repo = BaseRepository("files", db)
|
||||||
item = await repo.get(file_id)
|
item = await repo.get(file_id)
|
||||||
if not item or not os.path.exists(item["storage_path"]):
|
if not item:
|
||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
return FileResponse(
|
abs_path = _resolve_path(item)
|
||||||
path=item["storage_path"],
|
if not os.path.exists(abs_path):
|
||||||
media_type=item.get("mime_type") or "application/octet-stream",
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
mime = item.get("mime_type") or "application/octet-stream"
|
||||||
|
|
||||||
|
# Wrap text files in HTML with forced white background / dark text
|
||||||
|
if mime.startswith("text/"):
|
||||||
|
try:
|
||||||
|
with open(abs_path, "r", errors="replace") as f:
|
||||||
|
text_content = f.read()
|
||||||
|
except Exception:
|
||||||
|
return FileResponse(path=abs_path, media_type=mime)
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
html = (
|
||||||
|
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
||||||
|
'<style>body{background:#fff;color:#1a1a1a;font-family:monospace;'
|
||||||
|
'font-size:14px;padding:16px;margin:0;white-space:pre-wrap;'
|
||||||
|
'word-wrap:break-word;}</style></head><body>'
|
||||||
|
f'{escape(text_content)}</body></html>'
|
||||||
)
|
)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
return FileResponse(path=abs_path, media_type=mime)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/delete")
|
@router.post("/{file_id}/delete")
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a href="/files">Files</a>
|
<a href="/files">Files</a>
|
||||||
<span class="sep">/</span>
|
<span class="sep">/</span>
|
||||||
|
{% if folder and folder != '/' %}
|
||||||
|
<a href="/files?folder={{ folder }}">{{ folder }}</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
{% endif %}
|
||||||
<span>{{ item.original_filename }}</span>
|
<span>{{ item.original_filename }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,6 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-meta mt-2">
|
<div class="detail-meta mt-2">
|
||||||
|
<span class="detail-meta-item">Folder: {{ folder }}</span>
|
||||||
{% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %}
|
{% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %}
|
||||||
{% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %}
|
{% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %}
|
||||||
{% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
|
{% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
|
||||||
@@ -34,7 +39,7 @@
|
|||||||
{% elif item.mime_type == 'application/pdf' %}
|
{% elif item.mime_type == 'application/pdf' %}
|
||||||
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe>
|
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe>
|
||||||
{% elif item.mime_type and item.mime_type.startswith('text/') %}
|
{% elif item.mime_type and item.mime_type.startswith('text/') %}
|
||||||
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2);"></iframe>
|
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: #fff;"></iframe>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -17,6 +17,21 @@
|
|||||||
<input type="file" name="file" class="form-input" required>
|
<input type="file" name="file" class="form-input" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Folder</label>
|
||||||
|
<select name="folder" class="form-input">
|
||||||
|
<option value="">/ (root)</option>
|
||||||
|
{% for f in folders %}
|
||||||
|
<option value="{{ f }}" {{ 'selected' if prefill_folder == f }}>{{ f }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Folder</label>
|
||||||
|
<input type="text" name="new_folder" class="form-input" placeholder="Or create new folder...">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<input type="text" name="description" class="form-input" placeholder="Optional description...">
|
<input type="text" name="description" class="form-input" placeholder="Optional description...">
|
||||||
|
|||||||
@@ -2,9 +2,30 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
|
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
|
||||||
<a href="/files/upload{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a>
|
<div class="flex gap-2">
|
||||||
|
<form action="/files/sync" method="post" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-secondary">Sync Files</button>
|
||||||
|
</form>
|
||||||
|
<a href="/files/upload{{ '?folder=' ~ current_folder if current_folder }}{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if sync_result and (sync_result.added > 0 or sync_result.removed > 0) %}
|
||||||
|
<div class="flash-message" style="background: var(--accent-soft); border: 1px solid var(--accent); border-radius: var(--radius); padding: 8px 12px; margin-bottom: 16px; color: var(--text); font-size: 0.85rem;">
|
||||||
|
Synced: {{ sync_result.added }} file{{ 's' if sync_result.added != 1 }} added, {{ sync_result.removed }} removed
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if folders %}
|
||||||
|
<div class="filter-bar" style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
|
||||||
|
<a href="/files" class="btn btn-xs {{ 'btn-primary' if current_folder is none else 'btn-ghost' }}">All</a>
|
||||||
|
<a href="/files?folder=" class="btn btn-xs {{ 'btn-primary' if current_folder is not none and current_folder == '' else 'btn-ghost' }}">/</a>
|
||||||
|
{% for f in folders %}
|
||||||
|
<a href="/files?folder={{ f }}" class="btn btn-xs {{ 'btn-primary' if current_folder == f else 'btn-ghost' }}">{{ f }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
@@ -12,6 +33,7 @@
|
|||||||
<span class="row-title">
|
<span class="row-title">
|
||||||
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
|
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="row-meta" style="color: var(--muted); font-size: 0.8rem;">{{ item.folder }}</span>
|
||||||
{% if item.mime_type %}
|
{% if item.mime_type %}
|
||||||
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
|
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -33,7 +55,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📁</div>
|
<div class="empty-state-icon">📁</div>
|
||||||
<div class="empty-state-text">No files uploaded yet</div>
|
<div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
|
||||||
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
|
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
BIN
webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx
Normal file
BIN
webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user