diff --git a/app/db.py b/app/db.py index b5eeed3..de4f6b6 100644 --- a/app/db.py +++ b/app/db.py @@ -141,6 +141,49 @@ def init_db(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + + -- v1.3.0: Database Concept — Notion-style collections + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + icon TEXT DEFAULT '📋', + schema_json TEXT NOT NULL DEFAULT '[]', + gitea_owner TEXT, + gitea_repo TEXT, + is_locked BOOLEAN NOT NULL DEFAULT 0, + is_inline BOOLEAN NOT NULL DEFAULT 0, + parent_page_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS collection_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + icon TEXT DEFAULT '📄', + position INTEGER NOT NULL DEFAULT 0, + parent_id INTEGER REFERENCES collection_pages(id), + gitea_issue_id INTEGER, + gitea_issue_number INTEGER, + property_values_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position); + CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id); + CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id); + + CREATE TABLE IF NOT EXISTS collection_views ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'Default View', + view_type TEXT NOT NULL DEFAULT 'table', + config_json TEXT NOT NULL DEFAULT '{}', + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); """) # Migration: add parent_id if missing (v1.0.0+) try: @@ -158,6 +201,11 @@ def init_db(): conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'") except sqlite3.OperationalError: pass + # Migration: add deleted_at for trash/soft-delete + try: + conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT") + except sqlite3.OperationalError: + pass conn.commit() diff --git a/app/routers/board.py b/app/routers/board.py index b1a560d..eaa2d08 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -120,7 +120,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m if depth >= max_depth: return [] rows = conn.execute( - "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? ORDER BY sort_order ASC, updated_at DESC", + "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC", (ws_key, parent_id), ).fetchall() items = [] @@ -288,6 +288,39 @@ async def library_page(request: Request): template = env.get_template("library.html") return template.render(**sidebar) +# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ + +@router.get("/api/trash") +async def list_trash(request: Request): + with get_conn() as conn: + rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() + return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] + + +@router.post("/api/trash/{page_id}/restore") +async def restore_page(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) + conn.commit() + return {"status": "ok", "restored": page_id} + + +@router.delete("/api/trash/{page_id}") +async def permanent_delete(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) + conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) + conn.commit() + return {"status": "ok", "deleted": page_id} + + +@router.get("/trash", response_class=HTMLResponse) +async def trash_page(request: Request): + from jinja2 import Environment, FileSystemLoader + env = Environment(loader=FileSystemLoader("app/templates")) + template = env.get_template("trash.html") + return template.render(**_sidebar_data(request)) + # ═══════════ Board page ═══════════ @@ -572,19 +605,13 @@ async def move_page(request: Request, page_id: int, @router.delete("/api/pages/{page_id}") async def delete_page(request: Request, page_id: int): - """Move a page to trash (soft delete) or permanently delete.""" + """Move a page to trash (soft delete).""" with get_conn() as conn: - row = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() + row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone() if not row: raise HTTPException(404, "Page not found") - # Soft delete: set deleted_at timestamp - # For now, hard delete since we have no trash view yet - # If sub-pages exist, move them to root level - conn.execute( - "UPDATE pages SET parent_id=NULL, sort_order=0 WHERE parent_id=?", - (page_id,), - ) - conn.execute("DELETE FROM pages WHERE id=?", (page_id,)) + import datetime + conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,)) conn.commit() return {"status": "ok", "deleted": page_id, "title": row["title"]} diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 1ae25de..fe38c13 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -45,9 +45,18 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: } +@router.get("/trash", response_class=HTMLResponse) +async def trash_page(request: Request): + """Trash page.""" + from jinja2 import Environment, FileSystemLoader + env = Environment(loader=FileSystemLoader("app/templates")) + sidebar = _sidebar_data(request, []) + template = env.get_template("trash.html") + return template.render(**sidebar) + + @router.get("/library", response_class=HTMLResponse) async def library_page(request: Request): - """Library page avec onglets Recents, Favorites, Shared, Private.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) diff --git a/app/templates/base.html b/app/templates/base.html index 244aa5a..0211ec9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -202,7 +202,7 @@ diff --git a/app/templates/library.html b/app/templates/library.html index 3f1d79e..bf3d98d 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -10,7 +10,7 @@ - +
+ +
- -
- -
- Page name - Last edited time + +
+ +
+ 📄 Page name + 👤 Created by + ↗ Source + 🕒 Last edited time + 🕒 Last visited time
- + {% macro page_row(page) %} + + + {{ page.icon }} + {{ page.name }} + + 👤 {{ page.created_by or 'You' }} + {{ page.source or '🔒 Private' }} + {{ page.last_edited }} + {{ page.last_visited or page.last_edited }} + + {% endmacro %} + +
- {% for page in recent_pages %} - - {{ page.icon }} - {{ page.name }} - {{ page.last_edited }} - - {% endfor %} + {% for page in recent_pages %}{{ page_row(page) }}{% endfor %} {% if not recent_pages %} -
-
🕒
+
+
🕒

No recent pages

{% endif %}
- +
- {% for page in favorite_pages %} - - {{ page.icon }} - {{ page.name }} - {{ page.last_edited }} - - {% endfor %} + {% for page in favorite_pages %}{{ page_row(page) }}{% endfor %} {% if not favorite_pages %} -
-
⭐
+
+
⭐

No favorites

-

Right-click a page in the sidebar and select "Add to Favorites".

+

Right-click a page in the sidebar and select "Add to Favorites".

{% endif %}
- +
-
-
đŸ‘Ĩ
+
+
đŸ‘Ĩ

No shared pages

-

Shared pages from your team will appear here.

+

Shared pages from your team will appear here.

- +
- {% for page in private_pages %} - - {{ page.icon }} - {{ page.name }} - {{ page.last_edited }} - - {% endfor %} + {% for page in private_pages %}{{ page_row(page) }}{% endfor %} {% if not private_pages %} -
-
🔒
+
+
🔒

No private pages

-

Create a new page with the + button in the sidebar.

+

Create a new page with the + button in the sidebar.

{% endif %}
@@ -109,18 +107,11 @@ {% block scripts %} +{% endblock %} diff --git a/static/css/app.css b/static/css/app.css index 9c17852..7fbf4f1 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1517,6 +1517,51 @@ input, select, textarea { font-family: inherit; color: inherit; } .text-xs { font-size: 12px; } .text-sm { font-size: 13px; } .font-semibold { font-weight: 600; } + +/* Library table */ +.library-table-header { + display: flex; padding: 8px 12px; border-bottom: 1px solid var(--border); + font-size: 12px; font-weight: 600; color: var(--text-dim); +} +.library-table-row { + display: flex; align-items: center; padding: 8px 12px; text-decoration: none; + color: var(--text-primary); border-radius: 6px; transition: background 0.15s; +} +.library-table-row:hover { background: var(--bg-hover); } +.lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; } +.lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); } +.lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); } +.lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); } +.lib-col-visited { width: 140px; font-size: 13px; color: var(--text-dim); } +.lib-empty { text-align: center; padding: 48px; color: var(--text-dim); } +.lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); } +.lib-empty p { font-size: 13px; } + +/* Trash */ +.trash-filter { + display: flex; align-items: center; gap: 6px; + padding: 6px 12px; border-radius: 6px; + font-size: 13px; color: var(--text-primary); + background: var(--bg-tertiary); transition: background 0.15s; +} +.trash-filter:hover { background: var(--bg-hover); } +.trash-filter.active { color: #5b9bd5; background: rgba(91,155,213,0.1); } +.trash-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; border-radius: 6px; transition: background 0.15s; +} +.trash-item:hover { background: var(--bg-hover); } +.trash-item-icon { font-size: 18px; flex-shrink: 0; } +.trash-item-info { flex: 1; min-width: 0; } +.trash-item-name { display: block; font-size: 14px; color: var(--text-primary); font-weight: 500; } +.trash-item-path { display: block; font-size: 12px; color: var(--text-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.trash-item-btn { + width: 32px; height: 32px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-size: 14px; color: var(--text-secondary); transition: background 0.15s; +} +.trash-item-btn:hover { background: var(--bg-tertiary); } .ml-auto { margin-left: auto; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/tests/test_app.py b/tests/test_app.py index 09cf88f..ed167d4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -202,7 +202,8 @@ def test_db_tables_exist(client): names = {t["name"] for t in tables} required = {"boards", "cards", "notes", "checklists", "checklist_items", "col_mapping", "users", "user_tokens", - "project_properties", "property_values", "ai_keywords"} + "project_properties", "property_values", "ai_keywords", + "collections", "collection_pages", "collection_views"} assert required <= names