feat: complete favorites system — sidebar, library, context menu, API
6 files changed:
- db.py: migrate favorites table FK from collection_pages(id) to pages(id)
- board.py: add favorites API (POST/DELETE /board/api/favorites/{id}, GET list)
- board.py: _sidebar_data() now loads favorite_pages from DB via JOIN
- dashboard.py: library_page loads lib_favorites from DB (not parent_section)
- csrf.py: exclude /board/api/favorites from CSRF checks
- base.html: context menu toggles Add/Remove Favorites based on state
- base.html: favoriteIds Alpine set initialized from server-rendered favorites
- test_app.py: test_favorites_crud rewritten for new page-based favorites API
Favorites now work end-to-end:
- Right-click → Add to Favorites (or Remove if already favorited)
- Sidebar Favorites section shows favorited pages
- Library Favorites tab shows the same pages
- API: POST/DELETE /board/api/favorites/{page_id}, GET /board/api/favorites
This commit is contained in:
@@ -244,14 +244,16 @@ def init_db():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v2.2.0: Migrate favorites — drop old schema referencing collection_pages
|
||||
DROP TABLE IF EXISTS favorites;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER REFERENCES collection_pages(id),
|
||||
collection_id INTEGER REFERENCES collections(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id, collection_id)
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_templates (
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/board/api/pages", "/board/api/favorites", "/db/", "/workspace"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
+70
-1
@@ -169,10 +169,34 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
recent.append(p)
|
||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||
|
||||
# Load favorite pages from DB
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": [], "user": user,
|
||||
"favorite_pages": favorites, "user": user,
|
||||
"workspace_key": ws_key}
|
||||
|
||||
|
||||
@@ -314,6 +338,51 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||
).fetchall()
|
||||
return {"favorites": [r["page_id"] for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
|
||||
@@ -109,13 +109,36 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
all_pages = [to_page(r) for r in rows]
|
||||
|
||||
# Load favorites from DB for Library
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
lib_favs = []
|
||||
for r in fav_rows:
|
||||
r = dict(r)
|
||||
lib_favs.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"created_by": "You",
|
||||
"source": r.get("workspace") or "🔒 Private",
|
||||
"last_edited": r.get("updated_at", "now"),
|
||||
"last_visited": r.get("updated_at", "now"),
|
||||
})
|
||||
|
||||
# Catégoriser par parent_section — sidebar data intacte
|
||||
sidebar["lib_recent"] = all_pages
|
||||
sidebar["lib_favorites"] = [p for p, r in zip(all_pages, rows) if (r["parent_section"] or "") == "Favorites"]
|
||||
sidebar["lib_shared"] = [p for p, r in zip(all_pages, rows) if (r["parent_section"] or "") == "Shared"]
|
||||
sidebar["lib_private"] = [p for p, r in zip(all_pages, rows) if (r["parent_section"] or "Private") == "Private"]
|
||||
sidebar["lib_has_favorites"] = len(sidebar["lib_favorites"]) > 0
|
||||
sidebar["lib_has_shared"] = len(sidebar["lib_shared"]) > 0
|
||||
sidebar["lib_favorites"] = lib_favs
|
||||
sidebar["lib_shared"] = []
|
||||
sidebar["lib_private"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["lib_has_favorites"] = len(lib_favs) > 0
|
||||
sidebar["lib_has_shared"] = False
|
||||
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
+18
-2
@@ -289,7 +289,8 @@
|
||||
</div>
|
||||
<div class="context-menu-section">
|
||||
<div class="context-menu-item" @click="contextAction('favorite')">
|
||||
<span class="menu-icon">⭐</span> Remove from Favorites
|
||||
<span class="menu-icon">⭐</span>
|
||||
<span x-text="favoriteIds.has(contextMenu.pageId) ? 'Remove from Favorites' : 'Add to Favorites'"></span>
|
||||
</div>
|
||||
<div class="context-menu-item" @click="contextAction('recent')">
|
||||
<span class="menu-icon">👁️</span> Remove from Recents
|
||||
@@ -383,6 +384,9 @@
|
||||
contextActions: {},
|
||||
showNewPageMenu: false,
|
||||
|
||||
// ── Favorites tracking ──
|
||||
favoriteIds: new Set([{% for p in favorite_pages %}'{{ p.id }}'{% if not loop.last %},{% endif %}{% endfor %}]),
|
||||
|
||||
// ── Sections collapsible ──
|
||||
sectionsOpen: { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true },
|
||||
toggleSection(key) { this.sectionsOpen[key] = !this.sectionsOpen[key]; this.sectionsOpen = Object.assign({}, this.sectionsOpen); },
|
||||
@@ -477,10 +481,22 @@
|
||||
const { pageId, pageName } = this.contextMenu;
|
||||
const self = this;
|
||||
const csrf = this.getCsrfToken();
|
||||
const dbId = pageId.startsWith('page/') ? parseInt(pageId.split('/')[1]) : parseInt(pageId);
|
||||
|
||||
switch(action) {
|
||||
case 'favorite':
|
||||
alert(`Toggled favorite for: ${pageName}`);
|
||||
const isFav = this.favoriteIds.has(pageId);
|
||||
const method = isFav ? 'DELETE' : 'POST';
|
||||
fetch(`/board/api/favorites/${dbId}`, { method, headers: { 'X-CSRF-Token': csrf } })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (isFav) self.favoriteIds.delete(pageId);
|
||||
else self.favoriteIds.add(pageId);
|
||||
self.contextMenu.visible = false;
|
||||
// Reload to update sidebar
|
||||
location.reload();
|
||||
})
|
||||
.catch(e => alert('Favorite action failed'));
|
||||
break;
|
||||
case 'copyLink':
|
||||
const url = window.location.origin + '/' + pageId;
|
||||
|
||||
+19
-9
@@ -868,19 +868,29 @@ def test_comments_crud(client):
|
||||
|
||||
|
||||
def test_favorites_crud(client):
|
||||
r = client.post("/db/api", json={"name": "Fav DB"})
|
||||
cid = r.json()["id"]
|
||||
"""Test favorites CRUD for sidebar pages — POST/DELETE /board/api/favorites/{page_id}."""
|
||||
# Create a page first
|
||||
r = client.post("/board/api/pages?section=Private&project=test/test")
|
||||
assert r.status_code == 200
|
||||
pid = r.json()["id"]
|
||||
|
||||
resp = client.post("/workspace/favorites", json={"collection_id": cid})
|
||||
# Add to favorites
|
||||
resp = client.post(f"/board/api/favorites/{pid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "added"
|
||||
|
||||
favs = client.get("/workspace/favorites").json()["favorites"]
|
||||
assert len(favs) >= 1
|
||||
# List favorites
|
||||
favs = client.get("/board/api/favorites").json()["favorites"]
|
||||
assert pid in favs
|
||||
|
||||
fav_id = favs[0]["id"]
|
||||
client.delete(f"/workspace/favorites/{fav_id}")
|
||||
assert len(client.get("/workspace/favorites").json()["favorites"]) == 0
|
||||
client.delete(f"/db/api/{cid}")
|
||||
# Remove from favorites
|
||||
resp = client.delete(f"/board/api/favorites/{pid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "removed"
|
||||
|
||||
# List should be empty
|
||||
favs = client.get("/board/api/favorites").json()["favorites"]
|
||||
assert pid not in favs
|
||||
|
||||
|
||||
def test_csv_import_export(client):
|
||||
|
||||
Reference in New Issue
Block a user