Session 1: bug fix, global search, admin trash, lists CRUD
This commit is contained in:
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
6
main.py
6
main.py
@@ -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
121
routers/admin.py
Normal 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
275
routers/lists.py
Normal 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
190
routers/search.py
Normal 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",
|
||||||
|
})
|
||||||
@@ -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, {
|
||||||
|
|||||||
114
static/app.js
114
static/app.js
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
146
static/style.css
146
static/style.css
@@ -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; }
|
||||||
|
|||||||
@@ -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>⌘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>
|
||||||
|
|||||||
98
templates/list_detail.html
Normal file
98
templates/list_detail.html
Normal 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">☐</div>
|
||||||
|
<div class="empty-state-text">No items yet. Add one above.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
82
templates/list_form.html
Normal file
82
templates/list_form.html
Normal 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
60
templates/lists.html
Normal 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">☰</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
37
templates/search.html
Normal 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">🔍</div>
|
||||||
|
<div class="empty-state-text">No results found for "{{ query }}"</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
54
templates/trash.html
Normal file
54
templates/trash.html
Normal 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">🗑</div>
|
||||||
|
<div class="empty-state-text">Trash is empty</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user