feat: Move to, Gitea context menu, Activity popover
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

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
This commit is contained in:
2026-07-16 12:53:38 -04:00
parent cef267d7b5
commit 3210ac65a6
3 changed files with 167 additions and 35 deletions
+40 -33
View File
@@ -5,7 +5,7 @@ import json
import logging import logging
from fastapi import APIRouter, Request, HTTPException, Query 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.db import get_conn
from app.services.gitea_client import gitea 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") @router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int, async def move_page(request: Request, page_id: int):
new_parent_id: int = Query(default=0), """Move a page to another workspace or reorder within tree.
new_order: int = Query(default=0)):
"""Move/reorder a page in the tree (drag & drop). Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
new_parent_id=0 means move to root level. - workspace_id: move page to a different workspace
new_order=0 means append to end (or set explicit position).""" - 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: with get_conn() as conn:
# Verify page exists row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: if not row:
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
# Update parent (None for root level)
conn.execute( if new_ws_id:
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", # Move to a different workspace: get the workspace name
(new_parent_id if new_parent_id > 0 else None, page_id), ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
) if not ws_row:
# Update sort order return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
conn.execute( conn.execute(
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id), (new_ws_id, ws_row["name"], page_id),
) )
# Re-index siblings to ensure no gaps else:
siblings = conn.execute( # Reorder within same workspace
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC", conn.execute(
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id), "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
).fetchall() (new_parent_id if new_parent_id > 0 else None, page_id),
# Recompute sort_order for all siblings after the insertion point )
insert_point = new_order conn.execute(
reorder = [] "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
for sib in siblings: (new_order, page_id),
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))
conn.commit() 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}") @router.delete("/api/pages/{page_id}")
+50
View File
@@ -117,6 +117,7 @@
<template x-for="item in sortedTreeItems" :key="item.path"> <template x-for="item in sortedTreeItems" :key="item.path">
<div class="ws-tree-item" <div class="ws-tree-item"
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;"> style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-text="item.type==='folder' ? '📁' : '📄'" style="font-size:16px;"></span> <span x-text="item.type==='folder' ? '📁' : '📄'" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span> <span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
@@ -124,6 +125,16 @@
</div> </div>
</template> </template>
</div> </div>
<!-- Context menu -->
<div class="gw-context-menu"
x-show="gwCtx.visible"
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
@click.outside="gwCtx.visible = false"
style="display:none;position:fixed;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:1100;min-width:160px;padding:4px;">
<div class="ctx-item" @click="gwRename()">✏️ Rename</div>
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 Delete</div>
</div>
</div> </div>
<!-- New file form --> <!-- New file form -->
@@ -207,6 +218,8 @@ function giteaWorkspace() {
treeLoading: false, treeLoading: false,
folderStack: [], folderStack: [],
currentPath: '', currentPath: '',
// Context menu
gwCtx: { visible: false, x: 0, y: 0, item: null },
get sortedTreeItems() { get sortedTreeItems() {
return [...this.treeItems].sort(function(a, b) { return [...this.treeItems].sort(function(a, b) {
@@ -257,6 +270,43 @@ function giteaWorkspace() {
this.loadMainTree(''); 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() { async loadSidebarTree() {
try { try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree'); var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
+77 -2
View File
@@ -8,7 +8,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
<!-- ═══════════ Action Bar (Share, Copy, Favorite, More) ═══════════ --> <!-- ═══════════ Action Bar (Share, Copy, Favorite, More) ═══════════ -->
<div class="page-topbar"> <div class="page-topbar">
<div class="page-topbar-right" style="position: relative"> <div class="page-topbar-right" style="position: relative">
<span class="topbar-edited">Edited <span x-text="timeAgo"></span></span> <span class="topbar-edited" style="cursor:pointer;" @click="activityOpen = !activityOpen">Edited <span x-text="timeAgo"></span> ▾</span>
<button <button
class="page-action-btn share-btn" class="page-action-btn share-btn"
@click="shareOpen = !shareOpen" @click="shareOpen = !shareOpen"
@@ -357,6 +357,18 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
</div> </div>
</div> </div>
<!-- ═══════════ Activity Popover ═══════════ -->
<div class="activity-popover"
x-show="activityOpen"
@click.outside="activityOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);z-index:1000;width:280px;padding:12px;">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
<template x-if="pageCreated">
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
</template>
</div>
<!-- More menu dropdown --> <!-- More menu dropdown -->
<div <div
class="more-menu" class="more-menu"
@@ -374,6 +386,30 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
🗑️ Move to Trash 🗑️ Move to Trash
</div> </div>
</div> </div>
<!-- ═══════════ Move to Dialog ═══════════ -->
<div class="move-dialog"
x-show="moveOpen"
@click.outside="moveOpen = false"
x-transition.opacity.scale
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);z-index:1000;width:320px;padding:16px;">
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
<template x-for="ws in moveWorkspaces" :key="ws.id">
<div class="move-ws-item"
@click="doMove(ws.id)"
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
<span>📁</span>
<span x-text="ws.name" style="flex:1;"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
</div>
</template>
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
No workspaces available
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -586,6 +622,8 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
updatedAt:'{{ page.get("updated_at", "") }}', updatedAt:'{{ page.get("updated_at", "") }}',
fmt:{open:false,top:0,left:0,idx:-1}, fmt:{open:false,top:0,left:0,idx:-1},
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false, shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
moveOpen:false,moveWorkspaces:[],moveLoading:false,
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
shareTab:'share',favorited:false, shareTab:'share',favorited:false,
pagePublished:{{ page_published | tojson }}, pagePublished:{{ page_published | tojson }},
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer', 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'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd 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(){ init(){
_bid=Math.floor(Math.random()*10000); _bid=Math.floor(Math.random()*10000);
const dataEl=document.getElementById('page-data'); const dataEl=document.getElementById('page-data');
@@ -731,7 +775,38 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
this.showToast('Exported as Markdown'); 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')}&section=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');});}, duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=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');});}, 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){ onKd(e){