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
|
||||
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",
|
||||
}
|
||||
|
||||
6
main.py
6
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)
|
||||
|
||||
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")
|
||||
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, {
|
||||
|
||||
114
static/app.js
114
static/app.js
@@ -54,3 +54,117 @@ function toggleTheme() {
|
||||
document.head.insertAdjacentHTML('beforeend',
|
||||
'<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; }
|
||||
.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; }
|
||||
|
||||
@@ -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>
|
||||
Links
|
||||
</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' }}">
|
||||
<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
|
||||
@@ -76,6 +80,10 @@
|
||||
</div>
|
||||
|
||||
<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' }}">
|
||||
<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
|
||||
@@ -99,6 +107,11 @@
|
||||
<span class="topbar-env">DEV</span>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
<div class="page-content">
|
||||
@@ -106,6 +119,19 @@
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
</body>
|
||||
</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