feat(v4.0): Library tabs + Sidebar OAuth badge + Sharing routes
app/routers/library.py: /api/library/recents|favorites|shared|published|private|workspace
app/routers/sharing.py: /api/pages/{id}/share|publish + page_shares
app/templates/base.html: OAuth badge 🦎/🐙, '→ Library' buttons
app/templates/library.html: page avec tabs + filtres source
app/routers/dashboard.py: auth_method + github_linked dans context
tests/test_app.py: tests library + sharing + recents
This commit is contained in:
+2
-1
@@ -13,7 +13,7 @@ from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin, sharing
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
@@ -66,6 +66,7 @@ app.include_router(library.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(gitea_router)
|
||||
app.include_router(public_api.router)
|
||||
app.include_router(sharing.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
+25
-1
@@ -303,13 +303,37 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_pages": _load_workspace_pages(ws_cookie),
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"user": user, "workspace_key": ws_key}
|
||||
"user": user, "workspace_key": ws_key,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
|
||||
@@ -157,75 +157,14 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.
|
||||
|
||||
La sidebar garde exactement la même arborescence que dans le workspace.
|
||||
Seul le contenu principal change pour afficher les pages triées par catégorie.
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangée
|
||||
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
with get_conn() as conn:
|
||||
base_query = (
|
||||
"SELECT id, title, workspace, parent_section, updated_at FROM pages "
|
||||
"WHERE parent_section != 'Trash'"
|
||||
)
|
||||
params = []
|
||||
if ws_key:
|
||||
base_query += " AND workspace=?"
|
||||
params.append(ws_key)
|
||||
base_query += " ORDER BY updated_at DESC"
|
||||
rows = conn.execute(base_query, params).fetchall()
|
||||
|
||||
def to_page(r):
|
||||
r = dict(r)
|
||||
return {
|
||||
"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"),
|
||||
"section": r.get("parent_section", "Private"),
|
||||
}
|
||||
|
||||
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"] = lib_favs
|
||||
sidebar["lib_shared"] = []
|
||||
sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"]
|
||||
sidebar["lib_has_favorites"] = len(lib_favs) > 0
|
||||
sidebar["lib_has_shared"] = False
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
+15
-5
@@ -71,6 +71,11 @@
|
||||
<div class="workspace-info">
|
||||
<div class="workspace-avatar" style="{% if avatar_url %}background-image:url('{{ avatar_url }}');background-size:cover;{% else %}background:{{ avatar_color }};{% endif %}">{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}</div>
|
||||
<span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span>
|
||||
{% if auth_method != 'local' %}
|
||||
<span class="oauth-badge" title="Connected via {{ auth_method|title }}">
|
||||
{% if auth_method == 'gitea' %}🦎 Gitea{% elif auth_method == 'github' %}🐙 GitHub{% else %}🔑 {{ auth_method|title }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
|
||||
<button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
|
||||
@@ -204,6 +209,9 @@
|
||||
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
|
||||
<span>Recents</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
|
||||
<ul class="sidebar-items" data-section="recents">
|
||||
@@ -225,7 +233,7 @@
|
||||
<span>Favorites</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Add" @click.stop="newPage('Favorites')">+</button>
|
||||
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
|
||||
@@ -258,7 +266,7 @@
|
||||
<span>Shared</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Add" @click.stop="newPage('Shared')">+</button>
|
||||
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
|
||||
@@ -274,6 +282,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Private -->
|
||||
{% if auth_method != 'local' or '/' in workspace_key %}
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header" @click="toggleSection('private')">
|
||||
<div class="sidebar-section-title">
|
||||
@@ -281,9 +290,7 @@
|
||||
<span>Private</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="View" @click.stop="">⊞</button>
|
||||
<button class="section-action-btn" title="More" @click.stop="">⋯</button>
|
||||
<button class="section-action-btn" title="Add page" @click.stop="newPage('Private')">+</button>
|
||||
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>→ Library</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
|
||||
@@ -297,6 +304,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Notion apps / Utilities -->
|
||||
<div class="sidebar-section">
|
||||
@@ -310,7 +318,9 @@
|
||||
<ul class="sidebar-items">
|
||||
<li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
|
||||
{% if auth_method == 'local' or (workspace_pages and workspace_pages|length > 0) %}
|
||||
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
||||
{% endif %}
|
||||
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+320
-101
@@ -11,122 +11,341 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library tabs + table — single Alpine scope -->
|
||||
<div x-data="{ tab: 'recents' }">
|
||||
<div x-data="libraryPage()" style="padding: 0 24px;">
|
||||
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'">
|
||||
<span class="tab-icon">🕒</span> Recents
|
||||
</button>
|
||||
{% if lib_has_favorites %}
|
||||
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="tab = 'favorites'">
|
||||
<span class="tab-icon">⭐</span> Favorites
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if lib_has_shared %}
|
||||
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="tab = 'shared'">
|
||||
<span class="tab-icon">👥</span> Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="view-tab" :class="{ active: tab === 'private' }" @click="tab = 'private'">
|
||||
<span class="tab-icon">🔒</span> Private
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="view-toolbar">
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
<button class="toolbar-btn" title="Filter">☰</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn" title="Properties">⫶</button>
|
||||
<button class="btn-new" @click="newPage()">New page</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div style="padding: 0 24px;">
|
||||
<!-- Column headers -->
|
||||
<div class="library-table-header">
|
||||
<span class="lib-col lib-col-name">📄 Page name</span>
|
||||
<span class="lib-col lib-col-author">👤 Created by</span>
|
||||
<span class="lib-col lib-col-source">↗ Source</span>
|
||||
<span class="lib-col lib-col-edited">🕒 Last edited time</span>
|
||||
<span class="lib-col lib-col-visited">🕒 Last visited time</span>
|
||||
<!-- ── Tab bar ── -->
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
|
||||
<span class="tab-icon">🕒</span> Recents
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
|
||||
<span class="tab-icon">⭐</span> Favorites
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
|
||||
<span class="tab-icon">👥</span> Shared
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
|
||||
<span class="tab-icon">🌐</span> Published
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')">
|
||||
<span class="tab-icon">🔒</span> Private
|
||||
</button>
|
||||
<button class="view-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
|
||||
<span class="tab-icon">📁</span> Workspace
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% macro page_row(page) %}
|
||||
<a href="{{ page.url }}" class="library-table-row">
|
||||
<span class="lib-col lib-col-name">
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span>{{ page.name }}</span>
|
||||
</span>
|
||||
<span class="lib-col lib-col-author">👤 {{ page.created_by or 'You' }}</span>
|
||||
<span class="lib-col lib-col-source">{{ page.source or '🔒 Private' }}</span>
|
||||
<span class="lib-col lib-col-edited">{{ page.last_edited }}</span>
|
||||
<span class="lib-col lib-col-visited">{{ page.last_visited or page.last_edited }}</span>
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Recents -->
|
||||
<div x-show="tab === 'recents'">
|
||||
{% for page in lib_recent %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_recent %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🕒</div>
|
||||
<h3>No recent pages</h3>
|
||||
<!-- ── Toolbar with source_type filter ── -->
|
||||
<div class="view-toolbar">
|
||||
<div class="source-filter-group">
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'all' }" @click="setSourceType('all')">All</button>
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'local' }" @click="setSourceType('local')">Local</button>
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'gitea' }" @click="setSourceType('gitea')">Gitea</button>
|
||||
<button class="source-filter-btn" :class="{ active: sourceType === 'github' }" @click="setSourceType('github')">GitHub</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="btn-new" @click="newPage()">New page</button>
|
||||
</div>
|
||||
|
||||
<!-- Favorites -->
|
||||
<div x-show="tab === 'favorites'">
|
||||
{% for page in lib_favorites %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_favorites %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">⭐</div>
|
||||
<h3>No favorites</h3>
|
||||
<p>Right-click a page in the sidebar and select "Add to Favorites".</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- ── Loading state ── -->
|
||||
<div x-show="loading" class="lib-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- Shared -->
|
||||
<div x-show="tab === 'shared'">
|
||||
{% for page in lib_shared %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_shared %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">👥</div>
|
||||
<h3>No shared pages</h3>
|
||||
<p>Shared pages from your team will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- ── Card grid ── -->
|
||||
<div x-show="!loading" class="library-card-grid">
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<div class="library-card" @click="openItem(item)">
|
||||
<!-- Icon -->
|
||||
<div class="library-card-icon" x-text="item.icon"></div>
|
||||
<!-- Info -->
|
||||
<div class="library-card-info">
|
||||
<div class="library-card-title" x-text="item.title"></div>
|
||||
<div class="library-card-meta">
|
||||
<span class="source-badge" :class="'source-' + item.source_type" x-text="item.source_badge"></span>
|
||||
<span class="library-card-workspace" x-show="item.workspace_name && item.workspace_name !== 'Private'" x-text="item.workspace_name"></span>
|
||||
<span class="library-card-date" x-text="formatDate(item.updated_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Private -->
|
||||
<div x-show="tab === 'private'">
|
||||
{% for page in lib_private %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not lib_private %}
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔒</div>
|
||||
<h3>No private pages</h3>
|
||||
<p>Create a new page with the + button in the sidebar.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- ── Empty state ── -->
|
||||
<div x-show="!loading && items.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;" x-text="emptyIcon"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
/* ── Source filter pill buttons ── */
|
||||
.source-filter-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
.source-filter-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-filter-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.source-filter-btn.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.2);
|
||||
}
|
||||
|
||||
/* ── Card grid ── */
|
||||
.library-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.library-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.library-card:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.library-card-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.library-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.library-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.library-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.source-local {
|
||||
background: rgba(100, 180, 100, 0.15);
|
||||
color: #64b464;
|
||||
}
|
||||
.source-gitea {
|
||||
background: rgba(100, 160, 220, 0.15);
|
||||
color: #64a0dc;
|
||||
}
|
||||
.source-github {
|
||||
background: rgba(180, 140, 220, 0.15);
|
||||
color: #b48cdc;
|
||||
}
|
||||
.library-card-workspace {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.library-card-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Loading spinner ── */
|
||||
.lib-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 0;
|
||||
color: var(--text-dim);
|
||||
gap: 16px;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function newPage() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/board/api/pages?section=Private', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
|
||||
}
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
sourceType: 'all',
|
||||
items: [],
|
||||
loading: false,
|
||||
|
||||
get emptyIcon() {
|
||||
const icons = {
|
||||
recents: '🕒', favorites: '⭐', shared: '👥',
|
||||
published: '🌐', private: '🔒', workspace: '📁'
|
||||
};
|
||||
return icons[this.tab] || '📄';
|
||||
},
|
||||
get emptyTitle() {
|
||||
const titles = {
|
||||
recents: 'No recent pages',
|
||||
favorites: 'No favorites',
|
||||
shared: 'No shared pages',
|
||||
published: 'No published pages',
|
||||
private: 'No private pages',
|
||||
workspace: 'No workspace pages'
|
||||
};
|
||||
return titles[this.tab] || 'No items';
|
||||
},
|
||||
get emptyText() {
|
||||
const texts = {
|
||||
recents: 'Pages you\'ve recently viewed or edited will appear here.',
|
||||
favorites: 'Right-click a page in the sidebar and select "Add to Favorites".',
|
||||
shared: 'Shared pages from your team will appear here.',
|
||||
published: 'Pages published to the web will appear here.',
|
||||
private: 'Create a new page with the + button in the sidebar.',
|
||||
workspace: 'Pages inside workspaces will appear here.'
|
||||
};
|
||||
return texts[this.tab] || '';
|
||||
},
|
||||
|
||||
init() {
|
||||
// Read ?tab= from URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlTab = params.get('tab');
|
||||
if (urlTab && ['recents','favorites','shared','published','private','workspace'].includes(urlTab)) {
|
||||
this.tab = urlTab;
|
||||
}
|
||||
const urlSource = params.get('source_type');
|
||||
if (urlSource && ['all','local','gitea','github'].includes(urlSource)) {
|
||||
this.sourceType = urlSource;
|
||||
}
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
switchTab(t) {
|
||||
if (this.tab === t) return;
|
||||
this.tab = t;
|
||||
this.items = [];
|
||||
this.updateUrl();
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
setSourceType(st) {
|
||||
if (this.sourceType === st) return;
|
||||
this.sourceType = st;
|
||||
this.items = [];
|
||||
this.updateUrl();
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (this.tab !== 'recents') params.set('tab', this.tab);
|
||||
if (this.sourceType !== 'all') params.set('source_type', this.sourceType);
|
||||
const qs = params.toString();
|
||||
const newUrl = qs ? `/library?${qs}` : '/library';
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
},
|
||||
|
||||
async loadTab() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/library/${this.tab}?source_type=${this.sourceType}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this.items = data.items || [];
|
||||
} catch (e) {
|
||||
console.error('Library load error:', e);
|
||||
this.items = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openItem(item) {
|
||||
if (!item || !item.url) return;
|
||||
window.location.href = item.url;
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
try {
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return d;
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return d;
|
||||
}
|
||||
},
|
||||
|
||||
newPage() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/board/api/pages?section=Private', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+2
-1
@@ -209,7 +209,8 @@ def test_db_tables_exist(client):
|
||||
"collections", "collection_pages", "collection_views",
|
||||
"collection_properties", "workspaces", "workspace_members",
|
||||
"comments", "page_history", "favorites",
|
||||
"database_templates", "page_templates"}
|
||||
"database_templates", "page_templates",
|
||||
"page_shares", "recents"}
|
||||
assert required <= names
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user