diff --git a/core/base_repository.py b/core/base_repository.py index e04681e..a13d987 100644 --- a/core/base_repository.py +++ b/core/base_repository.py @@ -122,7 +122,7 @@ class BaseRepository: # Remove None values except for fields that should be nullable nullable_fields = { "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", "waiting_for_contact_id", "waiting_since", "color", } diff --git a/main.py b/main.py index 29a8c4b..d518def 100644 --- a/main.py +++ b/main.py @@ -30,6 +30,9 @@ from routers import ( focus as focus_router, capture as capture_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(capture_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) diff --git a/routers/admin.py b/routers/admin.py new file mode 100644 index 0000000..17105df --- /dev/null +++ b/routers/admin.py @@ -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) diff --git a/routers/lists.py b/routers/lists.py new file mode 100644 index 0000000..a4ccd2a --- /dev/null +++ b/routers/lists.py @@ -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) diff --git a/routers/search.py b/routers/search.py new file mode 100644 index 0000000..db39d55 --- /dev/null +++ b/routers/search.py @@ -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", + }) diff --git a/routers/tasks.py b/routers/tasks.py index 6361e4d..dffb523 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -290,7 +290,7 @@ async def update_task( @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.""" repo = BaseRepository("tasks", db) await repo.update(task_id, { diff --git a/static/app.js b/static/app.js index 5329746..3c11fd6 100644 --- a/static/app.js +++ b/static/app.js @@ -54,3 +54,117 @@ function toggleTheme() { document.head.insertAdjacentHTML('beforeend', '' ); + +// ---- 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 = '
No results found
'; + 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 += `
${label}
`; + items.forEach((item, i) => { + const isFirst = i === 0 && label === Object.keys(grouped)[0]; + html += ` + ${escHtml(item.name)} + ${item.context ? `${escHtml(item.context)}` : ''} + ${item.status ? `${item.status.replace('_', ' ')}` : ''} + `; + }); + } + container.innerHTML = html; + } catch (err) { + container.innerHTML = '
Search error
'; + } +} + +function escHtml(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} diff --git a/static/style.css b/static/style.css index 4093b45..bcb1b91 100644 --- a/static/style.css +++ b/static/style.css @@ -829,6 +829,152 @@ a:hover { color: var(--accent-hover); } .flex-1 { flex: 1; } .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 ---- */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/templates/base.html b/templates/base.html index 973a986..b68ec40 100644 --- a/templates/base.html +++ b/templates/base.html @@ -44,6 +44,10 @@ Links + + + Lists + Capture @@ -76,6 +80,10 @@ + + + diff --git a/templates/list_detail.html b/templates/list_detail.html new file mode 100644 index 0000000..712a3bb --- /dev/null +++ b/templates/list_detail.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block content %} + + + +
+

{{ item.name }}

+
+ Edit +
+ +
+
+
+ +
+ + {{ item.list_type }} + + {% if item.description %} +

{{ item.description }}

+ {% endif %} + {% if item.tags %} +
+ {% for tag in item.tags %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+ + +
+ + +
+ + +{% if list_items %} +
+ {% for li in list_items %} +
+ {% if item.list_type == 'checklist' %} +
+
+ + +
+
+ {% endif %} + + {{ li.content }} + +
+
+ +
+
+
+ + + {% for child in child_map.get(li.id|string, []) %} +
+ {% if item.list_type == 'checklist' %} +
+
+ + +
+
+ {% endif %} + + {{ child.content }} + +
+
+ +
+
+
+ {% endfor %} + {% endfor %} +
+{% else %} +
+
+
No items yet. Add one above.
+
+{% endif %} +{% endblock %} diff --git a/templates/list_form.html b/templates/list_form.html new file mode 100644 index 0000000..b251ce5 --- /dev/null +++ b/templates/list_form.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/lists.html b/templates/lists.html new file mode 100644 index 0000000..31c7d7e --- /dev/null +++ b/templates/lists.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block content %} + + + +
+ + +
+ +{% if items %} +
+ {% for item in items %} +
+ {{ item.name }} + + {{ item.completed_count }}/{{ item.item_count }} items + + {% if item.item_count > 0 %} +
+
+
+ {% endif %} + {{ item.list_type }} + {% if item.domain_name %} + {{ item.domain_name }} + {% endif %} + {% if item.project_name %} + {{ item.project_name }} + {% endif %} +
+ Edit +
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
+
No lists yet
+ Create First List +
+{% endif %} +{% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..0b63ab7 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block content %} + + +
+ + +
+ +{% if query %} +

{{ results|length }} result{{ 's' if results|length != 1 }} for "{{ query }}"

+{% endif %} + +{% if results %} +
+ {% for item in results %} +
+ {{ item.type_label }} + {{ item.name }} + {% if item.context %} + {{ item.context }} + {% endif %} + {% if item.status %} + {{ item.status|replace('_', ' ') }} + {% endif %} +
+ {% endfor %} +
+{% elif query %} +
+
🔍
+
No results found for "{{ query }}"
+
+{% endif %} +{% endblock %} diff --git a/templates/trash.html b/templates/trash.html new file mode 100644 index 0000000..c8928ec --- /dev/null +++ b/templates/trash.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block content %} + + + +
+ + All ({{ total_deleted }}) + + {% for entity in trash_entities %} + {% set count = entity_counts.get(entity.table, 0) %} + {% if count > 0 %} + + {{ entity.label }} ({{ count }}) + + {% endif %} + {% endfor %} +
+ +{% if deleted_items %} +
+ {% for item in deleted_items %} +
+ {{ item.type_label }} + {{ item.name }} + {% if item.deleted_at %} + Deleted {{ item.deleted_at.strftime('%Y-%m-%d %H:%M') if item.deleted_at else '' }} + {% endif %} +
+
+ +
+
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
🗑
+
Trash is empty
+
+{% endif %} +{% endblock %}