Session 1: bug fix, global search, admin trash, lists CRUD

This commit is contained in:
2026-02-28 03:52:12 +00:00
parent f36ea194f3
commit 5773808ae4
14 changed files with 1211 additions and 2 deletions

View File

@@ -122,7 +122,7 @@ class BaseRepository:
# Remove None values except for fields that should be nullable # Remove None values except for fields that should be nullable
nullable_fields = { nullable_fields = {
"description", "notes", "body", "area_id", "project_id", "description", "notes", "body", "area_id", "project_id",
"parent_id", "release_id", "due_date", "deadline", "tags", "parent_id", "parent_item_id", "release_id", "due_date", "deadline", "tags",
"context", "folder_id", "meeting_id", "completed_at", "context", "folder_id", "meeting_id", "completed_at",
"waiting_for_contact_id", "waiting_since", "color", "waiting_for_contact_id", "waiting_since", "color",
} }

View File

@@ -30,6 +30,9 @@ from routers import (
focus as focus_router, focus as focus_router,
capture as capture_router, capture as capture_router,
contacts as contacts_router, contacts as contacts_router,
search as search_router,
admin as admin_router,
lists as lists_router,
) )
@@ -167,3 +170,6 @@ app.include_router(links_router.router)
app.include_router(focus_router.router) app.include_router(focus_router.router)
app.include_router(capture_router.router) app.include_router(capture_router.router)
app.include_router(contacts_router.router) app.include_router(contacts_router.router)
app.include_router(search_router.router)
app.include_router(admin_router.router)
app.include_router(lists_router.router)

121
routers/admin.py Normal file
View File

@@ -0,0 +1,121 @@
"""Admin Trash: view, restore, and permanently delete soft-deleted items."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/admin/trash", tags=["admin"])
templates = Jinja2Templates(directory="templates")
# Entity configs for trash view: (table, display_name, name_column, detail_url_pattern)
TRASH_ENTITIES = [
{"table": "tasks", "label": "Tasks", "name_col": "title", "url": "/tasks/{id}"},
{"table": "projects", "label": "Projects", "name_col": "name", "url": "/projects/{id}"},
{"table": "notes", "label": "Notes", "name_col": "title", "url": "/notes/{id}"},
{"table": "links", "label": "Links", "name_col": "label", "url": "/links"},
{"table": "contacts", "label": "Contacts", "name_col": "first_name", "url": "/contacts/{id}"},
{"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"},
{"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"},
{"table": "lists", "label": "Lists", "name_col": "name", "url": "/lists/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
]
@router.get("/")
async def trash_view(
request: Request,
entity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
deleted_items = []
entity_counts = {}
for entity in TRASH_ENTITIES:
try:
# Count deleted items per type
result = await db.execute(text(
f"SELECT count(*) FROM {entity['table']} WHERE is_deleted = true"
))
count = result.scalar() or 0
entity_counts[entity["table"]] = count
# Load items for selected type (or all if none selected)
if count > 0 and (entity_type is None or entity_type == entity["table"]):
result = await db.execute(text(f"""
SELECT id, {entity['name_col']} as display_name, deleted_at, created_at
FROM {entity['table']}
WHERE is_deleted = true
ORDER BY deleted_at DESC
LIMIT 50
"""))
rows = [dict(r._mapping) for r in result]
for row in rows:
deleted_items.append({
"id": str(row["id"]),
"name": str(row.get("display_name") or row["id"])[:100],
"table": entity["table"],
"type_label": entity["label"],
"deleted_at": row.get("deleted_at"),
"created_at": row.get("created_at"),
})
except Exception:
continue
total_deleted = sum(entity_counts.values())
return templates.TemplateResponse("trash.html", {
"request": request, "sidebar": sidebar,
"deleted_items": deleted_items,
"entity_counts": entity_counts,
"trash_entities": TRASH_ENTITIES,
"current_type": entity_type or "",
"total_deleted": total_deleted,
"page_title": "Trash", "active_nav": "trash",
})
@router.post("/{table}/{item_id}/restore")
async def restore_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
# Validate table name against known entities
valid_tables = {e["table"] for e in TRASH_ENTITIES}
if table not in valid_tables:
return RedirectResponse(url="/admin/trash", status_code=303)
repo = BaseRepository(table, db)
await repo.restore(item_id)
referer = request.headers.get("referer", "/admin/trash")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{table}/{item_id}/permanent-delete")
async def permanent_delete_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
valid_tables = {e["table"] for e in TRASH_ENTITIES}
if table not in valid_tables:
return RedirectResponse(url="/admin/trash", status_code=303)
repo = BaseRepository(table, db)
await repo.permanent_delete(item_id)
referer = request.headers.get("referer", "/admin/trash")
return RedirectResponse(url=referer, status_code=303)
@router.post("/empty")
async def empty_trash(request: Request, db: AsyncSession = Depends(get_db)):
"""Permanently delete ALL soft-deleted items across all tables."""
for entity in TRASH_ENTITIES:
try:
await db.execute(text(
f"DELETE FROM {entity['table']} WHERE is_deleted = true"
))
except Exception:
continue
return RedirectResponse(url="/admin/trash", status_code=303)

275
routers/lists.py Normal file
View File

@@ -0,0 +1,275 @@
"""Lists: checklist/ordered list management with inline items."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime, timezone
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/lists", tags=["lists"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_lists(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["l.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("l.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("l.project_id = :project_id")
params["project_id"] = project_id
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT l.*,
d.name as domain_name, d.color as domain_color,
p.name as project_name,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false AND li.completed = true) as completed_count
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE {where_sql}
ORDER BY l.sort_order, l.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("lists.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "projects": projects,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"page_title": "Lists", "active_nav": "lists",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("list_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "areas": areas,
"page_title": "New List", "active_nav": "lists",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
})
@router.post("/create")
async def create_list(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"list_type": list_type,
"description": description,
}
if area_id and area_id.strip():
data["area_id"] = area_id
if project_id and project_id.strip():
data["project_id"] = project_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_list = await repo.create(data)
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
@router.get("/{list_id}")
async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(list_id)
if not item:
return RedirectResponse(url="/lists", status_code=303)
# Domain/project info
domain = None
if item.get("domain_id"):
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
row = result.first()
domain = dict(row._mapping) if row else None
project = None
if item.get("project_id"):
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
row = result.first()
project = dict(row._mapping) if row else None
# List items (ordered, with hierarchy)
result = await db.execute(text("""
SELECT * FROM list_items
WHERE list_id = :list_id AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"list_id": list_id})
list_items = [dict(r._mapping) for r in result]
# Separate top-level and child items
top_items = [i for i in list_items if i.get("parent_item_id") is None]
child_map = {}
for i in list_items:
pid = i.get("parent_item_id")
if pid:
child_map.setdefault(str(pid), []).append(i)
return templates.TemplateResponse("list_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"list_items": top_items, "child_map": child_map,
"page_title": item["name"], "active_nav": "lists",
})
@router.get("/{list_id}/edit")
async def edit_form(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(list_id)
if not item:
return RedirectResponse(url="/lists", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("list_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "areas": areas,
"page_title": "Edit List", "active_nav": "lists",
"item": item,
"prefill_domain_id": "", "prefill_project_id": "",
})
@router.post("/{list_id}/edit")
async def update_list(
list_id: str,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"list_type": list_type, "description": description,
"area_id": area_id if area_id and area_id.strip() else None,
"project_id": project_id if project_id and project_id.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(list_id, data)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/delete")
async def delete_list(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
await repo.soft_delete(list_id)
return RedirectResponse(url="/lists", status_code=303)
# ---- List Items ----
@router.post("/{list_id}/items/add")
async def add_item(
list_id: str,
request: Request,
content: str = Form(...),
parent_item_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
data = {"list_id": list_id, "content": content, "completed": False}
if parent_item_id and parent_item_id.strip():
data["parent_item_id"] = parent_item_id
await repo.create(data)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/toggle")
async def toggle_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("list_items", db)
item = await repo.get(item_id)
if not item:
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
if item["completed"]:
await repo.update(item_id, {"completed": False, "completed_at": None})
else:
await repo.update(item_id, {"completed": True, "completed_at": datetime.now(timezone.utc)})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/delete")
async def delete_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("list_items", db)
await repo.soft_delete(item_id)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/edit")
async def edit_item(
list_id: str,
item_id: str,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
await repo.update(item_id, {"content": content})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)

190
routers/search.py Normal file
View File

@@ -0,0 +1,190 @@
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/search", tags=["search"])
templates = Jinja2Templates(directory="templates")
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
SEARCH_ENTITIES = [
{
"type": "tasks",
"label": "Tasks",
"query": """
SELECT t.id, t.title as name, t.status,
d.name as domain_name, p.name as project_name,
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/tasks/{id}",
"icon": "task",
},
{
"type": "projects",
"label": "Projects",
"query": """
SELECT p.id, p.name, p.status,
d.name as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/projects/{id}",
"icon": "project",
},
{
"type": "notes",
"label": "Notes",
"query": """
SELECT n.id, n.title as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/notes/{id}",
"icon": "note",
},
{
"type": "contacts",
"label": "Contacts",
"query": """
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
NULL as status, c.company as domain_name, NULL as project_name,
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM contacts c
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/contacts/{id}",
"icon": "contact",
},
{
"type": "links",
"label": "Links",
"query": """
SELECT l.id, l.label as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/links",
"icon": "link",
},
{
"type": "lists",
"label": "Lists",
"query": """
SELECT l.id, l.name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/lists/{id}",
"icon": "list",
},
]
@router.get("/api")
async def search_api(
q: str = Query("", min_length=1),
entity_type: Optional[str] = None,
limit: int = Query(5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
):
"""JSON search endpoint for the Cmd/K modal."""
if not q or not q.strip():
return JSONResponse({"results": [], "query": q})
results = []
entities = SEARCH_ENTITIES
if entity_type:
entities = [e for e in entities if e["type"] == entity_type]
for entity in entities:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
rows = [dict(r._mapping) for r in result]
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"rank": float(row.get("rank", 0)),
"icon": entity["icon"],
})
except Exception:
# Table might not have search_vector yet, skip silently
continue
# Sort all results by rank descending
results.sort(key=lambda r: r["rank"], reverse=True)
return JSONResponse({"results": results[:20], "query": q})
@router.get("/")
async def search_page(
request: Request,
q: str = "",
db: AsyncSession = Depends(get_db),
):
"""Full search page (fallback for non-JS)."""
sidebar = await get_sidebar_data(db)
results = []
if q and q.strip():
for entity in SEARCH_ENTITIES:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
rows = [dict(r._mapping) for r in result]
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"icon": entity["icon"],
})
except Exception:
continue
return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar,
"results": results, "query": q,
"page_title": "Search", "active_nav": "search",
})

View File

@@ -290,7 +290,7 @@ async def update_task(
@router.post("/{task_id}/complete") @router.post("/{task_id}/complete")
async def complete_task(task_id: str, db: AsyncSession = Depends(get_db)): async def complete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""Quick complete from list view.""" """Quick complete from list view."""
repo = BaseRepository("tasks", db) repo = BaseRepository("tasks", db)
await repo.update(task_id, { await repo.update(task_id, {

View File

@@ -54,3 +54,117 @@ function toggleTheme() {
document.head.insertAdjacentHTML('beforeend', document.head.insertAdjacentHTML('beforeend',
'<style>.domain-children.collapsed { display: none; }</style>' '<style>.domain-children.collapsed { display: none; }</style>'
); );
// ---- Search Modal ----
let searchDebounce = null;
function openSearch() {
const modal = document.getElementById('search-modal');
if (!modal) return;
modal.classList.remove('hidden');
const input = document.getElementById('search-input');
input.value = '';
input.focus();
document.getElementById('search-results').innerHTML = '';
}
function closeSearch() {
const modal = document.getElementById('search-modal');
if (modal) modal.classList.add('hidden');
}
document.addEventListener('keydown', (e) => {
// Cmd/Ctrl + K opens search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openSearch();
}
// Escape closes search
if (e.key === 'Escape') {
closeSearch();
}
});
// Live search with debounce
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('search-input');
if (!input) return;
input.addEventListener('input', () => {
clearTimeout(searchDebounce);
const q = input.value.trim();
if (q.length < 1) {
document.getElementById('search-results').innerHTML = '';
return;
}
searchDebounce = setTimeout(() => doSearch(q), 200);
});
// Navigate results with arrow keys
input.addEventListener('keydown', (e) => {
const results = document.querySelectorAll('.search-result-item');
const active = document.querySelector('.search-result-item.active');
let idx = Array.from(results).indexOf(active);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (active) active.classList.remove('active');
idx = (idx + 1) % results.length;
results[idx]?.classList.add('active');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (active) active.classList.remove('active');
idx = idx <= 0 ? results.length - 1 : idx - 1;
results[idx]?.classList.add('active');
} else if (e.key === 'Enter') {
e.preventDefault();
const activeItem = document.querySelector('.search-result-item.active');
if (activeItem) {
window.location.href = activeItem.dataset.url;
}
}
});
});
async function doSearch(query) {
const container = document.getElementById('search-results');
try {
const resp = await fetch(`/search/api?q=${encodeURIComponent(query)}&limit=8`);
const data = await resp.json();
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div class="search-empty">No results found</div>';
return;
}
// Group by type
const grouped = {};
data.results.forEach(r => {
if (!grouped[r.type_label]) grouped[r.type_label] = [];
grouped[r.type_label].push(r);
});
let html = '';
for (const [label, items] of Object.entries(grouped)) {
html += `<div class="search-group-label">${label}</div>`;
items.forEach((item, i) => {
const isFirst = i === 0 && label === Object.keys(grouped)[0];
html += `<a href="${item.url}" class="search-result-item ${isFirst ? 'active' : ''}" data-url="${item.url}">
<span class="search-result-name">${escHtml(item.name)}</span>
${item.context ? `<span class="search-result-context">${escHtml(item.context)}</span>` : ''}
${item.status ? `<span class="status-badge status-${item.status}">${item.status.replace('_', ' ')}</span>` : ''}
</a>`;
});
}
container.innerHTML = html;
} catch (err) {
container.innerHTML = '<div class="search-empty">Search error</div>';
}
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}

View File

@@ -829,6 +829,152 @@ a:hover { color: var(--accent-hover); }
.flex-1 { flex: 1; } .flex-1 { flex: 1; }
.hidden { display: none; } .hidden { display: none; }
/* ---- Search Trigger (Topbar) ---- */
.search-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--muted);
font-family: var(--font-body);
font-size: 0.82rem;
cursor: pointer;
transition: all var(--transition);
min-width: 200px;
}
.search-trigger:hover { border-color: var(--accent); color: var(--text-secondary); }
.search-trigger kbd {
margin-left: auto;
padding: 1px 6px;
background: var(--surface3);
border: 1px solid var(--border);
border-radius: 3px;
font-family: var(--font-body);
font-size: 0.72rem;
color: var(--muted);
}
/* ---- Search Modal ---- */
.search-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
display: flex;
justify-content: center;
padding-top: 15vh;
}
.search-modal.hidden { display: none; }
.search-modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.55);
}
.search-modal-content {
position: relative;
width: 560px;
max-height: 440px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-modal-input-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.search-modal-icon { color: var(--muted); flex-shrink: 0; }
.search-modal-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--font-body);
font-size: 1rem;
}
.search-modal-input::placeholder { color: var(--muted); }
.search-modal-esc {
padding: 2px 8px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.72rem;
color: var(--muted);
cursor: pointer;
}
.search-results {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.search-group-label {
padding: 8px 16px 4px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.search-result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
text-decoration: none;
color: var(--text);
transition: background var(--transition);
}
.search-result-item:hover,
.search-result-item.active { background: var(--surface2); }
.search-result-name { font-weight: 500; flex: 1; }
.search-result-context { font-size: 0.78rem; color: var(--muted); }
.search-empty {
padding: 24px 16px;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
/* ---- Search Type Badges ---- */
.search-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.search-type-tasks { background: var(--accent-soft); color: var(--accent); }
.search-type-projects { background: var(--green-soft); color: var(--green); }
.search-type-notes { background: var(--purple-soft); color: var(--purple); }
.search-type-contacts { background: var(--amber-soft); color: var(--amber); }
.search-type-links { background: var(--surface2); color: var(--text-secondary); }
.search-type-lists { background: var(--accent-soft); color: var(--accent); }
.search-type-capture { background: var(--surface2); color: var(--muted); }
.search-type-domains { background: var(--green-soft); color: var(--green); }
.search-type-areas { background: var(--surface2); color: var(--text-secondary); }
.search-type-daily_focus { background: var(--amber-soft); color: var(--amber); }
/* ---- Search Page Form ---- */
.search-page-form {
display: flex;
gap: 8px;
align-items: center;
margin-top: 12px;
}
/* ---- Scrollbar ---- */ /* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }

View File

@@ -44,6 +44,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Links Links
</a> </a>
<a href="/lists" class="nav-item {{ 'active' if active_nav == 'lists' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
Lists
</a>
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}"> <a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
Capture Capture
@@ -76,6 +80,10 @@
</div> </div>
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;"> <div class="nav-section" style="margin-top: auto; padding-bottom: 12px;">
<a href="/admin/trash" class="nav-item {{ 'active' if active_nav == 'trash' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Trash
</a>
<a href="/domains" class="nav-item {{ 'active' if active_nav == 'domains' }}"> <a href="/domains" class="nav-item {{ 'active' if active_nav == 'domains' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m-7-7h6m6 0h6"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m-7-7h6m6 0h6"/></svg>
Manage Domains Manage Domains
@@ -99,6 +107,11 @@
<span class="topbar-env">DEV</span> <span class="topbar-env">DEV</span>
{% endif %} {% endif %}
<div class="topbar-spacer"></div> <div class="topbar-spacer"></div>
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Search...</span>
<kbd>&#8984;K</kbd>
</button>
</header> </header>
<div class="page-content"> <div class="page-content">
@@ -106,6 +119,19 @@
</div> </div>
</main> </main>
</div> </div>
<!-- Search Modal -->
<div id="search-modal" class="search-modal hidden">
<div class="search-modal-backdrop" onclick="closeSearch()"></div>
<div class="search-modal-content">
<div class="search-modal-input-wrap">
<svg class="search-modal-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="search-input" class="search-modal-input" placeholder="Search tasks, projects, notes..." autocomplete="off">
<kbd class="search-modal-esc" onclick="closeSearch()">Esc</kbd>
</div>
<div id="search-results" class="search-results"></div>
</div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block content %}
<!-- Breadcrumb -->
<div class="breadcrumb">
<a href="/lists">Lists</a>
<span class="sep">/</span>
{% if domain %}<span style="color: {{ domain.color or 'var(--accent)' }}">{{ domain.name }}</span><span class="sep">/</span>{% endif %}
{% if project %}<a href="/projects/{{ project.id }}">{{ project.name }}</a><span class="sep">/</span>{% endif %}
<span>{{ item.name }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.name }}</h1>
<div class="flex gap-2">
<a href="/lists/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/lists/{{ item.id }}/delete" method="post" data-confirm="Delete this list?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="detail-meta-item">
<span class="row-tag">{{ item.list_type }}</span>
</span>
{% if item.description %}
<p class="text-secondary mt-1">{{ item.description }}</p>
{% endif %}
{% if item.tags %}
<div class="mt-1">
{% for tag in item.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Add item form -->
<form class="quick-add mt-3" action="/lists/{{ item.id }}/items/add" method="post">
<input type="text" name="content" placeholder="Add item..." required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<!-- List items -->
{% if list_items %}
<div class="card mt-2">
{% for li in list_items %}
<div class="list-row {{ 'completed' if li.completed }}">
{% if item.list_type == 'checklist' %}
<div class="row-check">
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="li-{{ li.id }}" {{ 'checked' if li.completed }}
onchange="this.form.submit()">
<label for="li-{{ li.id }}"></label>
</form>
</div>
{% endif %}
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if li.completed }}">
{{ li.content }}
</span>
<div class="row-actions">
<form action="/lists/{{ item.id }}/items/{{ li.id }}/delete" method="post" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
<!-- Child items -->
{% for child in child_map.get(li.id|string, []) %}
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
{% if item.list_type == 'checklist' %}
<div class="row-check">
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="li-{{ child.id }}" {{ 'checked' if child.completed }}
onchange="this.form.submit()">
<label for="li-{{ child.id }}"></label>
</form>
</div>
{% endif %}
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if child.completed }}">
{{ child.content }}
</span>
<div class="row-actions">
<form action="/lists/{{ item.id }}/items/{{ child.id }}/delete" method="post" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#9744;</div>
<div class="empty-state-text">No items yet. Add one above.</div>
</div>
{% endif %}
{% endblock %}

82
templates/list_form.html Normal file
View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/lists/' ~ item.id ~ '/edit' if item else '/lists/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Name *</label>
<input type="text" name="name" class="form-input" required
value="{{ item.name if item else '' }}">
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
<option value="">Select domain...</option>
{% for d in domains %}
<option value="{{ d.id }}"
{{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>
{{ d.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Area</label>
<select name="area_id" class="form-select">
<option value="">None</option>
{% for a in areas %}
<option value="{{ a.id }}"
{{ 'selected' if item and item.area_id and item.area_id|string == a.id|string }}>
{{ a.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">None</option>
{% for p in projects %}
<option value="{{ p.id }}"
{{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>
{{ p.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select name="list_type" class="form-select">
<option value="checklist" {{ 'selected' if item and item.list_type == 'checklist' }}>Checklist</option>
<option value="ordered" {{ 'selected' if item and item.list_type == 'ordered' }}>Ordered</option>
<option value="reference" {{ 'selected' if item and item.list_type == 'reference' }}>Reference</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create List' }}</button>
<a href="{{ '/lists/' ~ item.id if item else '/lists' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

60
templates/lists.html Normal file
View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Lists<span class="page-count">{{ items|length }}</span></h1>
<a href="/lists/create" class="btn btn-primary">+ New List</a>
</div>
<!-- Filters -->
<form class="filters-bar" method="get" action="/lists">
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
<option value="">All Domains</option>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
{% endfor %}
</select>
<select name="project_id" class="filter-select" onchange="this.form.submit()">
<option value="">All Projects</option>
{% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
{% endfor %}
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
<span class="row-meta">
{{ item.completed_count }}/{{ item.item_count }} items
</span>
{% if item.item_count > 0 %}
<div class="progress-bar" style="width: 80px;">
<div class="progress-fill" style="width: {{ (item.completed_count / item.item_count * 100) if item.item_count > 0 else 0 }}%"></div>
</div>
{% endif %}
<span class="row-tag">{{ item.list_type }}</span>
{% if item.domain_name %}
<span class="row-domain-tag" style="background: {{ item.domain_color or '#4F6EF7' }}22; color: {{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
{% endif %}
{% if item.project_name %}
<span class="row-tag">{{ item.project_name }}</span>
{% endif %}
<div class="row-actions">
<a href="/lists/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/lists/{{ item.id }}/delete" method="post" data-confirm="Delete this list?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#9776;</div>
<div class="empty-state-text">No lists yet</div>
<a href="/lists/create" class="btn btn-primary">Create First List</a>
</div>
{% endif %}
{% endblock %}

37
templates/search.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Search</h1>
</div>
<form class="search-page-form" method="get" action="/search">
<input type="text" name="q" value="{{ query }}" class="form-input" placeholder="Search tasks, projects, notes, contacts..." autofocus style="max-width: 600px;">
<button type="submit" class="btn btn-primary">Search</button>
</form>
{% if query %}
<p class="text-muted mt-2">{{ results|length }} result{{ 's' if results|length != 1 }} for "{{ query }}"</p>
{% endif %}
{% if results %}
<div class="card mt-3">
{% for item in results %}
<div class="list-row">
<span class="search-type-badge search-type-{{ item.type }}">{{ item.type_label }}</span>
<span class="row-title"><a href="{{ item.url }}">{{ item.name }}</a></span>
{% if item.context %}
<span class="row-meta">{{ item.context }}</span>
{% endif %}
{% if item.status %}
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% elif query %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#128269;</div>
<div class="empty-state-text">No results found for "{{ query }}"</div>
</div>
{% endif %}
{% endblock %}

54
templates/trash.html Normal file
View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Trash<span class="page-count">{{ total_deleted }}</span></h1>
{% if total_deleted > 0 %}
<form action="/admin/trash/empty" method="post" data-confirm="Permanently delete ALL {{ total_deleted }} items? This cannot be undone." style="display:inline">
<button type="submit" class="btn btn-danger">Empty Trash</button>
</form>
{% endif %}
</div>
<!-- Type filter -->
<div class="filters-bar">
<a href="/admin/trash" class="btn {{ 'btn-primary' if not current_type else 'btn-secondary' }} btn-sm">
All ({{ total_deleted }})
</a>
{% for entity in trash_entities %}
{% set count = entity_counts.get(entity.table, 0) %}
{% if count > 0 %}
<a href="/admin/trash?entity_type={{ entity.table }}" class="btn {{ 'btn-primary' if current_type == entity.table else 'btn-secondary' }} btn-sm">
{{ entity.label }} ({{ count }})
</a>
{% endif %}
{% endfor %}
</div>
{% if deleted_items %}
<div class="card mt-3">
{% for item in deleted_items %}
<div class="list-row">
<span class="search-type-badge search-type-{{ item.table }}">{{ item.type_label }}</span>
<span class="row-title">{{ item.name }}</span>
{% if item.deleted_at %}
<span class="row-meta">Deleted {{ item.deleted_at.strftime('%Y-%m-%d %H:%M') if item.deleted_at else '' }}</span>
{% endif %}
<div class="row-actions" style="opacity: 1;">
<form action="/admin/trash/{{ item.table }}/{{ item.id }}/restore" method="post" style="display:inline">
<button type="submit" class="btn btn-secondary btn-xs">Restore</button>
</form>
<form action="/admin/trash/{{ item.table }}/{{ item.id }}/permanent-delete" method="post"
data-confirm="Permanently delete '{{ item.name }}'? This cannot be undone." style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Forever</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#128465;</div>
<div class="empty-state-text">Trash is empty</div>
</div>
{% endif %}
{% endblock %}