feat: drag-and-drop interne, file viewer multi-type, sidebar persistante
1. Drag & drop interne: déplacer fichiers/dossiers existants vers d'autres
dossiers ou racine via l'API /api/local-workspace/items/{id}/move.
Items rendus draggable dans le template x-for et renderChildren.
2. File viewer pour fichiers uploadés: détection content_format='file',
visualiseur intégré avec rendu adapté au type MIME:
- Images: affichage direct
- PDF: iframe
- Texte/Code (.py .js .md .html .ps1 etc.): <pre> avec fetch du contenu
- Autres: lien de téléchargement
Route GET /api/files/{ws_id}/{filename:path} pour servir les fichiers.
3. Sidebar persistante: expandedFolders sauvegardé dans localStorage,
restauré au chargement, préservé avant navigation vers un dossier.
4. Suppression: nettoie aussi le fichier disque pour content_format='file'
This commit is contained in:
+121
-1
@@ -811,7 +811,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML."""
|
||||
"""Render a page as HTML, or a file viewer for uploaded files."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
with get_conn() as conn:
|
||||
@@ -819,6 +819,11 @@ async def view_page(request: Request, page_id: int):
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
|
||||
# ── File viewer for uploaded files ──
|
||||
if page.get("content_format") == "file":
|
||||
return _render_file_viewer(page, request)
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
@@ -838,6 +843,121 @@ async def view_page(request: Request, page_id: int):
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
def _render_file_viewer(page: dict, request: Request) -> HTMLResponse:
|
||||
"""Render a file viewer for uploaded files (content_format='file')."""
|
||||
import json
|
||||
page_id = page["id"]
|
||||
title = page.get("title", "File")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws_name[0].upper() if ws_name else "B"
|
||||
|
||||
try:
|
||||
meta = json.loads(page.get("content", "{}"))
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
size = meta.get("size", 0)
|
||||
|
||||
# Build workspace_id from file_path
|
||||
parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
if len(parts) >= 3 and parts[2].startswith("workspace_"):
|
||||
ws_id = parts[2].replace("workspace_", "")
|
||||
filename = parts[-1] if parts else title
|
||||
|
||||
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
||||
|
||||
# Determine viewer type
|
||||
is_image = mime_type.startswith("image/")
|
||||
is_pdf = mime_type == "application/pdf"
|
||||
is_text = mime_type.startswith("text/") or mime_type in (
|
||||
"application/json", "application/javascript", "application/xml",
|
||||
"application/x-python", "application/x-sh"
|
||||
)
|
||||
is_code = any(ext in (filename or "").lower() for ext in (
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".htm", ".css",
|
||||
".json", ".xml", ".yaml", ".yml", ".md", ".sql", ".sh", ".bash",
|
||||
".ps1", ".bat", ".cmd", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
|
||||
".h", ".hpp", ".php", ".swift", ".kt", ".scala", ".r", ".toml",
|
||||
".ini", ".cfg", ".conf", ".env", ".dockerfile", ".makefile"
|
||||
))
|
||||
if is_code:
|
||||
is_text = True
|
||||
|
||||
# Determine language class for syntax highlighting
|
||||
ext_map = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
".html": "html", ".css": "css", ".json": "json", ".md": "markdown",
|
||||
".sql": "sql", ".sh": "bash", ".ps1": "powershell", ".rs": "rust",
|
||||
".go": "go", ".java": "java", ".rb": "ruby", ".xml": "xml",
|
||||
".yaml": "yaml", ".yml": "yaml", ".toml": "toml",
|
||||
}
|
||||
lang = "plaintext"
|
||||
for ext, name in ext_map.items():
|
||||
if (filename or "").lower().endswith(ext):
|
||||
lang = name
|
||||
break
|
||||
|
||||
size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB" if size < 1024*1024 else f"{size/1024/1024:.1f} MB"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
:root{{--bg:#191919;--bg2:#242424;--text:#e0e0e0;--text2:#999;--accent:#2383e2;--border:#333;}}
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;}}
|
||||
.topbar{{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--bg2);border-bottom:1px solid var(--border);}}
|
||||
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:none;border:none;cursor:pointer;font-size:13px;padding:6px 10px;border-radius:6px;}}
|
||||
.topbar a:hover,.topbar button:hover{{background:var(--bg);color:var(--text);}}
|
||||
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;}}
|
||||
.meta{{font-size:12px;color:var(--text2);}}
|
||||
.viewer{{padding:0;max-width:100%;margin:0 auto;}}
|
||||
.viewer img{{max-width:100%;max-height:85vh;display:block;margin:0 auto;}}
|
||||
.viewer pre{{margin:0;padding:20px;background:var(--bg);color:var(--text);font-size:13px;line-height:1.6;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono',monospace;white-space:pre-wrap;word-wrap:break-word;}}
|
||||
.viewer iframe{{width:100%;height:90vh;border:none;}}
|
||||
.unsupported{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;text-align:center;}}
|
||||
.unsupported .icon{{font-size:64px;margin-bottom:16px;}}
|
||||
.unsupported h2{{font-size:20px;margin-bottom:8px;}}
|
||||
.unsupported p{{color:var(--text2);margin-bottom:20px;}}
|
||||
.unsupported a{{display:inline-block;padding:10px 24px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-weight:500;}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<a href="/local-workspace">← Workspace</a>
|
||||
<span class="title">📄 {title}</span>
|
||||
<span class="meta">{size_str} · {mime_type}</span>
|
||||
<a href="{file_url}" download>⬇ Download</a>
|
||||
</div>
|
||||
<div class="viewer">"""
|
||||
|
||||
if is_image:
|
||||
html += f'<img src="{file_url}" alt="{title}">'
|
||||
elif is_pdf:
|
||||
html += f'<iframe src="{file_url}"></iframe>'
|
||||
elif is_text:
|
||||
# Fetch and embed the content as text
|
||||
html += f'<pre class="language-{lang}"><code id="code-content">Loading...</code></pre>'
|
||||
html += f'''<script>
|
||||
fetch("{file_url}").then(r=>r.text()).then(t=>{{
|
||||
document.getElementById("code-content").textContent=t;
|
||||
}}).catch(e=>{{document.getElementById("code-content").textContent="Error loading file: "+e;}});
|
||||
</script>'''
|
||||
else:
|
||||
html += f'''<div class="unsupported">
|
||||
<div class="icon">📎</div>
|
||||
<h2>{title}</h2>
|
||||
<p>{size_str} · {mime_type}<br>This file type cannot be previewed.</p>
|
||||
<a href="{file_url}" download>⬇ Download</a>
|
||||
</div>'''
|
||||
|
||||
html += "</div></body></html>"
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
|
||||
|
||||
@@ -182,15 +182,20 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context."""
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.routers.board import _render_file_viewer
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
|
||||
# ── File viewer for uploaded files ──
|
||||
if page.get("content_format") == "file":
|
||||
return _render_file_viewer(page, request)
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
@@ -645,13 +650,41 @@ async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Delete a file (and its children)."""
|
||||
"""Delete a file (and its children). Also removes uploaded file from disk."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
with get_conn() as conn:
|
||||
# Check if it's a file-type page with a disk file
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone()
|
||||
if row and row["content_format"] == "file":
|
||||
try:
|
||||
meta = json.loads(row["content"])
|
||||
fp = Path(f"/data/{meta['file_path']}")
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
from pathlib import Path
|
||||
import mimetypes
|
||||
fp = Path(f"/data/uploads/workspace_{ws_id}/{filename}").resolve()
|
||||
if not str(fp).startswith(f"/data/uploads/workspace_{ws_id}/"):
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not fp.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
mime, _ = mimetypes.guess_type(str(fp))
|
||||
content = fp.read_bytes()
|
||||
from fastapi.responses import Response
|
||||
return Response(content=content, media_type=mime or "application/octet-stream")
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
async def move_local_workspace_item(request: Request, item_id: int):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
|
||||
@@ -803,12 +803,20 @@
|
||||
},
|
||||
|
||||
// ── Tree expand/collapse + folder navigation ──
|
||||
expandedFolders: {},
|
||||
expandedFolders: (function() {
|
||||
try { return JSON.parse(localStorage.getItem('fd_expanded_folders') || '{}'); }
|
||||
catch(e) { return {}; }
|
||||
})(),
|
||||
toggleTreeFolder(id) {
|
||||
this.expandedFolders[id] = !(this.expandedFolders[id] || false);
|
||||
this.expandedFolders = Object.assign({}, this.expandedFolders);
|
||||
try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); }
|
||||
catch(e) {}
|
||||
},
|
||||
navigateToFolder(id) {
|
||||
// Save expansion state before navigating
|
||||
try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); }
|
||||
catch(e) {}
|
||||
window.location = `/local-workspace?folder=${id}`;
|
||||
},
|
||||
|
||||
|
||||
@@ -150,7 +150,10 @@
|
||||
<ul class="ws-tree" x-show="tree.length>0">
|
||||
<template x-for="node in tree" :key="node.id">
|
||||
<li>
|
||||
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}">
|
||||
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
|
||||
draggable="true"
|
||||
@dragstart="onInternalDragStart($event, node.id)"
|
||||
@dragend="draggedItemId = null; $el.style.opacity = ''">
|
||||
<!-- chevron -->
|
||||
<template x-if="node.is_folder && node.children && node.children.length > 0">
|
||||
<button class="ws-chevron"
|
||||
@@ -268,6 +271,7 @@ document.addEventListener('alpine:init', function() {
|
||||
uploading: false,
|
||||
uploadStatus: '',
|
||||
uploadProgress: 0,
|
||||
draggedItemId: null,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
@@ -303,7 +307,7 @@ document.addEventListener('alpine:init', function() {
|
||||
var c = children[i];
|
||||
var hasKids = c.children && c.children.length > 0;
|
||||
var icon = c.is_folder ? '📁' : '📄';
|
||||
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px">';
|
||||
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true" ondragstart="var d=document.querySelector(\'[x-data]\').__x.$data;d.onInternalDragStart(event,'+c.id+')" ondragend="event.target.style.opacity=\'\'">';
|
||||
if (c.is_folder && hasKids) {
|
||||
html += '<button class="ws-chevron" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.toggleExpand('+c.id+')">▶</button>';
|
||||
} else {
|
||||
@@ -445,11 +449,42 @@ document.addEventListener('alpine:init', function() {
|
||||
}
|
||||
},
|
||||
|
||||
onInternalDragStart(ev, id) {
|
||||
this.draggedItemId = id;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData('text/plain', String(id));
|
||||
// Add a visual class to the dragged element
|
||||
ev.target.style.opacity = '0.5';
|
||||
},
|
||||
|
||||
onDrop(ev) {
|
||||
this.isDragging = false;
|
||||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||||
// Reset any opacity from drag
|
||||
document.querySelectorAll('.ws-tree-item').forEach(function(el){ el.style.opacity = ''; });
|
||||
|
||||
// Determine target parent
|
||||
// ── Internal drag (move item to another folder) ──
|
||||
if (this.draggedItemId !== null) {
|
||||
var draggedId = this.draggedItemId;
|
||||
this.draggedItemId = null;
|
||||
// Determine target parent
|
||||
var folderEl = ev.target.closest('.ws-tree-item');
|
||||
var targetParentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||
if (folderEl) {
|
||||
var folderLink = folderEl.querySelector('.name.folder');
|
||||
if (folderLink) {
|
||||
var onclick = folderLink.getAttribute('onclick') || '';
|
||||
var m = onclick.match(/folder=(\d+)/);
|
||||
if (m) targetParentId = parseInt(m[1]);
|
||||
}
|
||||
}
|
||||
// Don't drop on self or same parent
|
||||
if (draggedId === targetParentId) return;
|
||||
this._moveItem(draggedId, targetParentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── External drop (files from computer) ──
|
||||
var folderEl = ev.target.closest('.ws-tree-item');
|
||||
var parentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||
var parentName = 'root';
|
||||
@@ -567,6 +602,24 @@ document.addEventListener('alpine:init', function() {
|
||||
this._doUpload(formData, parentId, parentName);
|
||||
},
|
||||
|
||||
async _moveItem(itemId, newParentId) {
|
||||
var body = { parent_id: newParentId || null };
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Move failed');
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Move failed: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async _doUpload(formData, parentId, parentName) {
|
||||
var self = this;
|
||||
this.uploading = true;
|
||||
|
||||
Reference in New Issue
Block a user