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
|
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 (
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
page_id INTEGER REFERENCES collection_pages(id),
|
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||||
collection_id INTEGER REFERENCES collections(id),
|
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
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 (
|
CREATE TABLE IF NOT EXISTS database_templates (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
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):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
# 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)
|
recent.append(p)
|
||||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
# 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/")]
|
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",
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||||
"recent_pages": recent, "private_pages": private_items,
|
"recent_pages": recent, "private_pages": private_items,
|
||||||
"favorite_pages": [], "user": user,
|
"favorite_pages": favorites, "user": user,
|
||||||
"workspace_key": ws_key}
|
"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")
|
template = env.get_template("library.html")
|
||||||
return template.render(**sidebar)
|
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) ═══════════
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||||
|
|
||||||
@router.get("/api/trash")
|
@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]
|
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
|
# Catégoriser par parent_section — sidebar data intacte
|
||||||
sidebar["lib_recent"] = all_pages
|
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_favorites"] = lib_favs
|
||||||
sidebar["lib_shared"] = [p for p, r in zip(all_pages, rows) if (r["parent_section"] or "") == "Shared"]
|
sidebar["lib_shared"] = []
|
||||||
sidebar["lib_private"] = [p for p, r in zip(all_pages, rows) if (r["parent_section"] or "Private") == "Private"]
|
sidebar["lib_private"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||||
sidebar["lib_has_favorites"] = len(sidebar["lib_favorites"]) > 0
|
sidebar["lib_has_favorites"] = len(lib_favs) > 0
|
||||||
sidebar["lib_has_shared"] = len(sidebar["lib_shared"]) > 0
|
sidebar["lib_has_shared"] = False
|
||||||
|
|
||||||
template = env.get_template("library.html")
|
template = env.get_template("library.html")
|
||||||
return template.render(**sidebar)
|
return template.render(**sidebar)
|
||||||
|
|||||||
+18
-2
@@ -289,7 +289,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="context-menu-section">
|
<div class="context-menu-section">
|
||||||
<div class="context-menu-item" @click="contextAction('favorite')">
|
<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>
|
||||||
<div class="context-menu-item" @click="contextAction('recent')">
|
<div class="context-menu-item" @click="contextAction('recent')">
|
||||||
<span class="menu-icon">👁️</span> Remove from Recents
|
<span class="menu-icon">👁️</span> Remove from Recents
|
||||||
@@ -383,6 +384,9 @@
|
|||||||
contextActions: {},
|
contextActions: {},
|
||||||
showNewPageMenu: false,
|
showNewPageMenu: false,
|
||||||
|
|
||||||
|
// ── Favorites tracking ──
|
||||||
|
favoriteIds: new Set([{% for p in favorite_pages %}'{{ p.id }}'{% if not loop.last %},{% endif %}{% endfor %}]),
|
||||||
|
|
||||||
// ── Sections collapsible ──
|
// ── Sections collapsible ──
|
||||||
sectionsOpen: { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true },
|
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); },
|
toggleSection(key) { this.sectionsOpen[key] = !this.sectionsOpen[key]; this.sectionsOpen = Object.assign({}, this.sectionsOpen); },
|
||||||
@@ -477,10 +481,22 @@
|
|||||||
const { pageId, pageName } = this.contextMenu;
|
const { pageId, pageName } = this.contextMenu;
|
||||||
const self = this;
|
const self = this;
|
||||||
const csrf = this.getCsrfToken();
|
const csrf = this.getCsrfToken();
|
||||||
|
const dbId = pageId.startsWith('page/') ? parseInt(pageId.split('/')[1]) : parseInt(pageId);
|
||||||
|
|
||||||
switch(action) {
|
switch(action) {
|
||||||
case 'favorite':
|
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;
|
break;
|
||||||
case 'copyLink':
|
case 'copyLink':
|
||||||
const url = window.location.origin + '/' + pageId;
|
const url = window.location.origin + '/' + pageId;
|
||||||
|
|||||||
+19
-9
@@ -868,19 +868,29 @@ def test_comments_crud(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_favorites_crud(client):
|
def test_favorites_crud(client):
|
||||||
r = client.post("/db/api", json={"name": "Fav DB"})
|
"""Test favorites CRUD for sidebar pages — POST/DELETE /board/api/favorites/{page_id}."""
|
||||||
cid = r.json()["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.status_code == 200
|
||||||
|
assert resp.json()["status"] == "added"
|
||||||
|
|
||||||
favs = client.get("/workspace/favorites").json()["favorites"]
|
# List favorites
|
||||||
assert len(favs) >= 1
|
favs = client.get("/board/api/favorites").json()["favorites"]
|
||||||
|
assert pid in favs
|
||||||
|
|
||||||
fav_id = favs[0]["id"]
|
# Remove from favorites
|
||||||
client.delete(f"/workspace/favorites/{fav_id}")
|
resp = client.delete(f"/board/api/favorites/{pid}")
|
||||||
assert len(client.get("/workspace/favorites").json()["favorites"]) == 0
|
assert resp.status_code == 200
|
||||||
client.delete(f"/db/api/{cid}")
|
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):
|
def test_csv_import_export(client):
|
||||||
|
|||||||
Reference in New Issue
Block a user