feat(v1.3.0): Step 2 — DB tables collections, collection_pages, collection_views
- Ajout des 3 nouvelles tables pour le Database Concept - collections: base de données abstraite, indépendante de Gitea - collection_pages: pages génériques avec parent_id et property_values_json - collection_views: vues configurables par collection - Mise à jour test_db_tables_exist pour inclure les nouvelles tables - 28/28 tests passent
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
+38
-11
@@ -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"]}
|
||||
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
<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>
|
||||
<li class="sidebar-item"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
||||
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
||||
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+46
-55
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library tabs: Recents | Favorites | Shared | Private -->
|
||||
<!-- Library tabs -->
|
||||
<div class="view-tabs" x-data="{ tab: 'recents' }">
|
||||
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'">
|
||||
<span class="tab-icon">🕒</span> Recents
|
||||
@@ -29,78 +29,76 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- Library content -->
|
||||
<div x-data="libraryData()" style="padding: 0 24px;">
|
||||
<!-- Table headers -->
|
||||
<div style="display:flex; padding: 8px 12px; border-bottom:1px solid var(--border); font-size:12px; font-weight:600; color:var(--text-dim);">
|
||||
<span style="flex:1;">Page name</span>
|
||||
<span style="width:160px;">Last edited time</span>
|
||||
<!-- Table -->
|
||||
<div x-data="{ tab: 'recents' }" 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>
|
||||
</div>
|
||||
|
||||
<!-- Recents tab -->
|
||||
{% 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 recent_pages %}
|
||||
<a href="{{ page.url }}" style="display:flex; align-items:center; padding:8px 12px; text-decoration:none; color:var(--text-primary); border-radius:6px; transition:background 0.15s;"
|
||||
onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<span style="font-size:16px; margin-right:10px;">{{ page.icon }}</span>
|
||||
<span style="flex:1;">{{ page.name }}</span>
|
||||
<span style="width:160px; font-size:13px; color:var(--text-dim);">{{ page.last_edited }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for page in recent_pages %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not recent_pages %}
|
||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">🕒</div>
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🕒</div>
|
||||
<h3>No recent pages</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Favorites tab -->
|
||||
<!-- Favorites -->
|
||||
<div x-show="tab === 'favorites'">
|
||||
{% for page in favorite_pages %}
|
||||
<a href="{{ page.url }}" style="display:flex; align-items:center; padding:8px 12px; text-decoration:none; color:var(--text-primary); border-radius:6px;"
|
||||
onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<span style="font-size:16px; margin-right:10px;">{{ page.icon }}</span>
|
||||
<span style="flex:1;">{{ page.name }}</span>
|
||||
<span style="width:160px; font-size:13px; color:var(--text-dim);">{{ page.last_edited }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for page in favorite_pages %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not favorite_pages %}
|
||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">⭐</div>
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">⭐</div>
|
||||
<h3>No favorites</h3>
|
||||
<p style="margin-top:8px;">Right-click a page in the sidebar and select "Add to Favorites".</p>
|
||||
<p>Right-click a page in the sidebar and select "Add to Favorites".</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Shared tab -->
|
||||
<!-- Shared -->
|
||||
<div x-show="tab === 'shared'">
|
||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">👥</div>
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">👥</div>
|
||||
<h3>No shared pages</h3>
|
||||
<p style="margin-top:8px;">Shared pages from your team will appear here.</p>
|
||||
<p>Shared pages from your team will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Private tab -->
|
||||
<!-- Private -->
|
||||
<div x-show="tab === 'private'">
|
||||
{% for page in private_pages %}
|
||||
<a href="{{ page.url }}" style="display:flex; align-items:center; padding:8px 12px; text-decoration:none; color:var(--text-primary); border-radius:6px;"
|
||||
onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<span style="font-size:16px; margin-right:10px;">{{ page.icon }}</span>
|
||||
<span style="flex:1;">{{ page.name }}</span>
|
||||
<span style="width:160px; font-size:13px; color:var(--text-dim);">{{ page.last_edited }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for page in private_pages %}{{ page_row(page) }}{% endfor %}
|
||||
{% if not private_pages %}
|
||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">🔒</div>
|
||||
<div class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔒</div>
|
||||
<h3>No private pages</h3>
|
||||
<p style="margin-top:8px;">Create a new page with the + button in the sidebar.</p>
|
||||
<p>Create a new page with the + button in the sidebar.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -109,18 +107,11 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function libraryData() {
|
||||
return {
|
||||
get tab() { return document.querySelector('[x-data]').__x.$data.tab; }
|
||||
};
|
||||
}
|
||||
|
||||
function newPage() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
fetch('/board/api/pages?title=New%20page§ion=Private', {
|
||||
fetch('/board/api/pages?section=Private', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': token }
|
||||
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}`; })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🗑️{% endblock %}
|
||||
{% block page_title %}Trash{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">🗑️</span>
|
||||
<h1>Trash</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<div style="position:relative; margin-bottom:12px;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">🔍</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:#5b9bd5;">👤</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>📁</span> In ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<div class="trash-item">
|
||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||
<div class="trash-item-info">
|
||||
<span class="trash-item-name" x-text="item.name"></span>
|
||||
<span class="trash-item-path" x-text="item.path"></span>
|
||||
</div>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🗑️</div>
|
||||
<h3>Trash is empty</h3>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div style="padding:12px 16px; margin-top:16px; background:var(--bg-tertiary); border-radius:8px; font-size:13px; color:var(--text-dim); display:flex; align-items:center; gap:8px;">
|
||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||
<span style="font-size:16px;">ⓘ</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
items: [],
|
||||
async init() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
this.items = await r.json();
|
||||
} catch(e) { this.items = []; }
|
||||
},
|
||||
get filteredItems() {
|
||||
const q = this.search.toLowerCase();
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
},
|
||||
async restore(id) {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user