From 3210ac65a64c8c00370eee50ea951186e0021c01 Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 16 Jul 2026 12:53:38 -0400 Subject: [PATCH] feat: Move to, Gitea context menu, Activity popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move to workspace: - movePage() opens a dialog listing all workspaces via /api/workspaces - doMove(wsId) calls PUT /api/pages/{id}/move with workspace_id - Updated move API to accept JSON body with workspace_id for cross-workspace moves Gitea workspace context menu: - Right-click on any file/folder shows Rename + Delete options - gwRename() prompts for new name, calls PUT /api/gitea/.../file - gwDelete() confirms then calls DELETE /api/gitea/.../file - Both trigger refreshTree() after success Activity popover: - 'Edited X ago' timestamp is now clickable (▾ indicator) - Opens popover showing edited time + created date - formatDate() helper for readable date formatting --- app/routers/board.py | 73 ++++++++++++++------------- app/templates/gitea_workspace.html | 50 +++++++++++++++++++ app/templates/page_editor.html | 79 +++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 35 deletions(-) diff --git a/app/routers/board.py b/app/routers/board.py index 3e83135..9b629ae 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -5,7 +5,7 @@ import json import logging from fastapi import APIRouter, Request, HTTPException, Query -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from app.db import get_conn from app.services.gitea_client import gitea @@ -857,42 +857,49 @@ async def save_page_blocks(request: Request, page_id: int): @router.put("/api/pages/{page_id}/move") -async def move_page(request: Request, page_id: int, - new_parent_id: int = Query(default=0), - new_order: int = Query(default=0)): - """Move/reorder a page in the tree (drag & drop). - new_parent_id=0 means move to root level. - new_order=0 means append to end (or set explicit position).""" +async def move_page(request: Request, page_id: int): + """Move a page to another workspace or reorder within tree. + + Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } + - workspace_id: move page to a different workspace + - parent_id: change parent (0 = root level) + - new_order: position among siblings (0 = append) + """ + try: + body = await request.json() + except Exception: + body = {} + new_ws_id = body.get("workspace_id") + new_parent_id = body.get("parent_id", 0) + new_order = body.get("new_order", 0) + with get_conn() as conn: - # Verify page exists - row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone() + row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() if not row: raise HTTPException(404, "Page not found") - # Update parent (None for root level) - conn.execute( - "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_parent_id if new_parent_id > 0 else None, page_id), - ) - # Update sort order - conn.execute( - "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_order, page_id), - ) - # Re-index siblings to ensure no gaps - siblings = conn.execute( - "SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC", - (row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id), - ).fetchall() - # Recompute sort_order for all siblings after the insertion point - insert_point = new_order - reorder = [] - for sib in siblings: - if sib["sort_order"] >= insert_point: - reorder.append(sib["id"]) - for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))): - conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid)) + + if new_ws_id: + # Move to a different workspace: get the workspace name + ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone() + if not ws_row: + return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404) + conn.execute( + "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_ws_id, ws_row["name"], page_id), + ) + else: + # Reorder within same workspace + conn.execute( + "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_parent_id if new_parent_id > 0 else None, page_id), + ) + conn.execute( + "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_order, page_id), + ) + conn.commit() - return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order} + return {"status": "ok", "id": page_id} @router.delete("/api/pages/{page_id}") diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index c34c68e..e3a1afe 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -117,6 +117,7 @@ + + + @@ -207,6 +218,8 @@ function giteaWorkspace() { treeLoading: false, folderStack: [], currentPath: '', + // Context menu + gwCtx: { visible: false, x: 0, y: 0, item: null }, get sortedTreeItems() { return [...this.treeItems].sort(function(a, b) { @@ -257,6 +270,43 @@ function giteaWorkspace() { this.loadMainTree(''); }, + openGwContext(ev, item) { + this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item }; + }, + gwRename() { + this.gwCtx.visible = false; + var item = this.gwCtx.item; + if (!item) return; + var newName = prompt('Rename:', item.name); + if (!newName || !newName.trim() || newName.trim() === item.name) return; + var self = this; + var oldPath = item.path; + var newPath = oldPath.replace(/[^/]+$/, newName.trim()); + fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath }) + }).then(function(r){ return r.json(); }) + .then(function(d){ + if (d.status === 'ok') { self.refreshTree(); } + else { window.showToast('Rename failed', 'error'); } + }) + .catch(function(){ window.showToast('Rename failed', 'error'); }); + }, + gwDelete() { + this.gwCtx.visible = false; + var item = this.gwCtx.item; + if (!item) return; + if (!confirm('Delete ' + item.name + '?')) return; + var self = this; + fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, { + method: 'DELETE' + }).then(function(r){ + if (r.ok) { self.refreshTree(); } + else { window.showToast('Delete failed', 'error'); } + }).catch(function(){ window.showToast('Delete failed', 'error'); }); + }, + async loadSidebarTree() { try { var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree'); diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index f3c12bc..893e3f9 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -8,7 +8,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
- Edited + Edited ▾
+ +
+
Edited
+ +
+
+ + +
+

Move to workspace

+
Loading...
+
+ +
+ No workspaces available +
+
+
@@ -586,6 +622,8 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} updatedAt:'{{ page.get("updated_at", "") }}', fmt:{open:false,top:0,left:0,idx:-1}, shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false, + moveOpen:false,moveWorkspaces:[],moveLoading:false, + activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}', shareTab:'share',favorited:false, pagePublished:{{ page_published | tojson }}, generalAccess:'{{ page_share_mode }}',publicPerm:'viewer', @@ -601,6 +639,12 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; }, + formatDate(d) { + if (!d) return ''; + var dt = new Date(d + 'Z'); + return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + + ' at ' + dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + }, init(){ _bid=Math.floor(Math.random()*10000); const dataEl=document.getElementById('page-data'); @@ -731,7 +775,38 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} this.showToast('Exported as Markdown'); }, duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});}, - movePage(){this.moreOpen=false;}, + movePage(){ + this.moreOpen=false; + this.moveOpen = true; + this.moveLoading = true; + var self = this; + fetch('/api/workspaces') + .then(function(r){ return r.json(); }) + .then(function(d){ + self.moveWorkspaces = d.workspaces || []; + self.moveLoading = false; + }) + .catch(function(){ self.moveLoading = false; }); + }, + doMove(wsId) { + this.moveOpen = false; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + var self = this; + fetch('/api/pages/' + this.pid + '/move', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }, + body: JSON.stringify({ workspace_id: wsId }) + }).then(function(r){ return r.json(); }) + .then(function(d){ + if (d.status === 'ok') { + self.showToast('Moved to workspace'); + setTimeout(function(){ window.location.href = '/local-workspace'; }, 800); + } else { + self.showToast(d.detail || 'Move failed'); + } + }) + .catch(function(){ self.showToast('Move failed'); }); + }, deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});}, onKd(e){