Compare commits
23 Commits
ff9be1249a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97027f2de4 | |||
| 7a2c6d3f2a | |||
| 1bb92ea87d | |||
| b5c8f305dd | |||
| 9833f131e1 | |||
| 1a6b3fac1d | |||
| 4c072beec0 | |||
| bbb80067ef | |||
| 997ea786ba | |||
| 4c51a1daad | |||
| 73be08e7cc | |||
| d034f4af4e | |||
| f6a9b86131 | |||
| ec2bd51585 | |||
| 50200c23cc | |||
| 0be6566045 | |||
| 2094ea5fbe | |||
| f88c6e5fd4 | |||
| c42fe9dc13 | |||
| 34b232de5f | |||
| 497436a0a3 | |||
| 75b055299a | |||
| 41b974c804 |
@@ -161,7 +161,7 @@ class BaseRepository:
|
|||||||
"category", "instructions", "expected_output", "estimated_days",
|
"category", "instructions", "expected_output", "estimated_days",
|
||||||
"contact_id", "started_at",
|
"contact_id", "started_at",
|
||||||
"weekly_hours", "effective_from",
|
"weekly_hours", "effective_from",
|
||||||
"task_id", "meeting_id",
|
"task_id", "meeting_id", "list_item_id",
|
||||||
}
|
}
|
||||||
clean_data = {}
|
clean_data = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
@@ -248,3 +248,72 @@ class BaseRepository:
|
|||||||
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
{"order": (i + 1) * 10, "id": str(id)}
|
{"order": (i + 1) * 10, "id": str(id)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def swap_sort_order(self, id_a: str, id_b: str) -> None:
|
||||||
|
"""Swap sort_order between two rows."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(f"SELECT id, sort_order FROM {self.table} WHERE id IN (:a, :b)"),
|
||||||
|
{"a": str(id_a), "b": str(id_b)},
|
||||||
|
)
|
||||||
|
rows = {str(r._mapping["id"]): r._mapping["sort_order"] for r in result}
|
||||||
|
if len(rows) != 2:
|
||||||
|
return
|
||||||
|
await self.db.execute(
|
||||||
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
|
{"order": rows[str(id_b)], "id": str(id_a)},
|
||||||
|
)
|
||||||
|
await self.db.execute(
|
||||||
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
|
{"order": rows[str(id_a)], "id": str(id_b)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def move_in_order(self, item_id: str, direction: str, filters: dict | None = None) -> None:
|
||||||
|
"""Move an item up or down within its sort group.
|
||||||
|
|
||||||
|
Handles lazy initialization (all sort_order=0) and swaps with neighbor.
|
||||||
|
filters: optional dict to scope the group (e.g. {"list_id": some_id}).
|
||||||
|
"""
|
||||||
|
where_clauses = ["is_deleted = false"]
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if filters:
|
||||||
|
for i, (key, value) in enumerate(filters.items()):
|
||||||
|
if value is None:
|
||||||
|
where_clauses.append(f"{key} IS NULL")
|
||||||
|
else:
|
||||||
|
param_name = f"mf_{i}"
|
||||||
|
where_clauses.append(f"{key} = :{param_name}")
|
||||||
|
params[param_name] = value
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(f"SELECT id, sort_order FROM {self.table} WHERE {where_sql} ORDER BY sort_order, created_at"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
items = [dict(r._mapping) for r in result]
|
||||||
|
if len(items) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Lazy init: if all sort_order are 0, assign incremental values
|
||||||
|
if all(r["sort_order"] == 0 for r in items):
|
||||||
|
for i, r in enumerate(items):
|
||||||
|
await self.db.execute(
|
||||||
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
|
{"order": (i + 1) * 10, "id": str(r["id"])},
|
||||||
|
)
|
||||||
|
# Re-fetch
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(f"SELECT id, sort_order FROM {self.table} WHERE {where_sql} ORDER BY sort_order, created_at"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
ids = [str(r["id"]) for r in items]
|
||||||
|
if item_id not in ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
idx = ids.index(item_id)
|
||||||
|
if direction == "up" and idx > 0:
|
||||||
|
await self.swap_sort_order(ids[idx], ids[idx - 1])
|
||||||
|
elif direction == "down" and idx < len(ids) - 1:
|
||||||
|
await self.swap_sort_order(ids[idx], ids[idx + 1])
|
||||||
|
|||||||
49
core/template_filters.py
Normal file
49
core/template_filters.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Jinja2 custom template filters."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
|
# Match http(s):// URLs and bare www. URLs
|
||||||
|
_URL_RE = re.compile(
|
||||||
|
r'(https?://[^\s<>\"\'\]]+|www\.[^\s<>\"\'\]]+)',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trailing punctuation that usually isn't part of the URL
|
||||||
|
_TRAILING_PUNCT = re.compile(r'[.,;:!?\)]+$')
|
||||||
|
|
||||||
|
|
||||||
|
def autolink(text):
|
||||||
|
"""Detect URLs in plain text and wrap them in clickable <a> tags.
|
||||||
|
|
||||||
|
Escapes all content first (XSS-safe), then replaces URL patterns.
|
||||||
|
Returns Markup so Jinja2 won't double-escape.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
escaped = str(escape(text))
|
||||||
|
|
||||||
|
def _replace(match):
|
||||||
|
raw = match.group(0)
|
||||||
|
# Strip trailing punctuation that got captured
|
||||||
|
trail = ''
|
||||||
|
m = _TRAILING_PUNCT.search(raw)
|
||||||
|
if m:
|
||||||
|
trail = m.group(0)
|
||||||
|
raw = raw[:m.start()]
|
||||||
|
|
||||||
|
href = raw
|
||||||
|
if raw.lower().startswith('www.'):
|
||||||
|
href = 'https://' + raw
|
||||||
|
|
||||||
|
# Truncate display text for readability
|
||||||
|
display = raw if len(raw) <= 60 else raw[:57] + '...'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<a href="{href}" target="_blank" rel="noopener noreferrer" '
|
||||||
|
f'class="autolink">{display}</a>{trail}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _URL_RE.sub(_replace, escaped)
|
||||||
|
return Markup(result)
|
||||||
@@ -300,3 +300,15 @@ async def delete_appointment(
|
|||||||
repo = BaseRepository("appointments", db)
|
repo = BaseRepository("appointments", db)
|
||||||
await repo.soft_delete(appointment_id)
|
await repo.soft_delete(appointment_id)
|
||||||
return RedirectResponse(url="/appointments", status_code=303)
|
return RedirectResponse(url="/appointments", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_appointment(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("appointments", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/appointments"), status_code=303)
|
||||||
|
|||||||
@@ -124,3 +124,15 @@ async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
repo = BaseRepository("contacts", db)
|
repo = BaseRepository("contacts", db)
|
||||||
await repo.soft_delete(contact_id)
|
await repo.soft_delete(contact_id)
|
||||||
return RedirectResponse(url="/contacts", status_code=303)
|
return RedirectResponse(url="/contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_contact(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("contacts", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/contacts"), status_code=303)
|
||||||
|
|||||||
@@ -216,3 +216,15 @@ async def delete_decision(decision_id: str, request: Request, db: AsyncSession =
|
|||||||
repo = BaseRepository("decisions", db)
|
repo = BaseRepository("decisions", db)
|
||||||
await repo.soft_delete(decision_id)
|
await repo.soft_delete(decision_id)
|
||||||
return RedirectResponse(url="/decisions", status_code=303)
|
return RedirectResponse(url="/decisions", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_decision(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("decisions", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/decisions"), status_code=303)
|
||||||
|
|||||||
152
routers/files.py
152
routers/files.py
@@ -91,9 +91,11 @@ async def sync_files(db: AsyncSession):
|
|||||||
))
|
))
|
||||||
db_records = [dict(r._mapping) for r in result]
|
db_records = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Build lookup sets
|
# Build lookup maps
|
||||||
all_db_paths = {r["storage_path"] for r in db_records}
|
all_db_paths = {r["storage_path"] for r in db_records}
|
||||||
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
|
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
|
||||||
|
deleted_on_disk = {r["storage_path"]: r for r in db_records
|
||||||
|
if r["is_deleted"] and r["storage_path"] in disk_files}
|
||||||
|
|
||||||
# New on disk, not in DB at all → create record
|
# New on disk, not in DB at all → create record
|
||||||
new_files = disk_files - all_db_paths
|
new_files = disk_files - all_db_paths
|
||||||
@@ -116,6 +118,12 @@ async def sync_files(db: AsyncSession):
|
|||||||
})
|
})
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
|
# Soft-deleted in DB but still on disk → restore
|
||||||
|
for rel_path, record in deleted_on_disk.items():
|
||||||
|
repo = BaseRepository("files", db)
|
||||||
|
await repo.restore(record["id"])
|
||||||
|
added += 1
|
||||||
|
|
||||||
# Active in DB but missing from disk → soft-delete
|
# Active in DB but missing from disk → soft-delete
|
||||||
missing_files = active_db_paths - disk_files
|
missing_files = active_db_paths - disk_files
|
||||||
for record in db_records:
|
for record in db_records:
|
||||||
@@ -127,10 +135,36 @@ async def sync_files(db: AsyncSession):
|
|||||||
return {"added": added, "removed": removed}
|
return {"added": added, "removed": removed}
|
||||||
|
|
||||||
|
|
||||||
|
SORT_OPTIONS = {
|
||||||
|
"path": "storage_path ASC",
|
||||||
|
"path_desc": "storage_path DESC",
|
||||||
|
"name": "original_filename ASC",
|
||||||
|
"name_desc": "original_filename DESC",
|
||||||
|
"date": "created_at DESC",
|
||||||
|
"date_asc": "created_at ASC",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map type filter values to mime prefixes/patterns
|
||||||
|
TYPE_FILTERS = {
|
||||||
|
"image": "image/%",
|
||||||
|
"document": "application/pdf",
|
||||||
|
"text": "text/%",
|
||||||
|
"spreadsheet": "application/vnd.%sheet%",
|
||||||
|
"archive": "application/%zip%",
|
||||||
|
}
|
||||||
|
|
||||||
|
PER_PAGE = 50
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_files(
|
async def list_files(
|
||||||
request: Request,
|
request: Request,
|
||||||
folder: Optional[str] = None,
|
folder: Optional[str] = None,
|
||||||
|
sort: Optional[str] = None,
|
||||||
|
q: Optional[str] = None,
|
||||||
|
file_type: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
context_type: Optional[str] = None,
|
context_type: Optional[str] = None,
|
||||||
context_id: Optional[str] = None,
|
context_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -142,38 +176,88 @@ async def list_files(
|
|||||||
|
|
||||||
folders = get_folders()
|
folders = get_folders()
|
||||||
|
|
||||||
|
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
|
||||||
|
|
||||||
|
# Normalize folder param from form: " " (space) = root, "" = all, None = all
|
||||||
|
if folder is not None:
|
||||||
|
if folder.strip() == "" and folder != "":
|
||||||
|
# Space-only value means root folder
|
||||||
|
folder = ""
|
||||||
|
elif folder.strip() == "":
|
||||||
|
# Empty string means "all folders" (no filter)
|
||||||
|
folder = None
|
||||||
|
|
||||||
|
# Build dynamic WHERE clauses
|
||||||
|
where_clauses = ["f.is_deleted = false"]
|
||||||
|
params = {}
|
||||||
|
|
||||||
if context_type and context_id:
|
if context_type and context_id:
|
||||||
# Files attached to a specific entity
|
where_clauses.append("fm.context_type = :ct AND fm.context_id = :cid")
|
||||||
result = await db.execute(text("""
|
params["ct"] = context_type
|
||||||
|
params["cid"] = context_id
|
||||||
|
|
||||||
|
if folder is not None:
|
||||||
|
if folder == "":
|
||||||
|
where_clauses.append("f.storage_path NOT LIKE '%/%'")
|
||||||
|
else:
|
||||||
|
where_clauses.append("f.storage_path LIKE :prefix")
|
||||||
|
params["prefix"] = folder + "/%"
|
||||||
|
|
||||||
|
if q and q.strip():
|
||||||
|
search_terms = q.strip().split()
|
||||||
|
tsquery = " & ".join(f"{t}:*" for t in search_terms)
|
||||||
|
where_clauses.append("f.search_vector @@ to_tsquery('english', :tsquery)")
|
||||||
|
params["tsquery"] = tsquery
|
||||||
|
|
||||||
|
if file_type and file_type in TYPE_FILTERS:
|
||||||
|
where_clauses.append("f.mime_type LIKE :mime_pattern")
|
||||||
|
params["mime_pattern"] = TYPE_FILTERS[file_type]
|
||||||
|
|
||||||
|
if tag and tag.strip():
|
||||||
|
where_clauses.append(":tag = ANY(f.tags)")
|
||||||
|
params["tag"] = tag.strip()
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
# Count total for pagination
|
||||||
|
if context_type and context_id:
|
||||||
|
count_sql = f"""
|
||||||
|
SELECT COUNT(*) FROM files f
|
||||||
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
count_sql = f"SELECT COUNT(*) FROM files f WHERE {where_sql}"
|
||||||
|
|
||||||
|
total = (await db.execute(text(count_sql), params)).scalar()
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
offset = (page - 1) * PER_PAGE
|
||||||
|
total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
|
||||||
|
|
||||||
|
# Query
|
||||||
|
if context_type and context_id:
|
||||||
|
query_sql = f"""
|
||||||
SELECT f.*, fm.context_type, fm.context_id
|
SELECT f.*, fm.context_type, fm.context_id
|
||||||
FROM files f
|
FROM files f
|
||||||
JOIN file_mappings fm ON fm.file_id = f.id
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
|
WHERE {where_sql}
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY {order_by}
|
||||||
"""), {"ct": context_type, "cid": context_id})
|
LIMIT :lim OFFSET :off
|
||||||
elif folder is not None:
|
"""
|
||||||
if folder == "":
|
|
||||||
# Root folder: files with no directory separator in storage_path
|
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT * FROM files
|
|
||||||
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"""))
|
|
||||||
else:
|
else:
|
||||||
# Specific folder: storage_path starts with folder/
|
query_sql = f"""
|
||||||
result = await db.execute(text("""
|
SELECT f.* FROM files f
|
||||||
SELECT * FROM files
|
WHERE {where_sql}
|
||||||
WHERE is_deleted = false AND storage_path LIKE :prefix
|
ORDER BY {order_by}
|
||||||
ORDER BY created_at DESC
|
LIMIT :lim OFFSET :off
|
||||||
"""), {"prefix": folder + "/%"})
|
"""
|
||||||
else:
|
|
||||||
# All files
|
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT * FROM files
|
|
||||||
WHERE is_deleted = false
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"""))
|
|
||||||
|
|
||||||
|
params["lim"] = PER_PAGE
|
||||||
|
params["off"] = offset
|
||||||
|
result = await db.execute(text(query_sql), params)
|
||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Add derived folder field for display
|
# Add derived folder field for display
|
||||||
@@ -181,9 +265,23 @@ async def list_files(
|
|||||||
dirname = os.path.dirname(item["storage_path"])
|
dirname = os.path.dirname(item["storage_path"])
|
||||||
item["folder"] = dirname if dirname else "/"
|
item["folder"] = dirname if dirname else "/"
|
||||||
|
|
||||||
|
# Get all unique tags for the tag filter dropdown
|
||||||
|
tag_result = await db.execute(text(
|
||||||
|
"SELECT DISTINCT unnest(tags) AS tag FROM files WHERE is_deleted = false AND tags IS NOT NULL ORDER BY tag"
|
||||||
|
))
|
||||||
|
all_tags = [r._mapping["tag"] for r in tag_result]
|
||||||
|
|
||||||
return templates.TemplateResponse("files.html", {
|
return templates.TemplateResponse("files.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
"folders": folders, "current_folder": folder,
|
"folders": folders, "current_folder": folder,
|
||||||
|
"current_sort": sort or "path",
|
||||||
|
"current_q": q or "",
|
||||||
|
"current_type": file_type or "",
|
||||||
|
"current_tag": tag or "",
|
||||||
|
"current_page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"total_files": total,
|
||||||
|
"all_tags": all_tags,
|
||||||
"sync_result": sync_result,
|
"sync_result": sync_result,
|
||||||
"context_type": context_type or "",
|
"context_type": context_type or "",
|
||||||
"context_id": context_id or "",
|
"context_id": context_id or "",
|
||||||
|
|||||||
164
routers/focus.py
164
routers/focus.py
@@ -1,4 +1,4 @@
|
|||||||
"""Daily Focus: date-scoped task commitment list."""
|
"""Daily Focus: date-scoped task/list-item commitment list."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form, Depends
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -11,9 +11,11 @@ from datetime import date, datetime, timezone
|
|||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
from core.base_repository import BaseRepository
|
from core.base_repository import BaseRepository
|
||||||
from core.sidebar import get_sidebar_data
|
from core.sidebar import get_sidebar_data
|
||||||
|
from core.template_filters import autolink
|
||||||
|
|
||||||
router = APIRouter(prefix="/focus", tags=["focus"])
|
router = APIRouter(prefix="/focus", tags=["focus"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
templates.env.filters["autolink"] = autolink
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@@ -23,33 +25,74 @@ async def focus_view(
|
|||||||
domain_id: Optional[str] = None,
|
domain_id: Optional[str] = None,
|
||||||
area_id: Optional[str] = None,
|
area_id: Optional[str] = None,
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
source_type: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
|
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
|
||||||
|
if not source_type:
|
||||||
|
source_type = "tasks"
|
||||||
|
|
||||||
|
# --- Focus items (both tasks and list items) ---
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT df.*, t.title, t.priority, t.status as task_status,
|
SELECT df.*,
|
||||||
|
t.title as title, t.priority, t.status as task_status,
|
||||||
t.project_id, t.due_date, t.estimated_minutes,
|
t.project_id, t.due_date, t.estimated_minutes,
|
||||||
p.name as project_name,
|
COALESCE(p.name, lp.name) as project_name,
|
||||||
d.name as domain_name, d.color as domain_color
|
COALESCE(t.project_id, l.project_id) as effective_project_id,
|
||||||
|
COALESCE(d.name, ld.name) as domain_name,
|
||||||
|
COALESCE(d.color, ld.color) as domain_color,
|
||||||
|
COALESCE(d.id, ld.id) as effective_domain_id,
|
||||||
|
COALESCE(a.name, pa.name, la.name) as area_name,
|
||||||
|
COALESCE(a.id, pa.id, la.id) as effective_area_id,
|
||||||
|
li.content as list_item_content, li.list_id as list_item_list_id,
|
||||||
|
li.completed as list_item_completed,
|
||||||
|
l.name as list_name
|
||||||
FROM daily_focus df
|
FROM daily_focus df
|
||||||
JOIN tasks t ON df.task_id = t.id
|
LEFT JOIN tasks t ON df.task_id = t.id
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
LEFT JOIN domains d ON t.domain_id = d.id
|
LEFT JOIN domains d ON t.domain_id = d.id
|
||||||
|
LEFT JOIN areas a ON t.area_id = a.id
|
||||||
|
LEFT JOIN areas pa ON p.area_id = pa.id
|
||||||
|
LEFT JOIN list_items li ON df.list_item_id = li.id
|
||||||
|
LEFT JOIN lists l ON li.list_id = l.id
|
||||||
|
LEFT JOIN projects lp ON l.project_id = lp.id
|
||||||
|
LEFT JOIN domains ld ON l.domain_id = ld.id
|
||||||
|
LEFT JOIN areas la ON l.area_id = la.id
|
||||||
WHERE df.focus_date = :target_date AND df.is_deleted = false
|
WHERE df.focus_date = :target_date AND df.is_deleted = false
|
||||||
|
AND (t.id IS NULL OR t.is_deleted = false)
|
||||||
|
AND (li.id IS NULL OR li.is_deleted = false)
|
||||||
ORDER BY df.sort_order, df.created_at
|
ORDER BY df.sort_order, df.created_at
|
||||||
"""), {"target_date": target_date})
|
"""), {"target_date": target_date})
|
||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Available tasks to add (open, not already in today's focus)
|
# Group items by domain only — area/project shown as inline columns
|
||||||
|
from collections import OrderedDict
|
||||||
|
domain_map = OrderedDict()
|
||||||
|
for item in items:
|
||||||
|
dk = item.get("effective_domain_id") or "__none__"
|
||||||
|
dl = item.get("domain_name") or "General"
|
||||||
|
dc = item.get("domain_color") or ""
|
||||||
|
if dk not in domain_map:
|
||||||
|
domain_map[dk] = {"key": dk, "label": dl, "color": dc, "rows": []}
|
||||||
|
domain_map[dk]["rows"].append(item)
|
||||||
|
|
||||||
|
hierarchy = list(domain_map.values())
|
||||||
|
|
||||||
|
# --- Available tasks ---
|
||||||
|
available_tasks = []
|
||||||
|
if source_type == "tasks":
|
||||||
avail_where = [
|
avail_where = [
|
||||||
"t.is_deleted = false",
|
"t.is_deleted = false",
|
||||||
"t.status NOT IN ('done', 'cancelled')",
|
"t.status NOT IN ('done', 'cancelled')",
|
||||||
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false)",
|
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false AND task_id IS NOT NULL)",
|
||||||
]
|
]
|
||||||
avail_params = {"target_date": target_date}
|
avail_params = {"target_date": target_date}
|
||||||
|
|
||||||
|
if search:
|
||||||
|
avail_where.append("t.title ILIKE :search")
|
||||||
|
avail_params["search"] = f"%{search}%"
|
||||||
if domain_id:
|
if domain_id:
|
||||||
avail_where.append("t.domain_id = :domain_id")
|
avail_where.append("t.domain_id = :domain_id")
|
||||||
avail_params["domain_id"] = domain_id
|
avail_params["domain_id"] = domain_id
|
||||||
@@ -61,7 +104,6 @@ async def focus_view(
|
|||||||
avail_params["project_id"] = project_id
|
avail_params["project_id"] = project_id
|
||||||
|
|
||||||
avail_sql = " AND ".join(avail_where)
|
avail_sql = " AND ".join(avail_where)
|
||||||
|
|
||||||
result = await db.execute(text(f"""
|
result = await db.execute(text(f"""
|
||||||
SELECT t.id, t.title, t.priority, t.due_date,
|
SELECT t.id, t.title, t.priority, t.due_date,
|
||||||
p.name as project_name, d.name as domain_name
|
p.name as project_name, d.name as domain_name
|
||||||
@@ -70,10 +112,47 @@ async def focus_view(
|
|||||||
LEFT JOIN domains d ON t.domain_id = d.id
|
LEFT JOIN domains d ON t.domain_id = d.id
|
||||||
WHERE {avail_sql}
|
WHERE {avail_sql}
|
||||||
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
||||||
LIMIT 50
|
LIMIT 200
|
||||||
"""), avail_params)
|
"""), avail_params)
|
||||||
available_tasks = [dict(r._mapping) for r in result]
|
available_tasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# --- Available list items ---
|
||||||
|
available_list_items = []
|
||||||
|
if source_type == "list_items":
|
||||||
|
li_where = [
|
||||||
|
"li.is_deleted = false",
|
||||||
|
"li.completed = false",
|
||||||
|
"l.is_deleted = false",
|
||||||
|
"li.id NOT IN (SELECT list_item_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false AND list_item_id IS NOT NULL)",
|
||||||
|
]
|
||||||
|
li_params = {"target_date": target_date}
|
||||||
|
|
||||||
|
if search:
|
||||||
|
li_where.append("li.content ILIKE :search")
|
||||||
|
li_params["search"] = f"%{search}%"
|
||||||
|
if domain_id:
|
||||||
|
li_where.append("l.domain_id = :domain_id")
|
||||||
|
li_params["domain_id"] = domain_id
|
||||||
|
if area_id:
|
||||||
|
li_where.append("l.area_id = :area_id")
|
||||||
|
li_params["area_id"] = area_id
|
||||||
|
if project_id:
|
||||||
|
li_where.append("l.project_id = :project_id")
|
||||||
|
li_params["project_id"] = project_id
|
||||||
|
|
||||||
|
li_sql = " AND ".join(li_where)
|
||||||
|
result = await db.execute(text(f"""
|
||||||
|
SELECT li.id, li.content, li.list_id, l.name as list_name,
|
||||||
|
d.name as domain_name
|
||||||
|
FROM list_items li
|
||||||
|
JOIN lists l ON li.list_id = l.id
|
||||||
|
LEFT JOIN domains d ON l.domain_id = d.id
|
||||||
|
WHERE {li_sql}
|
||||||
|
ORDER BY l.name ASC, li.sort_order ASC
|
||||||
|
LIMIT 200
|
||||||
|
"""), li_params)
|
||||||
|
available_list_items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Estimated total minutes
|
# Estimated total minutes
|
||||||
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
|
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
|
||||||
|
|
||||||
@@ -87,13 +166,17 @@ async def focus_view(
|
|||||||
|
|
||||||
return templates.TemplateResponse("focus.html", {
|
return templates.TemplateResponse("focus.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"items": items, "available_tasks": available_tasks,
|
"items": items, "hierarchy": hierarchy,
|
||||||
|
"available_tasks": available_tasks,
|
||||||
|
"available_list_items": available_list_items,
|
||||||
"focus_date": target_date,
|
"focus_date": target_date,
|
||||||
"total_estimated": total_est,
|
"total_estimated": total_est,
|
||||||
"domains": domains, "areas": areas, "projects": projects,
|
"domains": domains, "areas": areas, "projects": projects,
|
||||||
"current_domain_id": domain_id or "",
|
"current_domain_id": domain_id or "",
|
||||||
"current_area_id": area_id or "",
|
"current_area_id": area_id or "",
|
||||||
"current_project_id": project_id or "",
|
"current_project_id": project_id or "",
|
||||||
|
"current_search": search or "",
|
||||||
|
"current_source_type": source_type,
|
||||||
"page_title": "Daily Focus", "active_nav": "focus",
|
"page_title": "Daily Focus", "active_nav": "focus",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,8 +184,9 @@ async def focus_view(
|
|||||||
@router.post("/add")
|
@router.post("/add")
|
||||||
async def add_to_focus(
|
async def add_to_focus(
|
||||||
request: Request,
|
request: Request,
|
||||||
task_id: str = Form(...),
|
|
||||||
focus_date: str = Form(...),
|
focus_date: str = Form(...),
|
||||||
|
task_id: Optional[str] = Form(None),
|
||||||
|
list_item_id: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
repo = BaseRepository("daily_focus", db)
|
repo = BaseRepository("daily_focus", db)
|
||||||
@@ -114,10 +198,45 @@ async def add_to_focus(
|
|||||||
"""), {"fd": parsed_date})
|
"""), {"fd": parsed_date})
|
||||||
next_order = result.scalar()
|
next_order = result.scalar()
|
||||||
|
|
||||||
await repo.create({
|
data = {
|
||||||
"task_id": task_id, "focus_date": parsed_date,
|
"focus_date": parsed_date,
|
||||||
"sort_order": next_order, "completed": False,
|
"sort_order": next_order,
|
||||||
})
|
"completed": False,
|
||||||
|
}
|
||||||
|
if task_id:
|
||||||
|
data["task_id"] = task_id
|
||||||
|
if list_item_id:
|
||||||
|
data["list_item_id"] = list_item_id
|
||||||
|
|
||||||
|
await repo.create(data)
|
||||||
|
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_focus(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
focus_date: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("daily_focus", db)
|
||||||
|
parsed_date = date.fromisoformat(focus_date)
|
||||||
|
await repo.move_in_order(item_id, direction, filters={"focus_date": parsed_date})
|
||||||
|
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder-all")
|
||||||
|
async def reorder_all_focus(
|
||||||
|
request: Request,
|
||||||
|
item_ids: str = Form(...),
|
||||||
|
focus_date: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("daily_focus", db)
|
||||||
|
ids = [i.strip() for i in item_ids.split(",") if i.strip()]
|
||||||
|
if ids:
|
||||||
|
await repo.reorder(ids)
|
||||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,12 +246,25 @@ async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depen
|
|||||||
item = await repo.get(focus_id)
|
item = await repo.get(focus_id)
|
||||||
if item:
|
if item:
|
||||||
await repo.update(focus_id, {"completed": not item["completed"]})
|
await repo.update(focus_id, {"completed": not item["completed"]})
|
||||||
# Also toggle the task status
|
if item["task_id"]:
|
||||||
|
# Sync task status
|
||||||
task_repo = BaseRepository("tasks", db)
|
task_repo = BaseRepository("tasks", db)
|
||||||
if not item["completed"]:
|
if not item["completed"]:
|
||||||
await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)})
|
await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)})
|
||||||
else:
|
else:
|
||||||
await task_repo.update(item["task_id"], {"status": "open", "completed_at": None})
|
await task_repo.update(item["task_id"], {"status": "open", "completed_at": None})
|
||||||
|
elif item["list_item_id"]:
|
||||||
|
# Sync list item completed status
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if not item["completed"]:
|
||||||
|
await db.execute(text(
|
||||||
|
"UPDATE list_items SET completed = true, completed_at = :now, updated_at = :now WHERE id = :id"
|
||||||
|
), {"id": item["list_item_id"], "now": now})
|
||||||
|
else:
|
||||||
|
await db.execute(text(
|
||||||
|
"UPDATE list_items SET completed = false, completed_at = NULL, updated_at = :now WHERE id = :id"
|
||||||
|
), {"id": item["list_item_id"], "now": now})
|
||||||
|
await db.commit()
|
||||||
focus_date = item["focus_date"] if item else date.today()
|
focus_date = item["focus_date"] if item else date.today()
|
||||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||||
|
|
||||||
|
|||||||
@@ -154,3 +154,15 @@ async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
repo = BaseRepository("links", db)
|
repo = BaseRepository("links", db)
|
||||||
await repo.soft_delete(link_id)
|
await repo.soft_delete(link_id)
|
||||||
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_link(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("links", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from datetime import datetime, timezone
|
|||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
from core.base_repository import BaseRepository
|
from core.base_repository import BaseRepository
|
||||||
from core.sidebar import get_sidebar_data
|
from core.sidebar import get_sidebar_data
|
||||||
|
from core.template_filters import autolink
|
||||||
|
|
||||||
router = APIRouter(prefix="/lists", tags=["lists"])
|
router = APIRouter(prefix="/lists", tags=["lists"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
templates.env.filters["autolink"] = autolink
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@@ -187,11 +189,19 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
"""))
|
"""))
|
||||||
all_contacts = [dict(r._mapping) for r in result]
|
all_contacts = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# All links for insert-into-content picker
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, label, url FROM links
|
||||||
|
WHERE is_deleted = false ORDER BY label
|
||||||
|
"""))
|
||||||
|
all_links = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("list_detail.html", {
|
return templates.TemplateResponse("list_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"domain": domain, "project": project,
|
"domain": domain, "project": project,
|
||||||
"list_items": top_items, "child_map": child_map,
|
"list_items": top_items, "child_map": child_map,
|
||||||
"contacts": contacts, "all_contacts": all_contacts,
|
"contacts": contacts, "all_contacts": all_contacts,
|
||||||
|
"all_links": all_links,
|
||||||
"page_title": item["name"], "active_nav": "lists",
|
"page_title": item["name"], "active_nav": "lists",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -332,3 +342,35 @@ async def remove_contact(
|
|||||||
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
|
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
|
||||||
), {"cid": contact_id, "lid": list_id})
|
), {"cid": contact_id, "lid": list_id})
|
||||||
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_list(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("lists", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{list_id}/items/reorder")
|
||||||
|
async def reorder_list_item(
|
||||||
|
list_id: str,
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
parent_id: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("list_items", db)
|
||||||
|
filters = {"list_id": list_id}
|
||||||
|
if parent_id:
|
||||||
|
filters["parent_item_id"] = parent_id
|
||||||
|
else:
|
||||||
|
# Top-level items only (no parent)
|
||||||
|
filters["parent_item_id"] = None
|
||||||
|
await repo.move_in_order(item_id, direction, filters=filters)
|
||||||
|
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||||
|
|||||||
@@ -404,3 +404,15 @@ async def remove_contact(
|
|||||||
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
|
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
|
||||||
), {"cid": contact_id, "mid": meeting_id})
|
), {"cid": contact_id, "mid": meeting_id})
|
||||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_meeting(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("meetings", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/meetings"), status_code=303)
|
||||||
|
|||||||
@@ -193,3 +193,15 @@ async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
await repo.soft_delete(note_id)
|
await repo.soft_delete(note_id)
|
||||||
referer = request.headers.get("referer", "/notes")
|
referer = request.headers.get("referer", "/notes")
|
||||||
return RedirectResponse(url=referer, status_code=303)
|
return RedirectResponse(url=referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_note(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("notes", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/notes"), status_code=303)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async def list_tasks(
|
|||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
priority: Optional[str] = None,
|
priority: Optional[str] = None,
|
||||||
context: Optional[str] = None,
|
context: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
sort: str = "sort_order",
|
sort: str = "sort_order",
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -40,6 +41,9 @@ async def list_tasks(
|
|||||||
|
|
||||||
where_clauses = ["t.is_deleted = false"]
|
where_clauses = ["t.is_deleted = false"]
|
||||||
params = {}
|
params = {}
|
||||||
|
if search:
|
||||||
|
where_clauses.append("t.title ILIKE :search")
|
||||||
|
params["search"] = f"%{search}%"
|
||||||
if domain_id:
|
if domain_id:
|
||||||
where_clauses.append("t.domain_id = :domain_id")
|
where_clauses.append("t.domain_id = :domain_id")
|
||||||
params["domain_id"] = domain_id
|
params["domain_id"] = domain_id
|
||||||
@@ -97,6 +101,7 @@ async def list_tasks(
|
|||||||
return templates.TemplateResponse("tasks.html", {
|
return templates.TemplateResponse("tasks.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
"domains": domains, "projects": projects, "context_types": context_types,
|
"domains": domains, "projects": projects, "context_types": context_types,
|
||||||
|
"current_search": search or "",
|
||||||
"current_domain_id": domain_id or "",
|
"current_domain_id": domain_id or "",
|
||||||
"current_project_id": project_id or "",
|
"current_project_id": project_id or "",
|
||||||
"current_status": status or "",
|
"current_status": status or "",
|
||||||
@@ -422,8 +427,7 @@ async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("tasks", db)
|
repo = BaseRepository("tasks", db)
|
||||||
await repo.soft_delete(task_id)
|
await repo.soft_delete(task_id)
|
||||||
referer = request.headers.get("referer", "/tasks")
|
return RedirectResponse(url="/tasks", status_code=303)
|
||||||
return RedirectResponse(url=referer, status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
# Quick add from any task list
|
# Quick add from any task list
|
||||||
@@ -481,3 +485,15 @@ async def remove_contact(
|
|||||||
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
|
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
|
||||||
), {"cid": contact_id, "tid": task_id})
|
), {"cid": contact_id, "tid": task_id})
|
||||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_task(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("tasks", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
|
||||||
|
|||||||
125
static/style.css
125
static/style.css
@@ -152,11 +152,11 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-size: 0.80rem;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
@@ -201,7 +201,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.80rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -233,7 +233,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.project-link {
|
.project-link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 4px 10px 4px 18px;
|
padding: 4px 10px 4px 18px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.80rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -401,8 +401,8 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.list-row {
|
.list-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
@@ -466,6 +466,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
|
|
||||||
.row-title {
|
.row-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
font-size: 0.80rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -735,6 +736,9 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.detail-body {
|
.detail-body {
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-body p { margin-bottom: 1em; }
|
.detail-body p { margin-bottom: 1em; }
|
||||||
@@ -792,17 +796,15 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.capture-item {
|
.capture-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 6px 12px;
|
||||||
background: var(--surface);
|
border-bottom: 1px solid var(--border);
|
||||||
border: 1px solid var(--border);
|
transition: background var(--transition);
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-text {
|
.capture-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.92rem;
|
font-size: 0.80rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-actions {
|
.capture-actions {
|
||||||
@@ -815,20 +817,17 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.focus-item {
|
.focus-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 14px 16px;
|
padding: 6px 12px;
|
||||||
background: var(--surface);
|
border-bottom: 1px solid var(--border);
|
||||||
border: 1px solid var(--border);
|
transition: background var(--transition);
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
transition: all var(--transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-item:hover { border-color: var(--accent); }
|
.focus-item:hover { background: var(--surface2); }
|
||||||
.focus-item.completed { opacity: 0.6; }
|
.focus-item.completed { opacity: 0.6; }
|
||||||
.focus-item.completed .focus-title { text-decoration: line-through; }
|
.focus-item.completed .focus-title { text-decoration: line-through; }
|
||||||
|
|
||||||
.focus-title { flex: 1; font-weight: 500; }
|
.focus-title { flex: 1; font-size: 0.80rem; font-weight: 500; }
|
||||||
.focus-meta { font-size: 0.78rem; color: var(--muted); }
|
.focus-meta { font-size: 0.78rem; color: var(--muted); }
|
||||||
|
|
||||||
/* ---- Alerts ---- */
|
/* ---- Alerts ---- */
|
||||||
@@ -1108,6 +1107,90 @@ a:hover { color: var(--accent-hover); }
|
|||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Focus Domain Toggle ---- */
|
||||||
|
.focus-domain-header:hover td { background: var(--surface3); }
|
||||||
|
|
||||||
|
/* ---- Focus Drag-and-Drop ---- */
|
||||||
|
.focus-drag-row { cursor: grab; }
|
||||||
|
.focus-drag-row.dragging { opacity: 0.4; background: var(--accent-soft); }
|
||||||
|
.focus-drag-row.drag-over { box-shadow: 0 -2px 0 0 var(--accent) inset; }
|
||||||
|
|
||||||
|
/* ---- Reorder Grip Handle ---- */
|
||||||
|
.reorder-grip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.reorder-grip:hover { opacity: 0.9; }
|
||||||
|
.reorder-grip form { display: block; margin: 0; padding: 0; line-height: 0; }
|
||||||
|
.grip-btn {
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 10px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.grip-btn:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- Link Picker & Inline Edit ---- */
|
||||||
|
.link-picker {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.link-picker:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
|
||||||
|
.inline-edit-form {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inline-edit-form input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.inline-edit-form input[type="text"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Autolinked URLs in list items ---- */
|
||||||
|
.autolink {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--accent-soft);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition: text-decoration-color var(--transition);
|
||||||
|
}
|
||||||
|
.autolink:hover {
|
||||||
|
text-decoration-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Scrollbar ---- */
|
/* ---- Scrollbar ---- */
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
|
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
|
||||||
{% if appt_date != current_date.value %}
|
{% if appt_date != current_date.value %}
|
||||||
{% if not loop.first %}</div>{% endif %}
|
{% if not loop.first %}</div>{% endif %}
|
||||||
<div class="date-group-label" style="padding: 12px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
<div class="date-group-label" style="padding: 6px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
||||||
{{ appt_date }}
|
{{ appt_date }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -30,6 +30,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/appointments/reorder", item_id=appt.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<div style="flex-shrink: 0; min-width: 60px;">
|
<div style="flex-shrink: 0; min-width: 60px;">
|
||||||
{% if appt.all_day %}
|
{% if appt.all_day %}
|
||||||
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
|
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
|
||||||
|
|||||||
@@ -53,11 +53,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
|
|
||||||
{# Batch header #}
|
{# Batch header #}
|
||||||
{% if item._batch_first and item.import_batch_id %}
|
{% if item._batch_first and item.import_batch_id %}
|
||||||
<div class="flex items-center justify-between mb-2 mt-3" style="padding:6px 12px;background:var(--surface2);border-radius:var(--radius-sm)">
|
<div class="flex items-center justify-between" style="padding:6px 12px;background:var(--surface2);">
|
||||||
<span class="text-xs text-muted">Batch · {{ batches[item.import_batch_id|string] }} items</span>
|
<span class="text-xs text-muted">Batch · {{ batches[item.import_batch_id|string] }} items</span>
|
||||||
<form action="/capture/batch/{{ item.import_batch_id }}/undo" method="post" style="display:inline" data-confirm="Delete all items in this batch?">
|
<form action="/capture/batch/{{ item.import_batch_id }}/undo" method="post" style="display:inline" data-confirm="Delete all items in this batch?">
|
||||||
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Undo batch</button>
|
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Undo batch</button>
|
||||||
@@ -99,6 +100,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📥</div>
|
<div class="empty-state-icon">📥</div>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/contacts/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/contacts/{{ item.id }}">{{ item.first_name }} {{ item.last_name or '' }}</a></span>
|
<span class="row-title"><a href="/contacts/{{ item.id }}">{{ item.first_name }} {{ item.last_name or '' }}</a></span>
|
||||||
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
|
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
|
||||||
{% if item.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}
|
{% if item.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/decisions/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/decisions/{{ item.id }}">{{ item.title }}</a></span>
|
<span class="row-title"><a href="/decisions/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
||||||
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>
|
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
|
<h1 class="page-title">Files<span class="page-count">{{ total_files }}</span></h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<form action="/files/sync" method="post" style="display:inline">
|
<form action="/files/sync" method="post" style="display:inline">
|
||||||
<button type="submit" class="btn btn-secondary">Sync Files</button>
|
<button type="submit" class="btn btn-secondary">Sync Files</button>
|
||||||
@@ -16,47 +16,140 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if folders %}
|
<form class="filters-bar" method="get" action="/files" style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 16px;">
|
||||||
<div class="filter-bar" style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
|
<input type="text" name="q" value="{{ current_q }}" class="form-input" placeholder="Search files..." style="max-width: 220px; padding: 6px 10px; font-size: 0.85rem;">
|
||||||
<a href="/files" class="btn btn-xs {{ 'btn-primary' if current_folder is none else 'btn-ghost' }}">All</a>
|
|
||||||
<a href="/files?folder=" class="btn btn-xs {{ 'btn-primary' if current_folder is not none and current_folder == '' else 'btn-ghost' }}">/</a>
|
<select name="folder" class="form-input" style="max-width: 200px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
|
||||||
|
<option value="" {{ 'selected' if current_folder is none }}>All folders</option>
|
||||||
|
<option value=" " {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
|
||||||
{% for f in folders %}
|
{% for f in folders %}
|
||||||
<a href="/files?folder={{ f }}" class="btn btn-xs {{ 'btn-primary' if current_folder == f else 'btn-ghost' }}">{{ f }}</a>
|
<option value="{{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %} {{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
|
|
||||||
|
<select name="file_type" class="form-input" style="max-width: 160px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="image" {{ 'selected' if current_type == 'image' }}>Images</option>
|
||||||
|
<option value="document" {{ 'selected' if current_type == 'document' }}>Documents</option>
|
||||||
|
<option value="text" {{ 'selected' if current_type == 'text' }}>Text</option>
|
||||||
|
<option value="spreadsheet" {{ 'selected' if current_type == 'spreadsheet' }}>Spreadsheets</option>
|
||||||
|
<option value="archive" {{ 'selected' if current_type == 'archive' }}>Archives</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% if all_tags %}
|
||||||
|
<select name="tag" class="form-input" style="max-width: 160px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{% for t in all_tags %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if current_tag == t }}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_q or current_type or current_tag or current_folder is not none %}
|
||||||
|
<input type="hidden" name="sort" value="{{ current_sort }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Search</button>
|
||||||
|
<a href="/files" class="btn btn-ghost btn-xs">Clear</a>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Search</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% set qp = [] %}
|
||||||
|
{% if current_folder is not none %}{% if current_folder == '' %}{{ qp.append('folder= ') or '' }}{% else %}{{ qp.append('folder=' ~ current_folder) or '' }}{% endif %}{% endif %}
|
||||||
|
{% if current_q %}{{ qp.append('q=' ~ current_q) or '' }}{% endif %}
|
||||||
|
{% if current_type %}{{ qp.append('file_type=' ~ current_type) or '' }}{% endif %}
|
||||||
|
{% if current_tag %}{{ qp.append('tag=' ~ current_tag) or '' }}{% endif %}
|
||||||
|
{% set filter_qs = qp | join('&') %}
|
||||||
|
{% set sort_base = '/files?' ~ (filter_qs ~ '&' if filter_qs else '') %}
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
<div class="card">
|
<div class="card" style="overflow-x: auto;">
|
||||||
|
<table class="data-table" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border); text-align: left;">
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<a href="{{ sort_base }}sort={{ 'path_desc' if current_sort == 'path' else 'path' }}" style="color: var(--muted); text-decoration: none;">
|
||||||
|
Path {{ '▲' if current_sort == 'path' else ('▼' if current_sort == 'path_desc' else '') }}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<a href="{{ sort_base }}sort={{ 'name_desc' if current_sort == 'name' else 'name' }}" style="color: var(--muted); text-decoration: none;">
|
||||||
|
Name {{ '▲' if current_sort == 'name' else ('▼' if current_sort == 'name_desc' else '') }}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Type</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Size</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<a href="{{ sort_base }}sort={{ 'date_asc' if current_sort == 'date' else 'date' }}" style="color: var(--muted); text-decoration: none;">
|
||||||
|
Date {{ '▼' if current_sort == 'date' else ('▲' if current_sort == 'date_asc' else '') }}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th style="padding: 6px 12px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<tr style="border-bottom: 1px solid var(--border);">
|
||||||
<span class="row-title">
|
<td style="padding: 6px 12px; color: var(--muted); font-size: 0.80rem;">{{ item.folder }}</td>
|
||||||
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
|
<td style="padding: 6px 12px; font-size: 0.80rem;">
|
||||||
</span>
|
<a href="/files/{{ item.id }}/preview" style="color: var(--accent);">{{ item.original_filename }}</a>
|
||||||
<span class="row-meta" style="color: var(--muted); font-size: 0.8rem;">{{ item.folder }}</span>
|
</td>
|
||||||
{% if item.mime_type %}
|
<td style="padding: 6px 12px;">
|
||||||
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
|
{% if item.mime_type %}<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
{% if item.size_bytes %}
|
<td style="padding: 6px 12px; color: var(--muted); font-size: 0.80rem; white-space: nowrap;">
|
||||||
<span class="row-meta">{{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>
|
{% if item.size_bytes %}{{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
{% if item.description %}
|
<td style="padding: 6px 12px; color: var(--muted); font-size: 0.80rem; white-space: nowrap;">
|
||||||
<span class="row-meta">{{ item.description[:50] }}</span>
|
{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '' }}
|
||||||
{% endif %}
|
</td>
|
||||||
<div class="row-actions">
|
<td style="padding: 6px 12px; text-align: right; white-space: nowrap;">
|
||||||
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a>
|
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a>
|
||||||
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
|
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
|
||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
{% set page_base = sort_base ~ 'sort=' ~ current_sort ~ '&' %}
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 16px;">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="{{ page_base }}page={{ current_page - 1 }}" class="btn btn-ghost btn-xs">Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p == current_page %}
|
||||||
|
<span class="btn btn-primary btn-xs" style="pointer-events: none;">{{ p }}</span>
|
||||||
|
{% elif p <= 3 or p >= total_pages - 2 or (p >= current_page - 1 and p <= current_page + 1) %}
|
||||||
|
<a href="{{ page_base }}page={{ p }}" class="btn btn-ghost btn-xs">{{ p }}</a>
|
||||||
|
{% elif p == 4 and current_page > 5 %}
|
||||||
|
<span style="color: var(--muted);">...</span>
|
||||||
|
{% elif p == total_pages - 3 and current_page < total_pages - 4 %}
|
||||||
|
<span style="color: var(--muted);">...</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="{{ page_base }}page={{ current_page + 1 }}" class="btn btn-ghost btn-xs">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📁</div>
|
<div class="empty-state-icon">📁</div>
|
||||||
|
{% if current_q or current_type or current_tag %}
|
||||||
|
<div class="empty-state-text">No files match your filters</div>
|
||||||
|
<a href="/files" class="btn btn-secondary">Clear Filters</a>
|
||||||
|
{% else %}
|
||||||
<div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
|
<div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
|
||||||
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
|
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,58 +12,109 @@
|
|||||||
<span class="text-sm" style="font-weight:600">{{ focus_date }}</span>
|
<span class="text-sm" style="font-weight:600">{{ focus_date }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Focus items -->
|
<!-- Focus items grouped by domain -->
|
||||||
{% if items %}
|
{% if items %}
|
||||||
{% for item in items %}
|
<div class="card">
|
||||||
<div class="focus-item {{ 'completed' if item.completed }}">
|
<table id="focus-table" style="width:100%;border-collapse:collapse;font-size:0.8rem;">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width:18px"><col style="width:24px"><col style="width:20px"><col style="width:74px"><col style="width:10px">
|
||||||
|
<col><col style="width:110px"><col style="width:120px"><col style="width:50px"><col style="width:24px">
|
||||||
|
</colgroup>
|
||||||
|
{% for domain in hierarchy %}
|
||||||
|
<tbody>
|
||||||
|
<tr class="focus-domain-header" data-domain-key="{{ domain.key }}" style="cursor:pointer;">
|
||||||
|
<td colspan="10" style="padding:2px 8px;border-bottom:1px solid var(--border);background:var(--surface2);">
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:6px;">
|
||||||
|
<svg class="focus-domain-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" style="transition:transform 0.15s;flex-shrink:0;color:var(--muted);"><polyline points="4 2 8 6 4 10"/></svg>
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:{{ domain.color or 'var(--accent)' }};flex-shrink:0;"></span>
|
||||||
|
<span style="font-weight:700;font-size:0.8rem;letter-spacing:0.03em;text-transform:uppercase;color:var(--text)">{{ domain.label }}</span>
|
||||||
|
<span style="font-size:0.72rem;color:var(--muted);font-weight:400;">{{ domain.rows|length }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody class="focus-drag-group" data-domain-key="{{ domain.key }}">
|
||||||
|
{% for item in domain.rows %}
|
||||||
|
<tr class="focus-drag-row" draggable="true" data-id="{{ item.id }}" style="border-bottom:1px solid var(--border);{{ 'opacity:0.6;' if item.completed }}">
|
||||||
|
<td class="focus-row-num" style="padding:1px 2px;vertical-align:middle;text-align:center;color:var(--muted);font-size:0.72rem;font-weight:600;">{{ loop.index }}</td>
|
||||||
|
<td style="padding:1px 1px;vertical-align:middle;">{% with reorder_url="/focus/reorder", item_id=item.id, extra_fields={"focus_date": focus_date|string} %}{% include 'partials/reorder_arrows.html' %}{% endwith %}</td>
|
||||||
|
<td style="padding:1px 1px;vertical-align:middle;">
|
||||||
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
|
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||||
<div class="row-check">
|
<div class="row-check"><input type="checkbox" id="f-{{ item.id }}" {{ 'checked' if item.completed }} onchange="this.form.submit()"><label for="f-{{ item.id }}"></label></div>
|
||||||
<input type="checkbox" id="f-{{ item.id }}" {{ 'checked' if item.completed }} onchange="this.form.submit()">
|
|
||||||
<label for="f-{{ item.id }}"></label>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<span class="priority-dot priority-{{ item.priority }}"></span>
|
</td>
|
||||||
<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>
|
<td style="padding:1px 3px;vertical-align:middle;color:var(--muted);font-size:0.78rem;white-space:nowrap;">{{ item.due_date or '' }}</td>
|
||||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
<td style="padding:1px 1px;vertical-align:middle;">{% if item.task_id %}<span class="priority-dot priority-{{ item.priority }}"></span>{% elif item.list_item_id %}<span style="color:var(--muted);font-size:0.85rem;">☰</span>{% endif %}</td>
|
||||||
{% if item.estimated_minutes %}<span class="focus-meta">~{{ item.estimated_minutes }}min</span>{% endif %}
|
<td style="padding:1px 3px;vertical-align:middle;{{ 'text-decoration:line-through;' if item.completed }}">{% if item.task_id %}{% if item.title %}<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>{% else %}<span style="color:var(--muted)">[Deleted]</span>{% endif %}{% elif item.list_item_id %}{% if item.list_item_content %}<a href="/lists/{{ item.list_item_list_id }}" class="focus-title">{{ item.list_item_content }}</a>{% else %}<span style="color:var(--muted)">[Deleted]</span>{% endif %}{% endif %}</td>
|
||||||
{% if item.due_date %}<span class="focus-meta">{{ item.due_date }}</span>{% endif %}
|
<td style="padding:1px 3px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{% if item.area_name %}<span class="row-tag">{{ item.area_name }}</span>{% endif %}</td>
|
||||||
|
<td style="padding:1px 3px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{% if item.task_id and item.project_name %}<span class="row-tag" style="background:var(--accent-soft);color:var(--accent)">{{ item.project_name }}</span>{% elif item.list_item_id and item.list_name %}<span class="row-tag" style="background:var(--purple);color:#fff">{{ item.list_name }}</span>{% endif %}</td>
|
||||||
|
<td style="padding:1px 3px;vertical-align:middle;text-align:right;color:var(--muted);font-size:0.78rem;">{{ '~%smin'|format(item.estimated_minutes) if item.estimated_minutes else '' }}</td>
|
||||||
|
<td style="padding:1px 1px;vertical-align:middle;">
|
||||||
<form action="/focus/{{ item.id }}/remove" method="post" style="display:inline">
|
<form action="/focus/{{ item.id }}/remove" method="post" style="display:inline">
|
||||||
<button class="btn btn-ghost btn-xs" style="color:var(--red)" title="Remove from focus">×</button>
|
<button class="btn btn-ghost btn-xs" style="color:var(--red)" title="Remove from focus">×</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<form id="focus-reorder-form" action="/focus/reorder-all" method="post" style="display:none;">
|
||||||
|
<input type="hidden" name="item_ids" id="focus-reorder-ids">
|
||||||
|
<input type="hidden" name="focus_date" value="{{ focus_date }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
|
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Add task to focus -->
|
<!-- Add to Focus -->
|
||||||
<div class="card mt-4">
|
<div class="card mt-3">
|
||||||
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
|
<div class="card-header" style="padding:4px 10px;"><h2 class="card-title" style="font-size:0.85rem;margin:0;">Add to Focus</h2></div>
|
||||||
|
|
||||||
<form class="filters-bar" method="get" action="/focus" id="focus-filters" style="padding: 8px 12px; border-bottom: 1px solid var(--border);">
|
<!-- Tab strip -->
|
||||||
|
<div class="tab-strip" style="padding: 0 10px;">
|
||||||
|
<a href="/focus?focus_date={{ focus_date }}&source_type=tasks{% if current_search %}&search={{ current_search }}{% endif %}{% if current_domain_id %}&domain_id={{ current_domain_id }}{% endif %}{% if current_project_id %}&project_id={{ current_project_id }}{% endif %}"
|
||||||
|
class="tab-item {{ 'active' if current_source_type == 'tasks' }}">Tasks</a>
|
||||||
|
<a href="/focus?focus_date={{ focus_date }}&source_type=list_items{% if current_search %}&search={{ current_search }}{% endif %}{% if current_domain_id %}&domain_id={{ current_domain_id }}{% endif %}{% if current_project_id %}&project_id={{ current_project_id }}{% endif %}"
|
||||||
|
class="tab-item {{ 'active' if current_source_type == 'list_items' }}">List Items</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form class="filters-bar" method="get" action="/focus" id="focus-filters" style="padding: 4px 10px; border-bottom: 1px solid var(--border);">
|
||||||
<input type="hidden" name="focus_date" value="{{ focus_date }}">
|
<input type="hidden" name="focus_date" value="{{ focus_date }}">
|
||||||
|
<input type="hidden" name="source_type" value="{{ current_source_type }}">
|
||||||
|
<input type="text" name="search" value="{{ current_search }}" placeholder="Search..." class="filter-select" style="min-width:150px">
|
||||||
<select name="domain_id" class="filter-select" id="focus-domain" onchange="this.form.submit()">
|
<select name="domain_id" class="filter-select" id="focus-domain" onchange="this.form.submit()">
|
||||||
<option value="">All Domains</option>
|
<option value="">All Domains</option>
|
||||||
{% for d in domains %}
|
{% for d in domains %}
|
||||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
{% if current_source_type == 'tasks' %}
|
||||||
<select name="area_id" class="filter-select" onchange="this.form.submit()">
|
<select name="area_id" class="filter-select" onchange="this.form.submit()">
|
||||||
<option value="">All Areas</option>
|
<option value="">All Areas</option>
|
||||||
{% for a in areas %}
|
{% for a in areas %}
|
||||||
<option value="{{ a.id }}" {{ 'selected' if current_area_id == a.id|string }}>{{ a.name }}</option>
|
<option value="{{ a.id }}" {{ 'selected' if current_area_id == a.id|string }}>{{ a.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
{% endif %}
|
||||||
<select name="project_id" class="filter-select" id="focus-project" onchange="this.form.submit()">
|
<select name="project_id" class="filter-select" id="focus-project" onchange="this.form.submit()">
|
||||||
<option value="">All Projects</option>
|
<option value="">All Projects</option>
|
||||||
{% for p in projects %}
|
{% for p in projects %}
|
||||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs">Search</button>
|
||||||
|
{% if current_search or current_domain_id or current_area_id or current_project_id %}
|
||||||
|
<a href="/focus?focus_date={{ focus_date }}&source_type={{ current_source_type }}" class="btn btn-ghost btn-xs" style="color:var(--red)">Clear</a>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% for t in available_tasks[:15] %}
|
<!-- Available tasks -->
|
||||||
<div class="list-row">
|
{% if current_source_type == 'tasks' %}
|
||||||
|
{% for t in available_tasks %}
|
||||||
|
<div class="list-row{% if loop.index > 25 %} focus-hidden-item hidden{% endif %}" style="padding:3px 8px;min-height:0;">
|
||||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||||
<span class="row-title">{{ t.title }}</span>
|
<span class="row-title">{{ t.title }}</span>
|
||||||
{% if t.project_name %}<span class="row-tag">{{ t.project_name }}</span>{% endif %}
|
{% if t.project_name %}<span class="row-tag">{{ t.project_name }}</span>{% endif %}
|
||||||
@@ -75,8 +126,36 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No available tasks matching filters</div>
|
<div style="padding: 8px 10px; color: var(--muted); font-size: 0.8rem;">No available tasks matching filters</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if available_tasks|length > 25 %}
|
||||||
|
<div style="padding: 4px 10px; text-align:center;" id="focus-show-more-wrap">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="focus-show-more">Show all {{ available_tasks|length }} tasks</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Available list items -->
|
||||||
|
{% elif current_source_type == 'list_items' %}
|
||||||
|
{% for li in available_list_items %}
|
||||||
|
<div class="list-row{% if loop.index > 25 %} focus-hidden-item hidden{% endif %}" style="padding:3px 8px;min-height:0;">
|
||||||
|
<span style="color:var(--muted);font-size:0.85rem;margin-right:4px">☰</span>
|
||||||
|
<span class="row-title">{{ li.content|autolink }}</span>
|
||||||
|
{% if li.list_name %}<span class="row-tag" style="background:var(--purple);color:#fff">{{ li.list_name }}</span>{% endif %}
|
||||||
|
<form action="/focus/add" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="list_item_id" value="{{ li.id }}">
|
||||||
|
<input type="hidden" name="focus_date" value="{{ focus_date }}">
|
||||||
|
<button class="btn btn-ghost btn-xs" style="color:var(--green)">+ Focus</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="padding: 8px 10px; color: var(--muted); font-size: 0.8rem;">No available list items matching filters</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if available_list_items|length > 25 %}
|
||||||
|
<div style="padding: 4px 10px; text-align:center;" id="focus-show-more-wrap">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="focus-show-more">Show all {{ available_list_items|length }} items</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -86,9 +165,10 @@
|
|||||||
var currentProjectId = '{{ current_project_id }}';
|
var currentProjectId = '{{ current_project_id }}';
|
||||||
var form = document.getElementById('focus-filters');
|
var form = document.getElementById('focus-filters');
|
||||||
|
|
||||||
|
if (domainSel) {
|
||||||
domainSel.addEventListener('change', function() {
|
domainSel.addEventListener('change', function() {
|
||||||
var did = domainSel.value;
|
var did = domainSel.value;
|
||||||
if (!did) { form.submit(); return; }
|
if (!did || !projectSel) { form.submit(); return; }
|
||||||
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(projects) {
|
.then(function(projects) {
|
||||||
@@ -104,6 +184,114 @@
|
|||||||
})
|
})
|
||||||
.catch(function() { form.submit(); });
|
.catch(function() { form.submit(); });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show more button
|
||||||
|
var showMoreBtn = document.getElementById('focus-show-more');
|
||||||
|
if (showMoreBtn) {
|
||||||
|
showMoreBtn.addEventListener('click', function() {
|
||||||
|
var hidden = document.querySelectorAll('.focus-hidden-item.hidden');
|
||||||
|
hidden.forEach(function(el) { el.classList.remove('hidden'); });
|
||||||
|
document.getElementById('focus-show-more-wrap').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain group collapse/expand
|
||||||
|
document.querySelectorAll('.focus-domain-header').forEach(function(header) {
|
||||||
|
var key = 'focus-domain-' + header.dataset.domainKey;
|
||||||
|
var tbody = document.querySelector('tbody.focus-drag-group[data-domain-key="' + header.dataset.domainKey + '"]');
|
||||||
|
var chevron = header.querySelector('.focus-domain-chevron');
|
||||||
|
// Restore saved state
|
||||||
|
if (localStorage.getItem(key) === 'true') {
|
||||||
|
tbody.style.display = 'none';
|
||||||
|
chevron.style.transform = 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
chevron.style.transform = 'rotate(90deg)';
|
||||||
|
}
|
||||||
|
header.addEventListener('click', function() {
|
||||||
|
var hidden = tbody.style.display === 'none';
|
||||||
|
tbody.style.display = hidden ? '' : 'none';
|
||||||
|
chevron.style.transform = hidden ? 'rotate(90deg)' : 'rotate(0deg)';
|
||||||
|
localStorage.setItem(key, !hidden);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renumber rows within each domain group
|
||||||
|
function renumberFocusRows() {
|
||||||
|
document.querySelectorAll('.focus-drag-group').forEach(function(tbody) {
|
||||||
|
var rows = tbody.querySelectorAll('.focus-drag-row');
|
||||||
|
rows.forEach(function(row, i) {
|
||||||
|
var numCell = row.querySelector('.focus-row-num');
|
||||||
|
if (numCell) numCell.textContent = i + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag-and-drop reorder within domain groups
|
||||||
|
var dragRow = null;
|
||||||
|
var dragGroup = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('.focus-drag-row').forEach(function(row) {
|
||||||
|
row.addEventListener('dragstart', function(e) {
|
||||||
|
dragRow = row;
|
||||||
|
dragGroup = row.closest('.focus-drag-group');
|
||||||
|
row.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', row.dataset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dragend', function() {
|
||||||
|
row.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.focus-drag-row.drag-over').forEach(function(el) {
|
||||||
|
el.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
// Collect all IDs across all groups in DOM order and submit
|
||||||
|
if (dragRow) {
|
||||||
|
var allIds = [];
|
||||||
|
document.querySelectorAll('#focus-table .focus-drag-row').forEach(function(r) {
|
||||||
|
allIds.push(r.dataset.id);
|
||||||
|
});
|
||||||
|
document.getElementById('focus-reorder-ids').value = allIds.join(',');
|
||||||
|
document.getElementById('focus-reorder-form').submit();
|
||||||
|
}
|
||||||
|
dragRow = null;
|
||||||
|
dragGroup = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragRow || row === dragRow) return;
|
||||||
|
// Constrain to same domain group
|
||||||
|
if (row.closest('.focus-drag-group') !== dragGroup) return;
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
document.querySelectorAll('.focus-drag-row.drag-over').forEach(function(el) {
|
||||||
|
el.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
row.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dragleave', function() {
|
||||||
|
row.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragRow || row === dragRow) return;
|
||||||
|
if (row.closest('.focus-drag-group') !== dragGroup) return;
|
||||||
|
row.classList.remove('drag-over');
|
||||||
|
// Insert dragged row before or after target based on position
|
||||||
|
var tbody = dragGroup;
|
||||||
|
var rows = Array.from(tbody.querySelectorAll('.focus-drag-row'));
|
||||||
|
var dragIdx = rows.indexOf(dragRow);
|
||||||
|
var targetIdx = rows.indexOf(row);
|
||||||
|
if (dragIdx < targetIdx) {
|
||||||
|
tbody.insertBefore(dragRow, row.nextSibling);
|
||||||
|
} else {
|
||||||
|
tbody.insertBefore(dragRow, row);
|
||||||
|
}
|
||||||
|
renumberFocusRows();
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/links/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
||||||
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
|
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
|
||||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||||
|
|||||||
@@ -37,7 +37,15 @@
|
|||||||
|
|
||||||
<!-- Add item form -->
|
<!-- Add item form -->
|
||||||
<form class="quick-add mt-3" action="/lists/{{ item.id }}/items/add" method="post">
|
<form class="quick-add mt-3" action="/lists/{{ item.id }}/items/add" method="post">
|
||||||
<input type="text" name="content" placeholder="Add item..." required>
|
<input type="text" name="content" id="add-item-content" placeholder="Add item..." required style="flex:1">
|
||||||
|
{% if all_links %}
|
||||||
|
<select class="link-picker" data-target="add-item-content" style="max-width:180px">
|
||||||
|
<option value="">Insert link...</option>
|
||||||
|
{% for lnk in all_links %}
|
||||||
|
<option value="{{ lnk.url }}">{{ lnk.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -46,6 +54,9 @@
|
|||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
{% for li in list_items %}
|
{% for li in list_items %}
|
||||||
<div class="list-row {{ 'completed' if li.completed }}">
|
<div class="list-row {{ 'completed' if li.completed }}">
|
||||||
|
{% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=li.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.list_type == 'checklist' %}
|
{% if item.list_type == 'checklist' %}
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
|
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
|
||||||
@@ -55,19 +66,37 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if li.completed }}">
|
<span class="row-title" data-display-for="{{ li.id }}" style="{{ 'text-decoration: line-through; opacity: 0.6;' if li.completed }}">
|
||||||
{{ li.content }}
|
{{ li.content|autolink }}
|
||||||
</span>
|
</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions" data-display-for="{{ li.id }}">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs edit-item-btn" data-item-id="{{ li.id }}">Edit</button>
|
||||||
<form action="/lists/{{ item.id }}/items/{{ li.id }}/delete" method="post" style="display:inline">
|
<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>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<form class="inline-edit-form" data-edit-for="{{ li.id }}" action="/lists/{{ item.id }}/items/{{ li.id }}/edit" method="post" style="display:none;">
|
||||||
|
<input type="text" name="content" id="edit-content-{{ li.id }}" value="{{ li.content }}" required style="flex:1">
|
||||||
|
{% if all_links %}
|
||||||
|
<select class="link-picker" data-target="edit-content-{{ li.id }}" style="max-width:160px">
|
||||||
|
<option value="">Insert link...</option>
|
||||||
|
{% for lnk in all_links %}
|
||||||
|
<option value="{{ lnk.url }}">{{ lnk.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Save</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs cancel-edit-btn" data-item-id="{{ li.id }}">Cancel</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Child items -->
|
<!-- Child items -->
|
||||||
{% for child in child_map.get(li.id|string, []) %}
|
{% for child in child_map.get(li.id|string, []) %}
|
||||||
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
|
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
|
||||||
|
{% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=child.id, extra_fields={"parent_id": li.id|string} %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.list_type == 'checklist' %}
|
{% if item.list_type == 'checklist' %}
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">
|
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">
|
||||||
@@ -77,14 +106,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if child.completed }}">
|
<span class="row-title" data-display-for="{{ child.id }}" style="{{ 'text-decoration: line-through; opacity: 0.6;' if child.completed }}">
|
||||||
{{ child.content }}
|
{{ child.content|autolink }}
|
||||||
</span>
|
</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions" data-display-for="{{ child.id }}">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs edit-item-btn" data-item-id="{{ child.id }}">Edit</button>
|
||||||
<form action="/lists/{{ item.id }}/items/{{ child.id }}/delete" method="post" style="display:inline">
|
<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>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<form class="inline-edit-form" data-edit-for="{{ child.id }}" action="/lists/{{ item.id }}/items/{{ child.id }}/edit" method="post" style="display:none;">
|
||||||
|
<input type="text" name="content" id="edit-content-{{ child.id }}" value="{{ child.content }}" required style="flex:1">
|
||||||
|
{% if all_links %}
|
||||||
|
<select class="link-picker" data-target="edit-content-{{ child.id }}" style="max-width:160px">
|
||||||
|
<option value="">Insert link...</option>
|
||||||
|
{% for lnk in all_links %}
|
||||||
|
<option value="{{ lnk.url }}">{{ lnk.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Save</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs cancel-edit-btn" data-item-id="{{ child.id }}">Cancel</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -129,4 +173,49 @@
|
|||||||
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
|
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Link picker: insert URL at cursor position in target input
|
||||||
|
document.querySelectorAll('.link-picker').forEach(function(sel) {
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
var url = sel.value;
|
||||||
|
if (!url) return;
|
||||||
|
var targetId = sel.getAttribute('data-target');
|
||||||
|
var input = document.getElementById(targetId);
|
||||||
|
if (!input) return;
|
||||||
|
var start = input.selectionStart || input.value.length;
|
||||||
|
var end = input.selectionEnd || input.value.length;
|
||||||
|
var before = input.value.substring(0, start);
|
||||||
|
var after = input.value.substring(end);
|
||||||
|
// Add space padding if needed
|
||||||
|
if (before.length > 0 && before[before.length - 1] !== ' ') before += ' ';
|
||||||
|
if (after.length > 0 && after[0] !== ' ') url += ' ';
|
||||||
|
input.value = before + url + after;
|
||||||
|
input.focus();
|
||||||
|
var pos = before.length + url.length;
|
||||||
|
input.setSelectionRange(pos, pos);
|
||||||
|
sel.selectedIndex = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline edit: toggle edit form
|
||||||
|
document.querySelectorAll('.edit-item-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-item-id');
|
||||||
|
document.querySelectorAll('[data-display-for="' + id + '"]').forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
var form = document.querySelector('[data-edit-for="' + id + '"]');
|
||||||
|
if (form) { form.style.display = 'flex'; form.querySelector('input[name="content"]').focus(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.cancel-edit-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-item-id');
|
||||||
|
document.querySelectorAll('[data-display-for="' + id + '"]').forEach(function(el) { el.style.display = ''; });
|
||||||
|
var form = document.querySelector('[data-edit-for="' + id + '"]');
|
||||||
|
if (form) form.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/lists/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
|
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
|
||||||
<span class="row-meta">
|
<span class="row-meta">
|
||||||
{{ item.completed_count }}/{{ item.item_count }} items
|
{{ item.completed_count }}/{{ item.item_count }} items
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/meetings/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
|
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
<span class="row-meta">{{ item.meeting_date }}</span>
|
<span class="row-meta">{{ item.meeting_date }}</span>
|
||||||
{% if item.location %}
|
{% if item.location %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if item.body %}
|
{% if item.body %}
|
||||||
<div class="card"><div class="detail-body" style="white-space:pre-wrap;font-family:var(--font-body)">{{ item.body }}</div></div>
|
<div class="card" style="overflow:hidden"><div class="detail-body" style="white-space:pre-wrap;font-family:var(--font-body);margin-right:20px">{{ item.body }}</div></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card"><div class="text-muted" style="padding:20px">No content yet. <a href="/notes/{{ item.id }}/edit">Start writing</a></div></div>
|
<div class="card"><div class="text-muted" style="padding:20px">No content yet. <a href="/notes/{{ item.id }}/edit">Start writing</a></div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/notes/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
||||||
<span class="row-title"><a href="/notes/{{ item.id }}">{{ item.title }}</a></span>
|
<span class="row-title"><a href="/notes/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||||
|
|||||||
30
templates/partials/reorder_arrows.html
Normal file
30
templates/partials/reorder_arrows.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{#
|
||||||
|
Reorder grip handle. Include with:
|
||||||
|
{% with reorder_url="/focus/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
Required context vars:
|
||||||
|
reorder_url - POST endpoint for the reorder action
|
||||||
|
item_id - ID of the current item
|
||||||
|
Optional:
|
||||||
|
extra_fields - dict of extra hidden fields (e.g. {"focus_date": "2026-03-03"})
|
||||||
|
#}
|
||||||
|
<span class="reorder-grip">
|
||||||
|
<form action="{{ reorder_url }}" method="post">
|
||||||
|
<input type="hidden" name="item_id" value="{{ item_id }}">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
{% if extra_fields %}{% for k, v in extra_fields.items() %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}{% endif %}
|
||||||
|
<button type="submit" class="grip-btn" title="Move up">⠛</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ reorder_url }}" method="post">
|
||||||
|
<input type="hidden" name="item_id" value="{{ item_id }}">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
{% if extra_fields %}{% for k, v in extra_fields.items() %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}{% endif %}
|
||||||
|
<button type="submit" class="grip-btn" title="Move down">⠓</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/tasks/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
<a href="/tasks/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||||
|
<form action="/tasks/{{ item.id }}/delete" method="post" data-confirm="Delete this task?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||||
<button class="btn {{ 'btn-secondary' if item.status == 'done' else 'btn-primary' }} btn-sm">
|
<button class="btn {{ 'btn-secondary' if item.status == 'done' else 'btn-primary' }} btn-sm">
|
||||||
{{ 'Reopen' if item.status == 'done' else 'Complete' }}
|
{{ 'Reopen' if item.status == 'done' else 'Complete' }}
|
||||||
|
|||||||
@@ -116,4 +116,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% if item %}
|
||||||
|
<div style="margin-top:12px;text-align:right;">
|
||||||
|
<form action="/tasks/{{ item.id }}/delete" method="post" data-confirm="Delete this task?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete Task</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<form class="filters-bar" method="get" action="/tasks">
|
<form class="filters-bar" method="get" action="/tasks">
|
||||||
|
<input type="text" name="search" value="{{ current_search }}" placeholder="Search tasks..." class="filter-select" style="min-width:160px">
|
||||||
<select name="status" class="filter-select" data-auto-submit onchange="this.form.submit()">
|
<select name="status" class="filter-select" data-auto-submit onchange="this.form.submit()">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
|
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
|
||||||
@@ -46,6 +47,10 @@
|
|||||||
<option value="created_at" {{ 'selected' if current_sort == 'created_at' }}>Newest</option>
|
<option value="created_at" {{ 'selected' if current_sort == 'created_at' }}>Newest</option>
|
||||||
<option value="title" {{ 'selected' if current_sort == 'title' }}>Title</option>
|
<option value="title" {{ 'selected' if current_sort == 'title' }}>Title</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs">Search</button>
|
||||||
|
{% if current_search or current_domain_id or current_project_id or current_status or current_priority or current_context %}
|
||||||
|
<a href="/tasks" class="btn btn-ghost btn-xs" style="color:var(--red)">Clear</a>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Task List -->
|
<!-- Task List -->
|
||||||
@@ -53,6 +58,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
|
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
|
||||||
|
{% with reorder_url="/tasks/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||||
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
|
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
{% set entry_date = entry.start_at.strftime('%A, %B %-d') if entry.start_at else 'Unknown' %}
|
{% set entry_date = entry.start_at.strftime('%A, %B %-d') if entry.start_at else 'Unknown' %}
|
||||||
{% if entry_date != current_date.value %}
|
{% if entry_date != current_date.value %}
|
||||||
<div style="padding: 10px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
<div style="padding: 6px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
||||||
{{ entry_date }}
|
{{ entry_date }}
|
||||||
</div>
|
</div>
|
||||||
{% set current_date.value = entry_date %}
|
{% set current_date.value = entry_date %}
|
||||||
|
|||||||
Reference in New Issue
Block a user