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 @@
@@ -124,6 +125,16 @@
+
+
+
@@ -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 ▾
+
+
+
+
+
+
+
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){