diff --git a/app/db.py b/app/db.py index 945e16f..0b33be6 100644 --- a/app/db.py +++ b/app/db.py @@ -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 ( diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 29f94a9..b1af78a 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -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 diff --git a/app/routers/board.py b/app/routers/board.py index 8615e4a..15bfc4b 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -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") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index d994c4a..35c2c0c 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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) diff --git a/app/templates/base.html b/app/templates/base.html index 95023d9..1b0aaab 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -289,7 +289,8 @@
- ⭐ Remove from Favorites + ⭐ +
πŸ‘οΈ 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; diff --git a/tests/test_app.py b/tests/test_app.py index a3a8270..9c66563 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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):